diff --git a/src/backend/src/auth/auth.controller.ts b/src/backend/src/auth/auth.controller.ts index d81b0e9..97d07ed 100644 --- a/src/backend/src/auth/auth.controller.ts +++ b/src/backend/src/auth/auth.controller.ts @@ -1,11 +1,4 @@ -import { - Body, - Controller, - Get, - Post, - Req, - UseGuards, -} from '@nestjs/common'; +import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './jwt-auth.guard'; import { OnboardingGuard } from './onboarding.guard'; diff --git a/src/backend/src/auth/auth.service.ts b/src/backend/src/auth/auth.service.ts index 16fb64d..71efe75 100644 --- a/src/backend/src/auth/auth.service.ts +++ b/src/backend/src/auth/auth.service.ts @@ -121,9 +121,9 @@ export class AuthService { (user.firstName?.trim() && user.lastName?.trim()) || user.name?.trim(); return Boolean( hasName && - user.phoneNumber?.trim() && - user.program?.trim() && - user.passwordHash?.trim(), + user.phoneNumber?.trim() && + user.program?.trim() && + user.passwordHash?.trim(), ); } @@ -329,6 +329,8 @@ export class AuthService { }); } + // Ensure new/onboarding users get Member role so they can sign up for events + await this.usersService.replaceRoles(user.id, ['Member']); const userWithRoles = await this.usersService.findOneWithRoles(user.id); const token = this.issueJwt({ sub: user.id, email: user.email }); diff --git a/src/backend/src/auth/jwt-auth.guard.ts b/src/backend/src/auth/jwt-auth.guard.ts index dfad9d5..e5b24e4 100644 --- a/src/backend/src/auth/jwt-auth.guard.ts +++ b/src/backend/src/auth/jwt-auth.guard.ts @@ -17,19 +17,17 @@ export class JwtAuthGuard implements CanActivate { // M8: validateToken(token) -> verify JWT before protected routes. const request = context.switchToHttp().getRequest(); const header = request.headers?.authorization ?? ''; - const token = typeof header === 'string' && header.startsWith('Bearer ') - ? header.slice(7).trim() - : null; + const token = + typeof header === 'string' && header.startsWith('Bearer ') + ? header.slice(7).trim() + : null; if (!token) { throw new UnauthorizedException('Missing authorization token.'); } try { - const decoded = verify( - token, - process.env.JWT_SECRET || 'dev-secret', - ); + const decoded = verify(token, process.env.JWT_SECRET || 'dev-secret'); if (typeof decoded !== 'object' || decoded === null) { throw new UnauthorizedException('Invalid token payload.'); diff --git a/src/backend/src/auth/onboarding.guard.ts b/src/backend/src/auth/onboarding.guard.ts index 39b9306..708ce7e 100644 --- a/src/backend/src/auth/onboarding.guard.ts +++ b/src/backend/src/auth/onboarding.guard.ts @@ -16,19 +16,17 @@ export class OnboardingGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const header = request.headers?.authorization ?? ''; - const token = typeof header === 'string' && header.startsWith('Bearer ') - ? header.slice(7).trim() - : null; + const token = + typeof header === 'string' && header.startsWith('Bearer ') + ? header.slice(7).trim() + : null; if (!token) { throw new UnauthorizedException('Missing onboarding token.'); } try { - const decoded = verify( - token, - process.env.JWT_SECRET || 'dev-secret', - ); + const decoded = verify(token, process.env.JWT_SECRET || 'dev-secret'); if (typeof decoded !== 'object' || decoded === null) { throw new UnauthorizedException('Invalid onboarding token.'); diff --git a/src/backend/src/auth/roles.decorator.ts b/src/backend/src/auth/roles.decorator.ts new file mode 100644 index 0000000..2066e6a --- /dev/null +++ b/src/backend/src/auth/roles.decorator.ts @@ -0,0 +1,9 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; + +/** + * Declare which roles are allowed to access a route. + * Example: @Roles('Admin') or @Roles('Admin', 'Member') + */ +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/backend/src/auth/roles.guard.ts b/src/backend/src/auth/roles.guard.ts new file mode 100644 index 0000000..bea2939 --- /dev/null +++ b/src/backend/src/auth/roles.guard.ts @@ -0,0 +1,66 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from './roles.decorator'; +import { UsersService } from '../users/users.service'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly usersService: UsersService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + // If no roles metadata is present, allow the request (JwtAuthGuard still applies). + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context + .switchToHttp() + .getRequest<{ user?: { sub?: number } }>(); + const user = request.user; + if (!user?.sub) { + throw new ForbiddenException('Access denied.'); + } + + let dbUser; + try { + dbUser = await this.usersService.findOneWithRoles(user.sub); + } catch (err) { + console.error('[RolesGuard] findOneWithRoles error:', err); + throw new ForbiddenException('Access denied.'); + } + + if (!dbUser) { + throw new ForbiddenException('Access denied.'); + } + + const userWithRoles = dbUser as { + roles?: string[]; + isSystemAdmin?: boolean; + }; + const userRoles = new Set(userWithRoles.roles ?? []); + // Treat isSystemAdmin as implicit 'Admin' role for RBAC checks. + if (userWithRoles.isSystemAdmin) { + userRoles.add('Admin'); + } + + const hasRole = requiredRoles.some((role) => userRoles.has(role)); + if (!hasRole) { + throw new ForbiddenException('Access denied.'); + } + + return true; + } +} diff --git a/src/backend/src/common/http-exception.filter.ts b/src/backend/src/common/http-exception.filter.ts new file mode 100644 index 0000000..86c42cc --- /dev/null +++ b/src/backend/src/common/http-exception.filter.ts @@ -0,0 +1,46 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { FastifyReply, FastifyRequest } from 'fastify'; + +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionsFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException + ? exception.getResponse() + : exception instanceof Error + ? exception.message + : 'Internal server error'; + + this.logger.error( + `${request.method} ${request.url} -> ${status}\n` + + (exception instanceof Error ? exception.stack : String(exception)), + ); + + response.status(status).send({ + statusCode: status, + error: status >= 500 ? 'Internal Server Error' : 'Bad Request', + message: + typeof message === 'object' + ? ((message as { message?: string }).message ?? message) + : message, + }); + } +} diff --git a/src/backend/src/db/README.md b/src/backend/src/db/README.md index 8dae807..4c13362 100644 --- a/src/backend/src/db/README.md +++ b/src/backend/src/db/README.md @@ -3,7 +3,7 @@ ## Tables - **users** – Profile (email, name, phoneNumber, program, isSystemAdmin) -- **roles** – Role names (Admin, Member, Guest) +- **roles** – Role names (Admin, Member) - **user_roles** – Many-to-many user ↔ role - **events** – Events with optional table/bus config: - `tableCount`, `seatsPerTable` → table seats (user picks table + seat) diff --git a/src/backend/src/db/seed-data.ts b/src/backend/src/db/seed-data.ts index c76bbb6..859b617 100644 --- a/src/backend/src/db/seed-data.ts +++ b/src/backend/src/db/seed-data.ts @@ -25,32 +25,46 @@ export async function runSeedDb(db: SeedDb): Promise { return false; // already seeded } - const [roleAdmin, roleMember, roleGuest] = await db + const [roleAdmin, roleMember] = await db .insert(roles) - .values([{ name: 'Admin' }, { name: 'Member' }, { name: 'Guest' }]) + .values([{ name: 'Admin' }, { name: 'Member' }]) .returning(); const [user1, user2, user3] = await db .insert(users) .values([ { - email: 'admin@example.com', + email: 'admin@mcmaster.ca', name: 'Admin User', + firstName: 'Admin', + lastName: 'User', phoneNumber: '+15551234567', program: 'CS', isSystemAdmin: true, + passwordHash: + '$2b$10$gNGCkz3TUVsAJOaBK1ttW..Vjx6AQJkqw0l34eiAFf.rKsqqYF7o6', // e.g. for Password123 }, { - email: 'alice@example.com', + email: 'alice@mcmaster.ca', name: 'Alice Smith', + firstName: 'Alice', + lastName: 'Smith', phoneNumber: '+15559876543', program: 'Engineering', + // Same dev password as admin (Password123). + passwordHash: + '$2b$10$gNGCkz3TUVsAJOaBK1ttW..Vjx6AQJkqw0l34eiAFf.rKsqqYF7o6', }, { - email: 'bob@example.com', + email: 'bob@mcmaster.ca', name: 'Bob Jones', + firstName: 'Bob', + lastName: 'Jones', phoneNumber: '+15555555555', - program: null, + program: 'Science', + // Same dev password as admin (Password123). + passwordHash: + '$2b$10$gNGCkz3TUVsAJOaBK1ttW..Vjx6AQJkqw0l34eiAFf.rKsqqYF7o6', }, ]) .returning(); @@ -59,7 +73,7 @@ export async function runSeedDb(db: SeedDb): Promise { await db.insert(userRoles).values([ { userId: user1.id, roleId: roleAdmin.id }, { userId: user2.id, roleId: roleMember.id }, - { userId: user3.id, roleId: roleGuest.id }, + { userId: user3.id, roleId: roleMember.id }, ]); const [event1, event2] = await db @@ -145,7 +159,7 @@ export async function runSeedDb(db: SeedDb): Promise { let ticket1: Ticket | undefined; let ticket2: Ticket | undefined; let ticket3: Ticket | undefined; - + if (existingTickets.length > 0) { // Update existing tickets with QR codes const allTickets: Ticket[] = await db.select().from(tickets); @@ -166,49 +180,58 @@ export async function runSeedDb(db: SeedDb): Promise { const [t1, t2, t3] = await db .insert(tickets) .values([ - { - userId: user1.id, + { + userId: user1.id, eventId: event1.id, - qrCodeData: `TICKET:${user1.id}:${event1.id}:temp1:${timestamp}` + qrCodeData: `TICKET:${user1.id}:${event1.id}:temp1:${timestamp}`, }, - { - userId: user2.id, + { + userId: user2.id, eventId: event1.id, - qrCodeData: `TICKET:${user2.id}:${event1.id}:temp2:${timestamp}` + qrCodeData: `TICKET:${user2.id}:${event1.id}:temp2:${timestamp}`, }, - { - userId: user3.id, + { + userId: user3.id, eventId: event2.id, - qrCodeData: `TICKET:${user3.id}:${event2.id}:temp3:${timestamp}` + qrCodeData: `TICKET:${user3.id}:${event2.id}:temp3:${timestamp}`, }, ]) .returning(); - + // Update with actual ticket IDs if (t1) { await db .update(tickets) - .set({ qrCodeData: `TICKET:${user1.id}:${event1.id}:${t1.id}:${timestamp}`, updatedAt: new Date() }) + .set({ + qrCodeData: `TICKET:${user1.id}:${event1.id}:${t1.id}:${timestamp}`, + updatedAt: new Date(), + }) .where(eq(tickets.id, t1.id)); } if (t2) { await db .update(tickets) - .set({ qrCodeData: `TICKET:${user2.id}:${event1.id}:${t2.id}:${timestamp}`, updatedAt: new Date() }) + .set({ + qrCodeData: `TICKET:${user2.id}:${event1.id}:${t2.id}:${timestamp}`, + updatedAt: new Date(), + }) .where(eq(tickets.id, t2.id)); } if (t3) { await db .update(tickets) - .set({ qrCodeData: `TICKET:${user3.id}:${event2.id}:${t3.id}:${timestamp}`, updatedAt: new Date() }) + .set({ + qrCodeData: `TICKET:${user3.id}:${event2.id}:${t3.id}:${timestamp}`, + updatedAt: new Date(), + }) .where(eq(tickets.id, t3.id)); } - + ticket1 = t1; ticket2 = t2; ticket3 = t3; } - + if (!ticket1 || !ticket2 || !ticket3) throw new Error('Ticket insert failed'); if (ticket1 && ticket2) { diff --git a/src/backend/src/events/events.controller.ts b/src/backend/src/events/events.controller.ts index 5ba198d..4271d4b 100644 --- a/src/backend/src/events/events.controller.ts +++ b/src/backend/src/events/events.controller.ts @@ -1,19 +1,21 @@ import { + Body, Controller, + Delete, + ForbiddenException, Get, + Param, Post, Put, - Delete, - Body, - Param, Query, Req, UseGuards, - ForbiddenException, } from '@nestjs/common'; +import { UsersService } from '../users/users.service'; import { EventsService } from './events.service'; import type { NewEvent } from '../db/schema'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { Roles } from '../auth/roles.decorator'; interface RequestWithUser extends Request { user: { sub: number; email: string }; @@ -22,7 +24,10 @@ interface RequestWithUser extends Request { @Controller('events') @UseGuards(JwtAuthGuard) export class EventsController { - constructor(private readonly eventsService: EventsService) {} + constructor( + private readonly eventsService: EventsService, + private readonly usersService: UsersService, + ) {} @Get() findAll() { @@ -36,27 +41,38 @@ export class EventsController { } @Post() + @Roles('Admin') create(@Body() event: NewEvent) { return this.eventsService.create(event); } @Put(':id') + @Roles('Admin') update(@Param('id') id: string, @Body() event: Partial) { return this.eventsService.update(+id, event); } @Delete(':id') + @Roles('Admin') delete(@Param('id') id: string) { return this.eventsService.delete(+id); } @Post(':id/signup') - signup( + async signup( @Param('id') id: string, @Req() req: RequestWithUser, @Body('selectedTable') selectedTable?: number, ) { - return this.eventsService.signup(+id, req.user.sub, selectedTable); + try { + return await this.eventsService.signup(+id, req.user.sub, selectedTable); + } catch (err) { + console.error('[signup] Error:', err); + if (err instanceof Error) { + console.error('[signup] Stack:', err.stack); + } + throw err; + } } @Post(':id/checkout-session') @@ -118,9 +134,20 @@ export class EventsController { } @Get('user/:userId/tickets') - getUserTickets(@Param('userId') userId: string, @Req() req: RequestWithUser) { - if (req.user.sub !== +userId) { - throw new ForbiddenException('Access denied.'); + async getUserTickets( + @Param('userId') userId: string, + @Req() req: RequestWithUser, + ) { + const currentUserId = req.user.sub; + // Allow: self-access (user fetching own tickets) OR admin + if (currentUserId !== +userId) { + const dbUser = await this.usersService.findOneWithRoles(currentUserId); + const isAdmin = + (dbUser as { isSystemAdmin?: boolean })?.isSystemAdmin || + (dbUser?.roles ?? []).includes('Admin'); + if (!isAdmin) { + throw new ForbiddenException('Access denied.'); + } } return this.eventsService.getTicketsForUser(+userId); } diff --git a/src/backend/src/events/events.module.ts b/src/backend/src/events/events.module.ts index 714b3af..8150a8c 100644 --- a/src/backend/src/events/events.module.ts +++ b/src/backend/src/events/events.module.ts @@ -3,11 +3,13 @@ import { EventsController } from './events.controller'; import { EventsService } from './events.service'; import { DatabaseModule } from '../database/database.module'; import { PaymentsModule } from '../payments/payments.module'; +import { UsersModule } from '../users/users.module'; +import { RolesGuard } from '../auth/roles.guard'; @Module({ - imports: [DatabaseModule, PaymentsModule], + imports: [DatabaseModule, PaymentsModule, UsersModule], controllers: [EventsController], - providers: [EventsService], + providers: [EventsService, RolesGuard], exports: [EventsService], }) export class EventsModule {} diff --git a/src/backend/src/events/events.service.ts b/src/backend/src/events/events.service.ts index 9e0e828..6d0fa21 100644 --- a/src/backend/src/events/events.service.ts +++ b/src/backend/src/events/events.service.ts @@ -10,7 +10,7 @@ import { seatReservations, } from '../db/schema'; import type { NewEvent } from '../db/schema'; -import { eq, sql, and } from 'drizzle-orm'; +import { eq, sql, and, asc } from 'drizzle-orm'; import type { SeedDb } from '../db/seed-data'; import { createTableSeatsForEvent, @@ -417,6 +417,9 @@ export class EventsService { .values({ userId, eventId }) .returning(); const ticket = result[0]; + if (!ticket) { + return { error: 'Failed to create ticket. Please try again.' }; + } // Generate and update QR code data const qrCodeData = this.generateQRCodeData(ticket.id, userId, eventId); @@ -574,6 +577,9 @@ export class EventsService { .values({ userId, eventId }) .returning(); const ticket = result[0]; + if (!ticket) { + return { error: 'Failed to create ticket. Please try again.' }; + } // Generate and update QR code data const qrCodeData = this.generateQRCodeData(ticket.id, userId, eventId); @@ -598,7 +604,7 @@ export class EventsService { .select() .from(tableSeats) .where(conditions) - .orderBy(tableSeats.tableNumber, tableSeats.seatNumber) + .orderBy(asc(tableSeats.tableNumber), asc(tableSeats.seatNumber)) .limit(1); if (availableTableSeat[0]) { @@ -617,7 +623,7 @@ export class EventsService { .where( sql`${busSeats.eventId} = ${eventId} AND ${busSeats.ticketId} IS NULL`, ) - .orderBy(busSeats.busNumber, busSeats.seatNumber) + .orderBy(asc(busSeats.busNumber), asc(busSeats.seatNumber)) .limit(1); if (availableBusSeat[0]) { await this.dbService.db diff --git a/src/backend/src/main.ts b/src/backend/src/main.ts index 723bfe1..0e3cbeb 100644 --- a/src/backend/src/main.ts +++ b/src/backend/src/main.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/platform-fastify'; import cors from '@fastify/cors'; import { AppModule } from './app.module'; +import { AllExceptionsFilter } from './common/http-exception.filter'; interface RequestWithUrlAndRawBody { url?: string; @@ -19,6 +20,8 @@ async function bootstrap() { new FastifyAdapter(), ); + app.useGlobalFilters(new AllExceptionsFilter()); + const fastifyInstance = app.getHttpAdapter().getInstance(); // For Stripe webhook: capture raw body for signature verification (only on webhook route) @@ -41,10 +44,11 @@ async function bootstrap() { }, ); - // Enable CORS for all origins + // Enable CORS for all origins, including non-GET methods used by the admin panel await app.register(cors, { - origin: true, // Allow all origins + origin: true, // Allow all origins (dev) credentials: true, + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], }); await app.listen(process.env.PORT ?? 3000, '0.0.0.0'); diff --git a/src/backend/src/stats/stats.controller.ts b/src/backend/src/stats/stats.controller.ts index e628227..5a4d1c9 100644 --- a/src/backend/src/stats/stats.controller.ts +++ b/src/backend/src/stats/stats.controller.ts @@ -1,11 +1,15 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, UseGuards } from '@nestjs/common'; import { StatsService, type DashboardStats } from './stats.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { Roles } from '../auth/roles.decorator'; @Controller('admin') +@UseGuards(JwtAuthGuard) export class StatsController { constructor(private readonly statsService: StatsService) {} @Get('stats') + @Roles('Admin') getDashboardStats(): Promise { return this.statsService.getDashboardStats(); } diff --git a/src/backend/src/stats/stats.module.ts b/src/backend/src/stats/stats.module.ts index 1dda32b..9c59377 100644 --- a/src/backend/src/stats/stats.module.ts +++ b/src/backend/src/stats/stats.module.ts @@ -2,11 +2,13 @@ import { Module } from '@nestjs/common'; import { DatabaseModule } from '../database/database.module'; import { StatsController } from './stats.controller'; import { StatsService } from './stats.service'; +import { UsersModule } from '../users/users.module'; +import { RolesGuard } from '../auth/roles.guard'; @Module({ - imports: [DatabaseModule], + imports: [DatabaseModule, UsersModule], controllers: [StatsController], - providers: [StatsService], + providers: [StatsService, RolesGuard], exports: [StatsService], }) export class StatsModule {} diff --git a/src/backend/src/stats/stats.service.ts b/src/backend/src/stats/stats.service.ts index 18e21eb..e771f3f 100644 --- a/src/backend/src/stats/stats.service.ts +++ b/src/backend/src/stats/stats.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { count, sum, sql, gte, inArray, eq } from 'drizzle-orm'; +import { count, sum, sql, gte, eq } from 'drizzle-orm'; import { DatabaseService } from '../database/database.service'; -import { users, events, tickets, payments } from '../db/schema'; +import { users, events, tickets } from '../db/schema'; export interface DashboardStats { userCount: number; diff --git a/src/backend/src/users/users.controller.ts b/src/backend/src/users/users.controller.ts index 4c9728a..fb7cf35 100644 --- a/src/backend/src/users/users.controller.ts +++ b/src/backend/src/users/users.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Controller, Get, Post, @@ -7,12 +8,15 @@ import { Body, Param, Req, + UnauthorizedException, UseGuards, - ForbiddenException, } from '@nestjs/common'; import { UsersService } from './users.service'; import type { NewUser } from '../db/schema'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { Roles } from '../auth/roles.decorator'; + +type UpdateUserBody = Partial & { password?: string }; interface RequestWithUser extends Request { user: { sub: number; email: string }; @@ -24,36 +28,62 @@ export class UsersController { constructor(private readonly usersService: UsersService) {} @Get() + @Roles('Admin') findAll() { return this.usersService.findAll(); } @Get(':id') - findOne(@Param('id') id: string, @Req() req: RequestWithUser) { - if (req.user.sub !== +id) { - throw new ForbiddenException('Access denied.'); - } + @Roles('Admin') + findOne(@Param('id') id: string) { return this.usersService.findOneWithRoles(+id); } @Post() + @Roles('Admin') create(@Body() user: NewUser) { return this.usersService.create(user); } @Put(':id') - update(@Param('id') id: string, @Body() user: Partial, @Req() req: RequestWithUser) { - if (req.user.sub !== +id) { - throw new ForbiddenException('Access denied.'); + @Roles('Admin') + async update( + @Param('id') id: string, + @Body() body: UpdateUserBody, + @Req() req: RequestWithUser, + ) { + const user = { ...body }; + if (user.isSystemAdmin !== undefined) { + if (!user.password || typeof user.password !== 'string') { + throw new BadRequestException( + 'Password is required to change admin role.', + ); + } + const valid = await this.usersService.verifyUserPassword( + req.user.sub, + user.password, + ); + if (!valid) { + throw new UnauthorizedException('Invalid password.'); + } + delete (user as UpdateUserBody).password; } - return this.usersService.update(+id, user); + await this.usersService.update(+id, user); + const updated = await this.usersService.findOneWithRoles(+id); + if (!updated) throw new Error('User not found after update'); + return updated; + } + + @Put(':id/roles') + @Roles('Admin') + updateRoles(@Param('id') id: string, @Body() body: { roles?: string[] }) { + const roles = Array.isArray(body.roles) ? body.roles : []; + return this.usersService.replaceRoles(+id, roles); } @Delete(':id') - delete(@Param('id') id: string, @Req() req: RequestWithUser) { - if (req.user.sub !== +id) { - throw new ForbiddenException('Access denied.'); - } + @Roles('Admin') + delete(@Param('id') id: string) { return this.usersService.delete(+id); } } diff --git a/src/backend/src/users/users.module.ts b/src/backend/src/users/users.module.ts index 513776d..301eb5e 100644 --- a/src/backend/src/users/users.module.ts +++ b/src/backend/src/users/users.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; +import { RolesGuard } from '../auth/roles.guard'; @Module({ controllers: [UsersController], - providers: [UsersService], - exports: [UsersService], + providers: [UsersService, RolesGuard], + exports: [UsersService, RolesGuard], }) export class UsersModule {} diff --git a/src/backend/src/users/users.service.ts b/src/backend/src/users/users.service.ts index 690bccf..ae7ed21 100644 --- a/src/backend/src/users/users.service.ts +++ b/src/backend/src/users/users.service.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import bcrypt from 'bcryptjs'; import { DatabaseService } from '../database/database.service'; import { users, userRoles, roles } from '../db/schema'; import type { NewUser } from '../db/schema'; @@ -12,7 +13,7 @@ export class UsersService { // Avoid leaking password hashes to clients. // eslint-disable-next-line @typescript-eslint/no-unused-vars const { passwordHash, ...rest } = user; - return rest as Omit; + return rest; } async findAll() { @@ -56,6 +57,18 @@ export class UsersService { return result[0] ?? null; } + /** Verify password for the current user. Used for sensitive operations like role changes. */ + async verifyUserPassword(userId: number, password: string): Promise { + if (!password || typeof password !== 'string') return false; + const result = await this.dbService.db + .select({ passwordHash: users.passwordHash }) + .from(users) + .where(eq(users.id, userId)); + const row = result[0]; + if (!row?.passwordHash) return false; + return bcrypt.compare(password, row.passwordHash); + } + async findByEmailWithRoles(email: string) { const user = await this.findByEmail(email); if (!user) return null; @@ -79,6 +92,46 @@ export class UsersService { return result[0]; } + /** + * Replace all non-admin roles for a user with the provided role names. + * This is used by the admin panel to assign a primary role (currently Member only). + */ + async replaceRoles(id: number, roleNames: string[]) { + const db = this.dbService.db; + + // Resolve role names to IDs. + const allRoles = await db.select().from(roles); + const targetRoleIds = allRoles + .filter((r) => roleNames.includes(r.name)) + .map((r) => r.id); + + // Validate that all requested roles exist. + const existingRoleNames = new Set(allRoles.map((r) => r.name)); + const missingRoles = roleNames.filter( + (name) => !existingRoleNames.has(name), + ); + if (missingRoles.length > 0) { + throw new BadRequestException( + `Role(s) not found: ${missingRoles.join(', ')}. Available roles: ${Array.from(existingRoleNames).join(', ')}`, + ); + } + + // Remove all existing role mappings for this user. + await db.delete(userRoles).where(eq(userRoles.userId, id)); + + // Insert the new mappings. + if (targetRoleIds.length > 0) { + await db.insert(userRoles).values( + targetRoleIds.map((roleId) => ({ + userId: id, + roleId, + })), + ); + } + + return this.findOneWithRoles(id); + } + async delete(id: number) { await this.dbService.db.delete(users).where(eq(users.id, id)); return { deleted: true }; diff --git a/src/frontend/web-admin/app/_lib/api.ts b/src/frontend/web-admin/app/_lib/api.ts index bab083d..b17158f 100644 --- a/src/frontend/web-admin/app/_lib/api.ts +++ b/src/frontend/web-admin/app/_lib/api.ts @@ -9,11 +9,22 @@ const getBaseUrl = (): string => { async function apiFetch(path: string, options?: RequestInit): Promise { const base = getBaseUrl(); const url = `${base}${path}`; + + // Dynamically import to avoid server-side issues with document access + const { getToken } = await import("./auth"); + const token = getToken(); + const headers: Record = { + "Content-Type": "application/json", + ...((options?.headers as Record) ?? {}), + }; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } try { const res = await fetch(url, { - headers: { "Content-Type": "application/json", ...options?.headers }, ...options, + headers, }); if (!res.ok) { @@ -68,7 +79,7 @@ export function getUser(id: number): Promise { export function updateUser( id: number, - data: Partial, + data: Partial & { password?: string }, ): Promise { return apiFetch(`/users/${id}`, { method: "PUT", @@ -76,6 +87,16 @@ export function updateUser( }); } +export function updateUserRoles( + id: number, + roles: string[], +): Promise { + return apiFetch(`/users/${id}/roles`, { + method: "PUT", + body: JSON.stringify({ roles }), + }); +} + // ---------- Events ---------- export interface EventItem { id: number; @@ -144,3 +165,20 @@ export interface Ticket { export function getUserTickets(userId: number): Promise { return apiFetch(`/events/user/${userId}/tickets`); } + +// ---------- Auth ---------- +export interface LoginResponse { + token: string; + user: User; +} + +export function login(email: string, password: string): Promise { + return apiFetch("/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }); +} + +export function getMe(): Promise { + return apiFetch("/auth/me"); +} diff --git a/src/frontend/web-admin/app/_lib/auth.ts b/src/frontend/web-admin/app/_lib/auth.ts new file mode 100644 index 0000000..970ea13 --- /dev/null +++ b/src/frontend/web-admin/app/_lib/auth.ts @@ -0,0 +1,21 @@ +const TOKEN_COOKIE = "macsync_token"; + +/** Read the JWT from the cookie (client-side only). */ +export function getToken(): string | null { + if (typeof document === "undefined") return null; + const match = document.cookie.match( + new RegExp(`(?:^|; )${TOKEN_COOKIE}=([^;]*)`) + ); + return match ? decodeURIComponent(match[1]) : null; +} + +/** Persist a JWT in a cookie (7-day expiry, accessible to middleware). */ +export function setToken(token: string): void { + const maxAge = 7 * 24 * 60 * 60; // 7 days + document.cookie = `${TOKEN_COOKIE}=${encodeURIComponent(token)}; path=/; max-age=${maxAge}; SameSite=Lax`; +} + +/** Remove the JWT cookie (used on logout). */ +export function clearToken(): void { + document.cookie = `${TOKEN_COOKIE}=; path=/; max-age=0`; +} diff --git a/src/frontend/web-admin/app/components/DashboardShell.tsx b/src/frontend/web-admin/app/components/DashboardShell.tsx index 4eaed5d..ef9f1e8 100644 --- a/src/frontend/web-admin/app/components/DashboardShell.tsx +++ b/src/frontend/web-admin/app/components/DashboardShell.tsx @@ -1,7 +1,8 @@ "use client"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; +import { clearToken } from "../_lib/auth"; const navItems = [ { href: "/", label: "Dashboard" }, @@ -15,6 +16,17 @@ export default function DashboardShell({ children: React.ReactNode; }) { const pathname = usePathname(); + const router = useRouter(); + + // Don't render the shell on the login page + if (pathname === "/login") { + return <>{children}; + } + + function handleLogout() { + clearToken(); + router.push("/login"); + } return (
@@ -55,7 +67,27 @@ export default function DashboardShell({ })}
-

