From c7a3362d9de49385d7b50a18f235929db5140abd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:25:25 +0000 Subject: [PATCH 1/2] Initial plan From 0faf73317083995a3208fb64516be6e0776b6d0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:37:21 +0000 Subject: [PATCH 2/2] Implement user account feature: API users table, repo, routes, frontend auth, register, account pages Co-authored-by: webmaxru <1560278+webmaxru@users.noreply.github.com> --- api/sql/migrations/002_users.sql | 14 ++ api/sql/seed/005_users.sql | 5 + api/src/index.ts | 2 + api/src/models/user.ts | 72 +++++++ api/src/repositories/usersRepo.test.ts | 217 +++++++++++++++++++++ api/src/repositories/usersRepo.ts | 252 +++++++++++++++++++++++++ api/src/routes/user.ts | 228 ++++++++++++++++++++++ frontend/src/App.tsx | 4 + frontend/src/api/config.ts | 1 + frontend/src/components/Account.tsx | 152 +++++++++++++++ frontend/src/components/Login.tsx | 21 ++- frontend/src/components/Navigation.tsx | 11 +- frontend/src/components/Register.tsx | 162 ++++++++++++++++ frontend/src/context/AuthContext.tsx | 57 ++++-- frontend/src/utils/apiError.ts | 12 ++ 15 files changed, 1186 insertions(+), 24 deletions(-) create mode 100644 api/sql/migrations/002_users.sql create mode 100644 api/sql/seed/005_users.sql create mode 100644 api/src/models/user.ts create mode 100644 api/src/repositories/usersRepo.test.ts create mode 100644 api/src/repositories/usersRepo.ts create mode 100644 api/src/routes/user.ts create mode 100644 frontend/src/components/Account.tsx create mode 100644 frontend/src/components/Register.tsx create mode 100644 frontend/src/utils/apiError.ts diff --git a/api/sql/migrations/002_users.sql b/api/sql/migrations/002_users.sql new file mode 100644 index 0000000..aa26914 --- /dev/null +++ b/api/sql/migrations/002_users.sql @@ -0,0 +1,14 @@ +-- Migration 002: Add users table for account management + +CREATE TABLE users ( + user_id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CHECK (role IN ('user', 'admin')) +); + +-- Index for fast email lookup (used during login) +CREATE INDEX idx_users_email ON users(email); diff --git a/api/sql/seed/005_users.sql b/api/sql/seed/005_users.sql new file mode 100644 index 0000000..e14b0b7 --- /dev/null +++ b/api/sql/seed/005_users.sql @@ -0,0 +1,5 @@ +-- Seed data: default user accounts +-- Passwords: admin user = 'admin123', regular user = 'user1234' +INSERT INTO users (email, password_hash, name, role) VALUES + ('admin@github.com', '2a55ec229a234af5674125fc39868abc:aa19bd0c8ffd4c6ba927cfe08885678d4356d996e360db78207855ebcf63364b9d67354b2e24a837675ae100ea9f06313466c8a9afc15e1c7154397178525683', 'Admin User', 'admin'), + ('user@example.com', '189d0438941db1deb15302f68139be65:09d4e4ba164546fd11e77e6ddfbf5b77b82427d74a901df9e8e9ca784628337480353f35e73c85cd4d5096daafef21e464d6d6a45e3c1e30b08bb1c57cbdf6f5', 'Regular User', 'user'); diff --git a/api/src/index.ts b/api/src/index.ts index f5dd34e..a1e5cec 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -10,6 +10,7 @@ import orderRoutes from './routes/order'; import branchRoutes from './routes/branch'; import headquartersRoutes from './routes/headquarters'; import supplierRoutes from './routes/supplier'; +import userRoutes from './routes/user'; import { initializeDatabase } from './init-db'; import { errorHandler } from './utils/errors'; @@ -78,6 +79,7 @@ app.use('/api/orders', orderRoutes); app.use('/api/branches', branchRoutes); app.use('/api/headquarters', headquartersRoutes); app.use('/api/suppliers', supplierRoutes); +app.use('/api/users', userRoutes); app.get('/', (req, res) => { res.send('Hello, world!'); diff --git a/api/src/models/user.ts b/api/src/models/user.ts new file mode 100644 index 0000000..311caf8 --- /dev/null +++ b/api/src/models/user.ts @@ -0,0 +1,72 @@ +/** + * @swagger + * components: + * schemas: + * User: + * type: object + * required: + * - userId + * - email + * - name + * - role + * properties: + * userId: + * type: integer + * description: The unique identifier for the user + * email: + * type: string + * format: email + * description: The user's email address (unique) + * name: + * type: string + * description: The user's display name + * role: + * type: string + * enum: [user, admin] + * description: The user's role + * createdAt: + * type: string + * format: date-time + * description: The timestamp when the account was created + * UserRegistration: + * type: object + * required: + * - email + * - password + * - name + * properties: + * email: + * type: string + * format: email + * password: + * type: string + * minLength: 8 + * name: + * type: string + * role: + * type: string + * enum: [user, admin] + * default: user + * UserLogin: + * type: object + * required: + * - email + * - password + * properties: + * email: + * type: string + * format: email + * password: + * type: string + */ +export interface User { + userId: number; + email: string; + name: string; + role: 'user' | 'admin'; + createdAt: string; +} + +export interface UserWithPassword extends User { + passwordHash: string; +} diff --git a/api/src/repositories/usersRepo.test.ts b/api/src/repositories/usersRepo.test.ts new file mode 100644 index 0000000..b513dff --- /dev/null +++ b/api/src/repositories/usersRepo.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { UsersRepository, hashPassword, verifyPassword } from '../repositories/usersRepo'; +import { NotFoundError, ValidationError } from '../utils/errors'; + +// Mock the getDatabase function first +vi.mock('../db/sqlite', () => ({ + getDatabase: vi.fn(), +})); + +import { getDatabase } from '../db/sqlite'; + +describe('hashPassword / verifyPassword', () => { + it('should produce a hash that verifies correctly', () => { + const hash = hashPassword('secret123'); + expect(verifyPassword('secret123', hash)).toBe(true); + }); + + it('should reject an incorrect password', () => { + const hash = hashPassword('secret123'); + expect(verifyPassword('wrong', hash)).toBe(false); + }); + + it('should produce different hashes for the same password (random salt)', () => { + const h1 = hashPassword('secret123'); + const h2 = hashPassword('secret123'); + expect(h1).not.toBe(h2); + }); +}); + +describe('UsersRepository', () => { + let repository: UsersRepository; + let mockDb: any; + + const dbRow = { + user_id: 1, + email: 'alice@example.com', + name: 'Alice', + role: 'user', + created_at: '2024-01-01T00:00:00.000Z', + password_hash: hashPassword('password1'), + }; + + beforeEach(() => { + mockDb = { + db: {} as any, + run: vi.fn(), + get: vi.fn(), + all: vi.fn(), + close: vi.fn(), + }; + + (getDatabase as any).mockResolvedValue(mockDb); + repository = new UsersRepository(mockDb); + vi.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return all users without password hashes', async () => { + mockDb.all.mockResolvedValue([dbRow]); + + const result = await repository.findAll(); + + expect(mockDb.all).toHaveBeenCalledWith( + 'SELECT user_id, email, name, role, created_at FROM users ORDER BY user_id', + ); + expect(result).toHaveLength(1); + expect(result[0].userId).toBe(1); + expect(result[0].email).toBe('alice@example.com'); + expect((result[0] as any).passwordHash).toBeUndefined(); + }); + + it('should return empty array when no users exist', async () => { + mockDb.all.mockResolvedValue([]); + const result = await repository.findAll(); + expect(result).toEqual([]); + }); + }); + + describe('findById', () => { + it('should return user when found', async () => { + mockDb.get.mockResolvedValue(dbRow); + + const result = await repository.findById(1); + + expect(result?.userId).toBe(1); + expect(result?.email).toBe('alice@example.com'); + expect((result as any)?.passwordHash).toBeUndefined(); + }); + + it('should return null when user not found', async () => { + mockDb.get.mockResolvedValue(undefined); + const result = await repository.findById(999); + expect(result).toBeNull(); + }); + }); + + describe('findByEmail', () => { + it('should return user with password hash when found', async () => { + mockDb.get.mockResolvedValue(dbRow); + + const result = await repository.findByEmail('alice@example.com'); + + expect(result?.email).toBe('alice@example.com'); + expect(result?.passwordHash).toBeDefined(); + }); + + it('should return null when email not found', async () => { + mockDb.get.mockResolvedValue(undefined); + const result = await repository.findByEmail('nobody@example.com'); + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + it('should create a new user and return it without password hash', async () => { + mockDb.run.mockResolvedValue({ lastID: 2, changes: 1 }); + mockDb.get.mockResolvedValue({ + user_id: 2, + email: 'bob@example.com', + name: 'Bob', + role: 'user', + created_at: '2024-01-02T00:00:00.000Z', + }); + + const result = await repository.create({ + email: 'bob@example.com', + password: 'securePass1', + name: 'Bob', + }); + + expect(result.userId).toBe(2); + expect(result.email).toBe('bob@example.com'); + expect((result as any).passwordHash).toBeUndefined(); + }); + + it('should throw ValidationError when password is too short', async () => { + await expect( + repository.create({ email: 'x@y.com', password: 'short', name: 'X' }), + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when required fields are missing', async () => { + await expect( + repository.create({ email: '', password: 'longenough', name: 'X' }), + ).rejects.toThrow(ValidationError); + }); + }); + + describe('update', () => { + it('should update user name and return updated user', async () => { + mockDb.run.mockResolvedValue({ changes: 1 }); + mockDb.get.mockResolvedValue({ ...dbRow, name: 'Alice Updated' }); + + const result = await repository.update(1, { name: 'Alice Updated' }); + + expect(result.name).toBe('Alice Updated'); + }); + + it('should throw NotFoundError when user does not exist', async () => { + mockDb.run.mockResolvedValue({ changes: 0 }); + await expect(repository.update(999, { name: 'Nobody' })).rejects.toThrow(NotFoundError); + }); + }); + + describe('delete', () => { + it('should delete existing user', async () => { + mockDb.run.mockResolvedValue({ changes: 1 }); + await repository.delete(1); + expect(mockDb.run).toHaveBeenCalledWith('DELETE FROM users WHERE user_id = ?', [1]); + }); + + it('should throw NotFoundError when user does not exist', async () => { + mockDb.run.mockResolvedValue({ changes: 0 }); + await expect(repository.delete(999)).rejects.toThrow(NotFoundError); + }); + }); + + describe('validateCredentials', () => { + it('should return user when credentials are valid', async () => { + const pw = 'password1'; + const hash = hashPassword(pw); + mockDb.get.mockResolvedValue({ ...dbRow, password_hash: hash }); + + const result = await repository.validateCredentials('alice@example.com', pw); + + expect(result).not.toBeNull(); + expect(result?.email).toBe('alice@example.com'); + expect((result as any)?.passwordHash).toBeUndefined(); + }); + + it('should return null when password is wrong', async () => { + mockDb.get.mockResolvedValue(dbRow); + const result = await repository.validateCredentials('alice@example.com', 'wrongpassword'); + expect(result).toBeNull(); + }); + + it('should return null when email is not found', async () => { + mockDb.get.mockResolvedValue(undefined); + const result = await repository.validateCredentials('nobody@example.com', 'pass'); + expect(result).toBeNull(); + }); + }); + + describe('exists', () => { + it('should return true when user exists', async () => { + mockDb.get.mockResolvedValue({ count: 1 }); + const result = await repository.exists(1); + expect(result).toBe(true); + }); + + it('should return false when user does not exist', async () => { + mockDb.get.mockResolvedValue({ count: 0 }); + const result = await repository.exists(999); + expect(result).toBe(false); + }); + }); +}); diff --git a/api/src/repositories/usersRepo.ts b/api/src/repositories/usersRepo.ts new file mode 100644 index 0000000..8e1661e --- /dev/null +++ b/api/src/repositories/usersRepo.ts @@ -0,0 +1,252 @@ +/** + * Repository for users data access + */ + +import { scryptSync, randomBytes, timingSafeEqual } from 'crypto'; +import { getDatabase, DatabaseConnection } from '../db/sqlite'; +import { User, UserWithPassword } from '../models/user'; +import { handleDatabaseError, NotFoundError, ConflictError, ValidationError } from '../utils/errors'; +import { buildInsertSQL, buildUpdateSQL, objectToCamelCase } from '../utils/sql'; + +/** + * Hash a plain-text password using scrypt with a random salt + */ +export function hashPassword(password: string): string { + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, 64).toString('hex'); + return `${salt}:${hash}`; +} + +/** + * Verify a plain-text password against a stored hash + */ +export function verifyPassword(password: string, stored: string): boolean { + const [salt, hash] = stored.split(':'); + if (!salt || !hash) return false; + const derivedKey = scryptSync(password, salt, 64); + const storedHash = Buffer.from(hash, 'hex'); + return timingSafeEqual(derivedKey, storedHash); +} + +export class UsersRepository { + private db: DatabaseConnection; + + constructor(db: DatabaseConnection) { + this.db = db; + } + + /** + * Map a raw database row to a User object (excluding passwordHash) + */ + private rowToUser(row: any): User { + const camel = objectToCamelCase(row) as any; + return { + userId: camel.userId, + email: camel.email, + name: camel.name, + role: camel.role, + createdAt: camel.createdAt, + }; + } + + /** + * Map a raw database row to a UserWithPassword object + */ + private rowToUserWithPassword(row: any): UserWithPassword { + const camel = objectToCamelCase(row) as any; + return { + userId: camel.userId, + email: camel.email, + name: camel.name, + role: camel.role, + createdAt: camel.createdAt, + passwordHash: camel.passwordHash, + }; + } + + /** + * Get all users (without password hashes) + */ + async findAll(): Promise { + try { + const rows = await this.db.all( + 'SELECT user_id, email, name, role, created_at FROM users ORDER BY user_id', + ); + return rows.map((row) => this.rowToUser(row)); + } catch (error) { + handleDatabaseError(error); + } + } + + /** + * Get user by ID (without password hash) + */ + async findById(id: number): Promise { + try { + const row = await this.db.get( + 'SELECT user_id, email, name, role, created_at FROM users WHERE user_id = ?', + [id], + ); + return row ? this.rowToUser(row) : null; + } catch (error) { + handleDatabaseError(error); + } + } + + /** + * Get user by email (includes password hash for authentication) + */ + async findByEmail(email: string): Promise { + try { + const row = await this.db.get( + 'SELECT user_id, email, name, role, created_at, password_hash FROM users WHERE email = ?', + [email], + ); + return row ? this.rowToUserWithPassword(row) : null; + } catch (error) { + handleDatabaseError(error); + } + } + + /** + * Create a new user account + */ + async create(data: { + email: string; + password: string; + name: string; + role?: 'user' | 'admin'; + }): Promise { + try { + if (!data.email || !data.password || !data.name) { + throw new ValidationError('email, password, and name are required'); + } + if (data.password.length < 8) { + throw new ValidationError('password must be at least 8 characters'); + } + + const passwordHash = hashPassword(data.password); + const { sql, values } = buildInsertSQL('users', { + email: data.email, + passwordHash, + name: data.name, + role: data.role ?? 'user', + }); + const result = await this.db.run(sql, values); + + const created = await this.findById(result.lastID!); + if (!created) { + throw new Error('Failed to retrieve created user'); + } + return created; + } catch (error) { + handleDatabaseError(error); + } + } + + /** + * Update user profile (name and/or role). Password changes use updatePassword. + */ + async update( + id: number, + data: Partial<{ name: string; role: 'user' | 'admin' }>, + ): Promise { + try { + const { sql, values } = buildUpdateSQL('users', data, 'user_id = ?'); + const result = await this.db.run(sql, [...values, id]); + + if (result.changes === 0) { + throw new NotFoundError('User', id); + } + + const updated = await this.findById(id); + if (!updated) { + throw new Error('Failed to retrieve updated user'); + } + return updated; + } catch (error) { + handleDatabaseError(error, 'User', id); + } + } + + /** + * Update a user's password + */ + async updatePassword(id: number, newPassword: string): Promise { + try { + if (newPassword.length < 8) { + throw new ValidationError('password must be at least 8 characters'); + } + const passwordHash = hashPassword(newPassword); + const result = await this.db.run( + 'UPDATE users SET password_hash = ? WHERE user_id = ?', + [passwordHash, id], + ); + if (result.changes === 0) { + throw new NotFoundError('User', id); + } + } catch (error) { + handleDatabaseError(error, 'User', id); + } + } + + /** + * Delete user by ID + */ + async delete(id: number): Promise { + try { + const result = await this.db.run('DELETE FROM users WHERE user_id = ?', [id]); + if (result.changes === 0) { + throw new NotFoundError('User', id); + } + } catch (error) { + handleDatabaseError(error, 'User', id); + } + } + + /** + * Validate login credentials. Returns the user if credentials match, null otherwise. + */ + async validateCredentials(email: string, password: string): Promise { + try { + const userWithPw = await this.findByEmail(email); + if (!userWithPw) return null; + if (!verifyPassword(password, userWithPw.passwordHash)) return null; + const { passwordHash: _, ...user } = userWithPw; + return user as User; + } catch (error) { + handleDatabaseError(error); + } + } + + /** + * Check whether a user with the given ID exists + */ + async exists(id: number): Promise { + try { + const result = await this.db.get<{ count: number }>( + 'SELECT COUNT(*) as count FROM users WHERE user_id = ?', + [id], + ); + return (result?.count || 0) > 0; + } catch (error) { + handleDatabaseError(error); + } + } +} + +// Factory function +export async function createUsersRepository(isTest: boolean = false): Promise { + const db = await getDatabase(isTest); + return new UsersRepository(db); +} + +// Singleton instances (separate for test and production) +const usersRepoInstances = new Map(); + +export async function getUsersRepository(isTest: boolean = false): Promise { + if (!usersRepoInstances.has(isTest)) { + usersRepoInstances.set(isTest, await createUsersRepository(isTest)); + } + return usersRepoInstances.get(isTest)!; +} diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts new file mode 100644 index 0000000..a2e43d4 --- /dev/null +++ b/api/src/routes/user.ts @@ -0,0 +1,228 @@ +/** + * @swagger + * tags: + * name: Users + * description: API endpoints for user account management + */ + +/** + * @swagger + * /api/users/login: + * post: + * summary: Authenticate a user and return their profile + * tags: [Users] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UserLogin' + * responses: + * 200: + * description: Login successful + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * 401: + * description: Invalid email or password + * + * /api/users/register: + * post: + * summary: Register a new user account + * tags: [Users] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UserRegistration' + * responses: + * 201: + * description: User created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * 400: + * description: Validation error + * 409: + * description: Email already in use + * + * /api/users: + * get: + * summary: Returns all user accounts + * tags: [Users] + * responses: + * 200: + * description: List of all users + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/User' + * + * /api/users/{id}: + * get: + * summary: Get a user by ID + * tags: [Users] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: User ID + * responses: + * 200: + * description: User found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * 404: + * description: User not found + * put: + * summary: Update a user's profile + * tags: [Users] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: User ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * role: + * type: string + * enum: [user, admin] + * responses: + * 200: + * description: User updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * 404: + * description: User not found + * delete: + * summary: Delete a user account + * tags: [Users] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: User ID + * responses: + * 204: + * description: User deleted successfully + * 404: + * description: User not found + */ + +import express from 'express'; +import { getUsersRepository } from '../repositories/usersRepo'; +import { NotFoundError } from '../utils/errors'; + +const router = express.Router(); + +// POST /api/users/login – authenticate +router.post('/login', async (req, res, next) => { + try { + const { email, password } = req.body; + if (!email || !password) { + res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'email and password are required' } }); + return; + } + const repo = await getUsersRepository(); + const user = await repo.validateCredentials(email, password); + if (!user) { + res.status(401).json({ error: { code: 'UNAUTHORIZED', message: 'Invalid email or password' } }); + return; + } + res.json(user); + } catch (error) { + next(error); + } +}); + +// POST /api/users/register – create account +router.post('/register', async (req, res, next) => { + try { + const repo = await getUsersRepository(); + const newUser = await repo.create(req.body); + res.status(201).json(newUser); + } catch (error) { + next(error); + } +}); + +// GET /api/users – list all users +router.get('/', async (req, res, next) => { + try { + const repo = await getUsersRepository(); + const users = await repo.findAll(); + res.json(users); + } catch (error) { + next(error); + } +}); + +// GET /api/users/:id – get user by ID +router.get('/:id', async (req, res, next) => { + try { + const repo = await getUsersRepository(); + const user = await repo.findById(parseInt(req.params.id)); + if (user) { + res.json(user); + } else { + res.status(404).json({ error: { code: 'NOT_FOUND', message: 'User not found' } }); + } + } catch (error) { + next(error); + } +}); + +// PUT /api/users/:id – update user profile +router.put('/:id', async (req, res, next) => { + try { + const repo = await getUsersRepository(); + const { name, role } = req.body; + const updatedUser = await repo.update(parseInt(req.params.id), { name, role }); + res.json(updatedUser); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: { code: 'NOT_FOUND', message: 'User not found' } }); + } else { + next(error); + } + } +}); + +// DELETE /api/users/:id – delete user +router.delete('/:id', async (req, res, next) => { + try { + const repo = await getUsersRepository(); + await repo.delete(parseInt(req.params.id)); + res.status(204).send(); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: { code: 'NOT_FOUND', message: 'User not found' } }); + } else { + next(error); + } + } +}); + +export default router; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 29b9f53..f52677e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,8 @@ import About from './components/About'; import Footer from './components/Footer'; import Products from './components/entity/product/Products'; import Login from './components/Login'; +import Register from './components/Register'; +import Account from './components/Account'; import { AuthProvider } from './context/AuthContext'; import { ThemeProvider } from './context/ThemeContext'; import AdminProducts from './components/admin/AdminProducts'; @@ -26,6 +28,8 @@ function ThemedApp() { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 932a9a2..3059bb3 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -47,5 +47,6 @@ export const api = { deliveries: '/api/deliveries', orderDetails: '/api/order-details', orderDetailDeliveries: '/api/order-detail-deliveries', + users: '/api/users', }, }; diff --git a/frontend/src/components/Account.tsx b/frontend/src/components/Account.tsx new file mode 100644 index 0000000..12dd9ee --- /dev/null +++ b/frontend/src/components/Account.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { Navigate } from 'react-router-dom'; +import axios from 'axios'; +import { useAuth } from '../context/AuthContext'; +import { useTheme } from '../context/ThemeContext'; +import { API_BASE_URL, api } from '../api/config'; +import { extractApiErrorMessage } from '../utils/apiError'; + +export default function Account() { + const { isLoggedIn, currentUser, updateCurrentUser, logout } = useAuth(); + const { darkMode } = useTheme(); + + const [editName, setEditName] = useState(currentUser?.name ?? ''); + const [successMsg, setSuccessMsg] = useState(''); + const [errorMsg, setErrorMsg] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + if (!isLoggedIn || !currentUser) { + return ; + } + + const handleUpdateName = async (e: React.FormEvent) => { + e.preventDefault(); + setSuccessMsg(''); + setErrorMsg(''); + setIsSaving(true); + try { + await axios.put(`${API_BASE_URL}${api.endpoints.users}/${currentUser.userId}`, { + name: editName, + }); + updateCurrentUser({ name: editName }); + setSuccessMsg('Name updated successfully.'); + } catch (err: unknown) { + setErrorMsg(extractApiErrorMessage(err, 'Failed to update profile. Please try again.')); + } finally { + setIsSaving(false); + } + }; + + const labelCls = `block text-sm font-medium ${darkMode ? 'text-gray-400' : 'text-gray-500'} mb-1`; + const valueCls = `${darkMode ? 'text-light' : 'text-gray-800'} font-medium`; + const cardCls = `${darkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg shadow-lg p-6 transition-colors duration-300`; + + return ( +
+
+

+ My Account +

+ + {/* Profile Info */} +
+

+ Account Details +

+
+
+
Email
+
{currentUser.email}
+
+
+
Role
+
+ + {currentUser.role} + +
+
+
+
Member since
+
+ {new Date(currentUser.createdAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} +
+
+
+
+ + {/* Edit Name */} +
+

+ Update Display Name +

+ + {successMsg && ( +
+ {successMsg} +
+ )} + {errorMsg && ( +
+ {errorMsg} +
+ )} + +
+
+ + setEditName(e.target.value)} + className={`w-full ${darkMode ? 'bg-gray-700 text-light' : 'bg-gray-100 text-gray-800'} rounded px-3 py-2 transition-colors duration-300`} + required + /> +
+ +
+
+ + {/* Danger Zone */} +
+

Sign Out

+

+ You will be signed out of your account on this device. +

+ +
+
+
+ ); +} diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 6429c7f..f6b8bbc 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { useTheme } from '../context/ThemeContext'; +import { extractApiErrorMessage } from '../utils/apiError'; export default function Login() { const [email, setEmail] = useState(''); @@ -24,8 +25,8 @@ export default function Login() { try { await login(email, password); navigate('/'); - } catch { - setError('Login failed. Please try again.'); + } catch (err: unknown) { + setError(extractApiErrorMessage(err, 'Invalid email or password. Please try again.')); } }; @@ -43,10 +44,9 @@ export default function Login() { {error && ( -
+
+ {error} +
)}
@@ -92,6 +92,13 @@ export default function Login() { Login
+ +

+ Don't have an account?{' '} + + Register + +

); diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index 5f35e12..baf3983 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -4,7 +4,7 @@ import { useTheme } from '../context/ThemeContext'; import { useState } from 'react'; export default function Navigation() { - const { isLoggedIn, isAdmin, logout } = useAuth(); + const { isLoggedIn, isAdmin, currentUser, logout } = useAuth(); const { darkMode, toggleTheme } = useTheme(); const [adminMenuOpen, setAdminMenuOpen] = useState(false); @@ -116,12 +116,13 @@ export default function Navigation() { {isLoggedIn ? ( <> - {isAdmin && (Admin) } - Welcome! - + {currentUser?.name ?? 'Account'} + + + +

+ Already have an account?{' '} + + Login + +

+ + + ); +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 578a972..40b86fe 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,34 +1,67 @@ -import { createContext, useContext, useState, ReactNode } from 'react'; +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import axios from 'axios'; +import { API_BASE_URL, api } from '../api/config'; + +export interface CurrentUser { + userId: number; + email: string; + name: string; + role: 'user' | 'admin'; + createdAt: string; +} interface AuthContextType { isLoggedIn: boolean; isAdmin: boolean; + currentUser: CurrentUser | null; login: (email: string, password: string) => Promise; logout: () => void; + updateCurrentUser: (updates: Partial>) => void; } +const AUTH_STORAGE_KEY = 'octocat_user'; + const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { - const [isLoggedIn, setIsLoggedIn] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); + const [currentUser, setCurrentUser] = useState(() => { + try { + const stored = localStorage.getItem(AUTH_STORAGE_KEY); + return stored ? (JSON.parse(stored) as CurrentUser) : null; + } catch { + return null; + } + }); - const login = async (email: string, password: string) => { - // In a real app, you would validate credentials with an API - // For now, we'll just check the email domain - if (email && password) { - setIsLoggedIn(true); - setIsAdmin(email.endsWith('@github.com')); + const isLoggedIn = currentUser !== null; + const isAdmin = currentUser?.role === 'admin'; + + useEffect(() => { + if (currentUser) { + localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(currentUser)); + } else { + localStorage.removeItem(AUTH_STORAGE_KEY); } + }, [currentUser]); + + const login = async (email: string, password: string) => { + const response = await axios.post( + `${API_BASE_URL}${api.endpoints.users}/login`, + { email, password }, + ); + setCurrentUser(response.data); }; const logout = () => { - setIsLoggedIn(false); - setIsAdmin(false); + setCurrentUser(null); + }; + + const updateCurrentUser = (updates: Partial>) => { + setCurrentUser((prev) => (prev ? { ...prev, ...updates } : prev)); }; return ( - + {children} ); diff --git a/frontend/src/utils/apiError.ts b/frontend/src/utils/apiError.ts new file mode 100644 index 0000000..360bad4 --- /dev/null +++ b/frontend/src/utils/apiError.ts @@ -0,0 +1,12 @@ +/** + * Extracts a human-readable error message from an Axios API error response. + * Falls back to the provided default message when the response body does not + * contain a structured error. + */ +export function extractApiErrorMessage(err: unknown, fallback: string): string { + if (err && typeof err === 'object') { + const response = (err as any)?.response?.data?.error?.message; + if (typeof response === 'string' && response.length > 0) return response; + } + return fallback; +}