diff --git a/backend/.env.example b/backend/.env.example index 97a2170..2e5a88c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,3 +24,8 @@ RATE_LIMIT_MAX_REQUESTS=100 # Logging LOG_LEVEL=info + +# Authentication (JWT) +# Generate a strong secret: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +JWT_SECRET=your-jwt-secret-min-32-chars +JWT_EXPIRES_IN=7d diff --git a/backend/package-lock.json b/backend/package-lock.json index 5ae931e..3fa19f3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@google/genai": "^1.0.0", + "bcryptjs": "^3.0.3", "compression": "^1.7.5", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -17,6 +18,7 @@ "express-rate-limit": "^7.4.0", "express-validator": "^7.2.0", "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.3", "morgan": "^1.10.0", "pg": "^8.13.0", "winston": "^3.14.0" @@ -1880,6 +1882,15 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -4562,6 +4573,40 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4662,6 +4707,42 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4669,6 +4750,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", diff --git a/backend/package.json b/backend/package.json index 24ab347..8b12370 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,15 +22,17 @@ "license": "MIT", "dependencies": { "@google/genai": "^1.0.0", + "bcryptjs": "^3.0.3", "compression": "^1.7.5", - "pg": "^8.13.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", "express-rate-limit": "^7.4.0", "express-validator": "^7.2.0", "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.3", "morgan": "^1.10.0", + "pg": "^8.13.0", "winston": "^3.14.0" }, "devDependencies": { diff --git a/backend/src/config/index.js b/backend/src/config/index.js index df3ffd5..57872ec 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -40,7 +40,7 @@ const validateEnvVars = (requiredVars) => { }; // Validate critical environment variables -const requiredVars = ['DATABASE_URL']; +const requiredVars = ['DATABASE_URL', 'JWT_SECRET']; // Only validate in production, allow fallbacks in development if (process.env.NODE_ENV === 'production') { @@ -64,6 +64,13 @@ const config = { url: process.env.DATABASE_URL || '', }, + // Authentication (JWT) + auth: { + jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production', + jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d', + bcryptRounds: 12, + }, + // Gemini AI configuration gemini: { apiKey: process.env.GEMINI_API_KEY || '', diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js new file mode 100644 index 0000000..71c247d --- /dev/null +++ b/backend/src/controllers/authController.js @@ -0,0 +1,84 @@ +/** + * Auth Controller + * Handles HTTP request/response for authentication endpoints. + * Validates input, delegates to authService, and formats responses. + */ + +import { asyncHandler, APIError } from '../middleware/errorHandler.js'; +import * as authService from '../services/authService.js'; + +/** + * POST /api/v1/auth/register + * Create a new user account. + * Body: { email, password, name? } + */ +export const register = asyncHandler(async (req, res) => { + const { email, password, name } = req.body; + + // Input validation + if (!email || !password) { + throw new APIError('Email and password are required', 400, 'VALIDATION_ERROR'); + } + + // Basic email format check + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + throw new APIError('Invalid email format', 400, 'VALIDATION_ERROR'); + } + + if (password.length < 8) { + throw new APIError('Password must be at least 8 characters', 400, 'VALIDATION_ERROR'); + } + + if (password.length > 128) { + throw new APIError('Password must be at most 128 characters', 400, 'VALIDATION_ERROR'); + } + + try { + const result = await authService.registerUser(email, password, name); + res.status(201).json({ success: true, data: result }); + } catch (err) { + if (err.statusCode === 409) { + throw new APIError(err.message, 409, err.code || 'EMAIL_EXISTS'); + } + throw err; + } +}); + +/** + * POST /api/v1/auth/login + * Authenticate with email and password. + * Body: { email, password } + */ +export const login = asyncHandler(async (req, res) => { + const { email, password } = req.body; + + if (!email || !password) { + throw new APIError('Email and password are required', 400, 'VALIDATION_ERROR'); + } + + try { + const result = await authService.loginUser(email, password); + res.json({ success: true, data: result }); + } catch (err) { + if (err.statusCode === 401) { + throw new APIError(err.message, 401, err.code || 'INVALID_CREDENTIALS'); + } + throw err; + } +}); + +/** + * GET /api/v1/auth/me + * Get the current user's profile. + * Requires: Authorization: Bearer + */ +export const me = asyncHandler(async (req, res) => { + const user = await authService.getUserById(req.user.id); + + if (!user) { + throw new APIError('User not found', 404, 'NOT_FOUND'); + } + + res.json({ success: true, data: { user } }); +}); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..adda0ce --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,59 @@ +/** + * JWT Authentication Middleware + * Extracts and verifies JWT from the Authorization header. + * Sets req.user = { id, email } on success. + */ + +import jwt from 'jsonwebtoken'; +import config from '../config/index.js'; +import { APIError } from './errorHandler.js'; + +/** + * requireAuth — blocks unauthenticated requests with 401. + * Expects header: Authorization: Bearer + */ +export const requireAuth = (req, _res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new APIError('Authentication required', 401, 'UNAUTHORIZED'); + } + + const token = authHeader.split(' ')[1]; + + try { + const decoded = jwt.verify(token, config.auth.jwtSecret); + req.user = { id: decoded.sub, email: decoded.email }; + next(); + } catch (err) { + if (err.name === 'TokenExpiredError') { + throw new APIError('Token expired — please log in again', 401, 'TOKEN_EXPIRED'); + } + throw new APIError('Invalid token', 401, 'INVALID_TOKEN'); + } +}; + +/** + * optionalAuth — attaches user if a valid token is present, but does NOT block. + * Useful for routes that work for both guests and logged-in users. + * Sets req.user = null when no token or invalid token. + */ +export const optionalAuth = (req, _res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + req.user = null; + return next(); + } + + const token = authHeader.split(' ')[1]; + + try { + const decoded = jwt.verify(token, config.auth.jwtSecret); + req.user = { id: decoded.sub, email: decoded.email }; + } catch { + req.user = null; + } + + next(); +}; diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 580972e..c83b5a4 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -5,4 +5,5 @@ export { APIError, notFoundHandler, errorHandler, asyncHandler } from './errorHandler.js'; export { validate, validationRules } from './validation.js'; -export { generalLimiter, aiLimiter, ttsLimiter } from './rateLimiter.js'; +export { generalLimiter, aiLimiter, ttsLimiter, authLimiter } from './rateLimiter.js'; +export { requireAuth, optionalAuth } from './auth.js'; diff --git a/backend/src/middleware/rateLimiter.js b/backend/src/middleware/rateLimiter.js index f8d8f7e..7c094da 100644 --- a/backend/src/middleware/rateLimiter.js +++ b/backend/src/middleware/rateLimiter.js @@ -73,8 +73,21 @@ export const ttsLimiter = rateLimit({ handler: createLimitHandler('tts'), }); +/** + * Strict limiter for authentication endpoints + * Protects against brute-force attacks + */ +export const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, // Limit each IP to 20 requests per window + standardHeaders: true, + legacyHeaders: false, + handler: createLimitHandler('auth'), +}); + export default { generalLimiter, aiLimiter, ttsLimiter, + authLimiter, }; diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js new file mode 100644 index 0000000..904ff88 --- /dev/null +++ b/backend/src/routes/authRoutes.js @@ -0,0 +1,27 @@ +/** + * Auth Routes + * Handles user registration, login, and profile endpoints. + * + * POST /register — Create a new account (open registration) + * POST /login — Authenticate and receive JWT + * GET /me — Get current user profile (requires auth) + */ + +import { Router } from 'express'; +import * as authController from '../controllers/authController.js'; +import { requireAuth } from '../middleware/auth.js'; +import { authLimiter } from '../middleware/rateLimiter.js'; + +const router = Router(); + +// Apply strict rate limiting to auth routes +router.use(authLimiter); + +// Public routes +router.post('/register', authController.register); +router.post('/login', authController.login); + +// Protected routes +router.get('/me', requireAuth, authController.me); + +export default router; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index edb5434..17abf40 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -4,6 +4,7 @@ */ import { Router } from 'express'; +import authRoutes from './authRoutes.js'; import transcriptRoutes from './transcriptRoutes.js'; import aiRoutes from './aiRoutes.js'; import healthRoutes from './healthRoutes.js'; @@ -11,6 +12,7 @@ import healthRoutes from './healthRoutes.js'; const router = Router(); // Mount route modules +router.use('/auth', authRoutes); router.use('/transcripts', transcriptRoutes); router.use('/ai', aiRoutes); router.use('/health', healthRoutes); @@ -23,6 +25,11 @@ router.get('/', (req, res) => { version: '1.0.0', documentation: { endpoints: { + auth: { + 'POST /api/v1/auth/register': 'Create a new user account', + 'POST /api/v1/auth/login': 'Authenticate and receive JWT', + 'GET /api/v1/auth/me': 'Get current user profile (requires auth)', + }, transcripts: { 'GET /api/v1/transcripts/conferences/summary': 'Get lean conference summary', 'GET /api/v1/transcripts': 'Get all raw transcripts', diff --git a/backend/src/services/authService.js b/backend/src/services/authService.js new file mode 100644 index 0000000..27354a2 --- /dev/null +++ b/backend/src/services/authService.js @@ -0,0 +1,139 @@ +/** + * Auth Service + * Handles user registration, login, and JWT token generation. + * All database operations use the shared query() from supabaseService. + */ + +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import config from '../config/index.js'; +import logger from '../config/logger.js'; +import { query } from './supabaseService.js'; + +/** + * Register a new user with email and password. + * @param {string} email - User email + * @param {string} password - Plain-text password (will be hashed) + * @param {string} [name] - Optional display name + * @returns {Promise<{user: Object, token: string}>} + * @throws {Error} 409 if email already registered + */ +export const registerUser = async (email, password, name) => { + // Check if user already exists + const existing = await query( + 'SELECT id FROM users WHERE email = $1', + [email.toLowerCase().trim()] + ); + + if (existing.rows.length > 0) { + const error = new Error('Email already registered'); + error.statusCode = 409; + error.code = 'EMAIL_EXISTS'; + throw error; + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, config.auth.bcryptRounds); + + // Insert user + const result = await query( + `INSERT INTO users (email, password, name) + VALUES ($1, $2, $3) + RETURNING id, email, name, avatar_url, created_at`, + [email.toLowerCase().trim(), hashedPassword, name?.trim() || null] + ); + + const user = result.rows[0]; + const token = generateToken(user); + + logger.info(`New user registered: ${user.email}`); + + return { + user: sanitizeUser(user), + token, + }; +}; + +/** + * Log in with email and password. + * @param {string} email - User email + * @param {string} password - Plain-text password + * @returns {Promise<{user: Object, token: string}>} + * @throws {Error} 401 if credentials are invalid + */ +export const loginUser = async (email, password) => { + const result = await query( + 'SELECT id, email, name, password, avatar_url, created_at FROM users WHERE email = $1', + [email.toLowerCase().trim()] + ); + + if (result.rows.length === 0) { + const error = new Error('Invalid email or password'); + error.statusCode = 401; + error.code = 'INVALID_CREDENTIALS'; + throw error; + } + + const user = result.rows[0]; + const isValid = await bcrypt.compare(password, user.password); + + if (!isValid) { + const error = new Error('Invalid email or password'); + error.statusCode = 401; + error.code = 'INVALID_CREDENTIALS'; + throw error; + } + + const token = generateToken(user); + + logger.info(`User logged in: ${user.email}`); + + return { + user: sanitizeUser(user), + token, + }; +}; + +/** + * Get a user's public profile by ID. + * @param {string} userId - User UUID + * @returns {Promise} User profile or null + */ +export const getUserById = async (userId) => { + const result = await query( + 'SELECT id, email, name, avatar_url, created_at FROM users WHERE id = $1', + [userId] + ); + + if (result.rows.length === 0) { + return null; + } + + return sanitizeUser(result.rows[0]); +}; + +/** + * Generate a signed JWT for a user. + * @param {Object} user - User record (must have id and email) + * @returns {string} Signed JWT + */ +const generateToken = (user) => { + return jwt.sign( + { sub: user.id, email: user.email }, + config.auth.jwtSecret, + { expiresIn: config.auth.jwtExpiresIn } + ); +}; + +/** + * Strip sensitive fields (password hash) from a user record. + * @param {Object} user - Raw database row + * @returns {Object} Safe user object for API responses + */ +const sanitizeUser = (user) => ({ + id: user.id, + email: user.email, + name: user.name, + avatarUrl: user.avatar_url, + createdAt: user.created_at, +}); diff --git a/backend/src/services/supabaseService.js b/backend/src/services/supabaseService.js index d6eec68..14683b7 100644 --- a/backend/src/services/supabaseService.js +++ b/backend/src/services/supabaseService.js @@ -522,6 +522,9 @@ export const fetchTranscriptMeta = async () => { return { speakers, topics, conferences, tags, stats }; }; +// Named export for use by other services (e.g. authService) +export { query }; + export default { fetchTranscriptSummaries, fetchAllTranscripts, diff --git a/backend/supabase/migrations/002_auth_users.sql b/backend/supabase/migrations/002_auth_users.sql new file mode 100644 index 0000000..2880504 --- /dev/null +++ b/backend/supabase/migrations/002_auth_users.sql @@ -0,0 +1,36 @@ +-- ============================================================ +-- Auth Users Migration +-- Creates the users table for JWT-based authentication. +-- Portable — no vendor dependency (Supabase, Clerk, etc.) +-- ============================================================ + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, -- bcrypt hash + name TEXT, + avatar_url TEXT, + provider TEXT DEFAULT 'local', -- 'local' | 'github' (future OAuth) + provider_id TEXT, -- external provider user ID (future) + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id); + +-- Auto-update updated_at on row modification +CREATE OR REPLACE FUNCTION update_users_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_users_updated_at(); diff --git a/services/api.ts b/services/api.ts index c57c3b5..0bfc71c 100644 --- a/services/api.ts +++ b/services/api.ts @@ -53,6 +53,14 @@ const createTimeoutController = (timeoutMs: number): { controller: AbortControll return { controller, timeoutId }; }; +/** + * Helper to get the auth header from local storage + */ +function getAuthHeader(): Record { + const token = localStorage.getItem('btc-auth-token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + /** * Make an HTTP request to the backend API * @param endpoint - API endpoint path @@ -73,6 +81,7 @@ async function request(endpoint: string, options: RequestOptions = {}): Promi try { const requestHeaders: Record = { 'Content-Type': 'application/json', + ...getAuthHeader(), ...headers, }; @@ -101,6 +110,20 @@ async function request(endpoint: string, options: RequestOptions = {}): Promi throw new APIError('Invalid response format from server', 500, 'INVALID_RESPONSE'); } + // Handle 401 Unauthorized (invalid or expired token) + if (response.status === 401) { + localStorage.removeItem('btc-auth-token'); + // If we are not already on the login page and it's not a login attempt + if (!window.location.pathname.includes('/login') && !endpoint.includes('/auth/login')) { + // We could redirect to a login page, but since we use a modal, + // we'll just clear the token. The AuthContext or ProtectedRoute will handle the UI. + // For hard routes, a reload forces the ProtectedRoute to trigger. + if (window.location.pathname === '/audio') { + window.location.reload(); + } + } + } + // Handle error responses if (!response.ok) { throw new APIError( diff --git a/services/authService.ts b/services/authService.ts new file mode 100644 index 0000000..cb363d2 --- /dev/null +++ b/services/authService.ts @@ -0,0 +1,29 @@ +/** + * Auth API Service + * Handles all HTTP calls to the auth endpoints. + */ + +import api from './api'; +import type { AuthResponse, User } from '@/types/auth'; + +export const authApi = { + /** + * Register a new user account. + */ + register: (email: string, password: string, name?: string) => + api.post('/api/v1/auth/register', { email, password, name }), + + /** + * Log in with email and password. + */ + login: (email: string, password: string) => + api.post('/api/v1/auth/login', { email, password }), + + /** + * Get the current user's profile (requires valid JWT). + */ + me: () => + api.get<{ user: User }>('/api/v1/auth/me'), +}; + +export default authApi; diff --git a/services/config.ts b/services/config.ts index 428bcc4..a0a2493 100644 --- a/services/config.ts +++ b/services/config.ts @@ -10,6 +10,12 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000'; export const config = { apiUrl: API_BASE_URL, endpoints: { + // Auth + auth: { + register: '/api/v1/auth/register', + login: '/api/v1/auth/login', + me: '/api/v1/auth/me', + }, // Transcripts conferencesSummary: '/api/v1/transcripts/conferences/summary', conferences: '/api/v1/transcripts/conferences', diff --git a/src/App.tsx b/src/App.tsx index ac40526..43f1a95 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { Layout } from "@/components/Layout"; import { ScrollToTop } from "@/components/ScrollToTop"; +import { AuthProvider } from "@/contexts/AuthContext"; +import { LoginModal } from "@/components/LoginModal"; +import { ProtectedRoute } from "@/components/ProtectedRoute"; import Index from "./pages/Index"; import Topics from "./pages/Topics"; import Speakers from "./pages/Speakers"; @@ -14,6 +17,7 @@ import ConferenceArchive from "./pages/ConferenceArchive"; import Library from "./pages/Library"; import About from "./pages/About"; import SearchResults from "./pages/SearchResults"; +import AudioGeneration from "./pages/AudioGeneration"; import NotFound from "./pages/NotFound"; const queryClient = new QueryClient({ @@ -29,28 +33,42 @@ const queryClient = new QueryClient({ const App = () => ( - - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Protected Routes */} + + + + } + /> + + } /> + + + + + + ); diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index ca845ec..52dd8f8 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -6,6 +6,15 @@ import { motion, AnimatePresence } from "framer-motion"; import type { SearchResult, PaginatedResponse } from "../../types"; import { useBookmarks } from "@/hooks/useBookmarks"; import { useConferences, useSearch } from "@/hooks/useTranscripts"; +import { useAuth } from "@/hooks/useAuth"; +import { LogOut } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; const navItems = [ { label: "Explore", path: "/topics" }, @@ -69,6 +78,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => { const [recentSearches, setRecentSearches] = useState(getRecentSearches()); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + const { user, logout, openLoginModal } = useAuth(); // Get bookmark count for nav badge const { totalCount } = useBookmarks(); @@ -240,11 +250,52 @@ export const Layout = ({ children }: { children: React.ReactNode }) => { + {/* Auth button/avatar */} + {user ? ( + + + + + + + {user.name || 'User'} + {user.email} + + + + Profile + + logout()} className="cursor-pointer text-destructive focus:text-destructive"> + + Log out + + + + ) : ( + + )} + {/* Mobile menu */} diff --git a/src/components/LoginModal.tsx b/src/components/LoginModal.tsx new file mode 100644 index 0000000..2e57cd4 --- /dev/null +++ b/src/components/LoginModal.tsx @@ -0,0 +1,324 @@ +/** + * LoginModal + * A dialog-based login/register form. + * Uses the existing Dialog and Tabs UI components. + * Controlled by AuthContext (openLoginModal / closeLoginModal). + */ + +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { LogIn, UserPlus, Mail, Lock, User, Loader2, AlertCircle, Eye, EyeOff } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAuth } from '@/hooks/useAuth'; + +export function LoginModal() { + const { isLoginModalOpen, closeLoginModal, login, register } = useAuth(); + + const [activeTab, setActiveTab] = useState<'login' | 'register'>('login'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Login form state + const [loginEmail, setLoginEmail] = useState(''); + const [loginPassword, setLoginPassword] = useState(''); + const [showLoginPassword, setShowLoginPassword] = useState(false); + + // Register form state + const [regName, setRegName] = useState(''); + const [regEmail, setRegEmail] = useState(''); + const [regPassword, setRegPassword] = useState(''); + const [regConfirm, setRegConfirm] = useState(''); + const [showRegPassword, setShowRegPassword] = useState(false); + + const resetForms = () => { + setLoginEmail(''); + setLoginPassword(''); + setRegName(''); + setRegEmail(''); + setRegPassword(''); + setRegConfirm(''); + setError(null); + setShowLoginPassword(false); + setShowRegPassword(false); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + closeLoginModal(); + resetForms(); + } + }; + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsSubmitting(true); + + try { + await login(loginEmail, loginPassword); + resetForms(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } finally { + setIsSubmitting(false); + } + }; + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (regPassword !== regConfirm) { + setError('Passwords do not match'); + return; + } + + if (regPassword.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + setIsSubmitting(true); + + try { + await register(regEmail, regPassword, regName || undefined); + resetForms(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + Welcome to BitScribe + + + Sign in to save notes, bookmarks, and access audio features. + + + +
+ { + setActiveTab(v as 'login' | 'register'); + setError(null); + }} + className="w-full" + > + + + + Sign In + + + + Create Account + + + + {/* Error banner */} + + {error && ( + + + {error} + + )} + + + {/* Login Form */} + +
+
+ +
+ + setLoginEmail(e.target.value)} + className="pl-9" + required + autoComplete="email" + disabled={isSubmitting} + /> +
+
+ +
+ +
+ + setLoginPassword(e.target.value)} + className="pl-9 pr-10" + required + autoComplete="current-password" + disabled={isSubmitting} + /> + +
+
+ + +
+
+ + {/* Register Form */} + +
+
+ +
+ + setRegName(e.target.value)} + className="pl-9" + autoComplete="name" + disabled={isSubmitting} + /> +
+
+ +
+ +
+ + setRegEmail(e.target.value)} + className="pl-9" + required + autoComplete="email" + disabled={isSubmitting} + /> +
+
+ +
+ +
+ + setRegPassword(e.target.value)} + className="pl-9 pr-10" + required + minLength={8} + autoComplete="new-password" + disabled={isSubmitting} + /> + +
+
+ +
+ +
+ + setRegConfirm(e.target.value)} + className="pl-9" + required + minLength={8} + autoComplete="new-password" + disabled={isSubmitting} + /> +
+
+ + +
+
+
+
+
+
+ ); +} diff --git a/src/components/LoginPrompt.tsx b/src/components/LoginPrompt.tsx new file mode 100644 index 0000000..5bcd95e --- /dev/null +++ b/src/components/LoginPrompt.tsx @@ -0,0 +1,34 @@ +/** + * LoginPrompt + * Inline placeholder shown to unauthenticated users trying to access + * protected component-level features (like the Notes panel). + */ + +import { LogIn } from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; + +interface LoginPromptProps { + message?: string; +} + +export function LoginPrompt({ message = 'Sign in to access this feature' }: LoginPromptProps) { + const { openLoginModal } = useAuth(); + + return ( +
+
+ +
+

Authentication Required

+

+ {message} +

+ +
+ ); +} diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..9dc9e7a --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,50 @@ +/** + * ProtectedRoute + * Wraps routes that require authentication. + * If the user is not logged in, it prompts them to log in via the AuthContext modal. + * While loading, it displays a loading spinner. + */ + +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; + +interface ProtectedRouteProps { + children: React.ReactNode; + redirectTo?: string; +} + +export function ProtectedRoute({ children, redirectTo = '/' }: ProtectedRouteProps) { + const { user, isLoading, openLoginModal, isLoginModalOpen } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + // If we've finished loading and there is no user, trigger the login modal + if (!isLoading && !user && !isLoginModalOpen) { + openLoginModal(); + } + }, [isLoading, user, openLoginModal, isLoginModalOpen]); + + useEffect(() => { + // If the modal was opened by this route but then closed without a successful login, + // redirect them away to avoid being stuck on an empty/unauthorized page. + if (!isLoading && !user && !isLoginModalOpen) { + navigate(redirectTo, { replace: true }); + } + }, [isLoginModalOpen, isLoading, user, navigate, redirectTo]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!user) { + return null; // Don't render protected content; useEffect will redirect/prompt + } + + return <>{children}; +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..6a0e391 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,137 @@ +/** + * Auth Context + * Provides authentication state and methods to the entire app. + * + * Responsibilities: + * - Stores user and token in React state + * - Persists token to localStorage (key: btc-auth-token) + * - On mount: validates stored token via GET /auth/me + * - Provides login(), register(), logout() + * - Controls the login modal open/close state + */ + +import { + createContext, + useState, + useEffect, + useCallback, + useMemo, + type ReactNode, +} from 'react'; +import { toast } from 'sonner'; +import { authApi } from '../../services/authService'; +import type { User, AuthContextType } from '@/types/auth'; + +const AUTH_TOKEN_KEY = 'btc-auth-token'; + +export const AuthContext = createContext(null); + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(() => + localStorage.getItem(AUTH_TOKEN_KEY) + ); + const [isLoading, setIsLoading] = useState(true); + const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); + + /** + * Persist or clear the token in localStorage + */ + const persistToken = useCallback((newToken: string | null) => { + if (newToken) { + localStorage.setItem(AUTH_TOKEN_KEY, newToken); + } else { + localStorage.removeItem(AUTH_TOKEN_KEY); + } + setToken(newToken); + }, []); + + /** + * On mount — if we have a stored token, validate it by calling /auth/me. + * If the token is expired or invalid, clear it silently. + */ + useEffect(() => { + const validateToken = async () => { + if (!token) { + setIsLoading(false); + return; + } + + try { + // Temporarily inject the token header for this request + const data = await authApi.me(); + setUser(data.user); + } catch { + // Token is invalid or expired — clear silently + persistToken(null); + setUser(null); + } finally { + setIsLoading(false); + } + }; + + validateToken(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Only run on mount + + /** + * Log in with email and password + */ + const login = useCallback( + async (email: string, password: string) => { + const data = await authApi.login(email, password); + persistToken(data.token); + setUser(data.user); + setIsLoginModalOpen(false); + toast.success(`Welcome back, ${data.user.name || data.user.email}!`); + }, + [persistToken] + ); + + /** + * Register a new account + */ + const register = useCallback( + async (email: string, password: string, name?: string) => { + const data = await authApi.register(email, password, name); + persistToken(data.token); + setUser(data.user); + setIsLoginModalOpen(false); + toast.success('Account created! Welcome aboard.'); + }, + [persistToken] + ); + + /** + * Log out — clear token and user state + */ + const logout = useCallback(() => { + persistToken(null); + setUser(null); + toast.success('Logged out'); + }, [persistToken]); + + const openLoginModal = useCallback(() => setIsLoginModalOpen(true), []); + const closeLoginModal = useCallback(() => setIsLoginModalOpen(false), []); + + const value = useMemo( + () => ({ + user, + token, + isLoading, + login, + register, + logout, + openLoginModal, + closeLoginModal, + isLoginModalOpen, + }), + [user, token, isLoading, login, register, logout, openLoginModal, closeLoginModal, isLoginModalOpen] + ); + + return {children}; +} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..99f3543 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,19 @@ +/** + * useAuth Hook + * Convenience wrapper for accessing AuthContext. + * Throws if used outside of AuthProvider. + */ + +import { useContext } from 'react'; +import { AuthContext } from '@/contexts/AuthContext'; +import type { AuthContextType } from '@/types/auth'; + +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + + return context; +} diff --git a/src/pages/TranscriptDetail.tsx b/src/pages/TranscriptDetail.tsx index 4240d8f..e21396d 100644 --- a/src/pages/TranscriptDetail.tsx +++ b/src/pages/TranscriptDetail.tsx @@ -18,6 +18,8 @@ import { formatDate, cn } from "@/lib/utils"; import { generateSummary } from "../../services/geminiService"; import type { RawTranscript } from "../../types"; import { useTranscript } from "@/hooks/useTranscripts"; +import { useAuth } from "@/hooks/useAuth"; +import { LoginPrompt } from "@/components/LoginPrompt"; type TabType = "summary" | "transcript" | "chat" | "audio" | "notes" | "whiteboard"; @@ -50,6 +52,7 @@ const TranscriptDetail = () => { const transcriptRef = useRef(null); const { getHighlightsForTranscript, removeHighlight, updateHighlightNote } = useBookmarks(); const { getNotesForTranscript, addNote } = useNotes(); + const { user } = useAuth(); const { data, isLoading, error } = useTranscript(id); const transcript = data ?? null; @@ -656,7 +659,11 @@ const TranscriptDetail = () => { {activeTab === "whiteboard" && id && ( - + {user ? ( + + ) : ( + + )} )} @@ -696,12 +703,16 @@ const TranscriptDetail = () => {
{rightTab === "notes" && ( - setPendingNoteText(undefined)} - /> + user ? ( + setPendingNoteText(undefined)} + /> + ) : ( + + ) )} {rightTab === "chat" && ( { )} {rightTab === "canvas" && (
- + {user ? ( + + ) : ( + + )}
)}
diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..5849da8 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,29 @@ +/** + * Auth Types + * Shared type definitions for the authentication system. + */ + +export interface User { + id: string; + email: string; + name: string | null; + avatarUrl: string | null; + createdAt: string; +} + +export interface AuthResponse { + user: User; + token: string; +} + +export interface AuthContextType { + user: User | null; + token: string | null; + isLoading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, name?: string) => Promise; + logout: () => void; + openLoginModal: () => void; + closeLoginModal: () => void; + isLoginModalOpen: boolean; +}