Admin portal

+
diff --git a/src/frontend/web-admin/app/login/page.tsx b/src/frontend/web-admin/app/login/page.tsx new file mode 100644 index 0000000..18e4718 --- /dev/null +++ b/src/frontend/web-admin/app/login/page.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { login } from "../_lib/api"; +import { setToken } from "../_lib/auth"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + const { token, user } = await login(email, password); + + const roles = user.roles ?? []; + const isAdmin = user.isSystemAdmin || roles.includes("Admin"); + + if (!isAdmin) { + setError("Access denied. Only system administrators can sign in."); + setLoading(false); + return; + } + + setToken(token); + router.push("/"); + } catch (err) { + const msg = + err instanceof Error ? err.message : "Login failed. Please try again."; + // Try to parse backend JSON error + try { + const parsed = JSON.parse(msg); + setError(parsed.message ?? msg); + } catch { + setError(msg); + } + } finally { + setLoading(false); + } + } + + return ( +
+
+ {/* Logo */} +
+
+ M +
+

MacSync Admin

+

+ Sign in with your McMaster account +

+
+ + {/* Form card */} +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-maroon focus:ring-1 focus:ring-maroon outline-none transition-colors" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-maroon focus:ring-1 focus:ring-maroon outline-none transition-colors" + /> +
+ + +
+ +

+ Only @mcmaster.ca accounts with admin privileges can access this + portal. +

+
+
+ ); +} diff --git a/src/frontend/web-admin/app/users/[id]/page.tsx b/src/frontend/web-admin/app/users/[id]/page.tsx index b9f7b97..43f6079 100644 --- a/src/frontend/web-admin/app/users/[id]/page.tsx +++ b/src/frontend/web-admin/app/users/[id]/page.tsx @@ -6,6 +6,7 @@ import { useParams } from "next/navigation"; import { getUser, getUserTickets, + getMe, updateUser, type Ticket, type User, @@ -35,16 +36,27 @@ export default function UserProfilePage() { const [error, setError] = useState(null); const [savingRole, setSavingRole] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); + const [currentUser, setCurrentUser] = useState(null); + const [roleConfirmStep, setRoleConfirmStep] = useState< + null | "confirm" | "password" + >(null); + const [pendingRoleChange, setPendingRoleChange] = useState( + null, + ); + const [passwordInput, setPasswordInput] = useState(""); + const [passwordError, setPasswordError] = useState(null); useEffect(() => { if (!Number.isFinite(userId)) return; async function load() { try { setError(null); - const [u, t] = await Promise.all([ + const [me, u, t] = await Promise.all([ + getMe(), getUser(userId), getUserTickets(userId), ]); + setCurrentUser(me); setUser(u); setTickets(t); } catch (e) { @@ -61,19 +73,66 @@ export default function UserProfilePage() { [tickets], ); - async function onToggleSystemAdmin(next: boolean) { - if (!user) return; + const currentRoles = currentUser?.roles ?? []; + const currentIsAdmin = + !!currentUser && + (currentUser.isSystemAdmin || currentRoles.includes("Admin")); + + function startRoleChange(next: boolean) { + setPendingRoleChange(next); + setRoleConfirmStep("confirm"); + setPasswordInput(""); + setPasswordError(null); + } + + function cancelRoleChange() { + setRoleConfirmStep(null); + setPendingRoleChange(null); + setPasswordInput(""); + setPasswordError(null); + } + + async function submitRoleChange() { + if (!user || pendingRoleChange === null) return; + if (!passwordInput.trim()) { + setPasswordError("Please enter your password."); + return; + } try { setSavingRole(true); - const updated = await updateUser(user.id, { isSystemAdmin: next }); + setPasswordError(null); + const updated = await updateUser(user.id, { + isSystemAdmin: pendingRoleChange, + password: passwordInput, + }); setUser((prev) => (prev ? { ...prev, ...updated } : updated)); + cancelRoleChange(); } catch (e) { - alert(e instanceof Error ? e.message : "Failed to update user"); + const raw = e instanceof Error ? e.message : "Unknown error"; + let friendly = raw; + try { + const parsed = JSON.parse(raw); + if (parsed?.message === "Invalid password." || parsed?.statusCode === 401) { + friendly = "Password is incorrect. Please try again."; + } else if (typeof parsed?.message === "string") { + friendly = parsed.message; + } + } catch { + // Not JSON; use raw message unless it looks like a raw API response + if (raw.startsWith("{") && raw.includes("statusCode")) { + friendly = "Password is incorrect. Please try again."; + } + } + setPasswordError(friendly); } finally { setSavingRole(false); } } + function onConfirmYes() { + setRoleConfirmStep("password"); + } + if (loading) { return (
@@ -101,6 +160,100 @@ export default function UserProfilePage() { return (
+ {/* Role change confirmation modal */} + {roleConfirmStep && ( +
e.target === e.currentTarget && cancelRoleChange()} + role="dialog" + aria-modal="true" + aria-labelledby="role-confirm-title" + > +
e.stopPropagation()} + > + {roleConfirmStep === "confirm" ? ( + <> +

+ Confirm admin role change +

+

+ Are you sure you want to do that? +

+
+ + +
+ + ) : ( + <> +

+ Enter your password +

+

+ Enter your password to confirm this change. +

+ { + setPasswordInput(e.target.value); + setPasswordError(null); + }} + placeholder="Your password" + className="mt-4 w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-maroon focus:border-maroon outline-none" + autoFocus + autoComplete="current-password" + onKeyDown={(e) => { + if (e.key === "Enter") submitRoleChange(); + if (e.key === "Escape") cancelRoleChange(); + }} + /> + {passwordError && ( +

{passwordError}

+ )} +
+ + +
+ + )} +
+
+ )} +
-
- {user.isSystemAdmin && ( - - SYSTEM_ADMIN - - )} - {user.roles?.map((r) => ( +
+ Role: + {(user.roles && user.roles.length > 0 + ? user.roles + : ["Member"] + ).map((r) => ( ))} + {user.isSystemAdmin && ( + + SYSTEM_ADMIN + + )}
- + {currentIsAdmin && ( + + )}
- {showAdvanced && ( + {showAdvanced && currentIsAdmin && (

Advanced settings

@@ -160,7 +319,7 @@ export default function UserProfilePage() {