From 9a96c41acb209d5f4048f787c3c6049e0c8f73db Mon Sep 17 00:00:00 2001 From: Mahad Ahmed Date: Thu, 12 Feb 2026 13:06:43 -0500 Subject: [PATCH 1/3] add admin auth, RBAC, and role-change password confirmation Backend: - Add Roles decorator and RolesGuard for role-based access control - Gate users, stats, and events endpoints by role (Admin/Member) - Require password confirmation when changing a user's admin role - Add verifyUserPassword and replaceRoles to UsersService - Fix getUserTickets: allow self-access or Admin (keeps mobile app working) - Update seed data: remove Guest role, use @mcmaster.ca emails, add passwordHash - Extend CORS with methods used by admin panel Frontend web-admin: - Add login page, auth middleware, and token handling - Add JWT to API requests and logout in DashboardShell - Gate advanced settings to admins; show confirm + password for role changes - Replace raw JSON with friendly "Password is incorrect" message on error - Add getMe, updateUserRoles, login to API client --- src/backend/package-lock.json | 35 +-- src/backend/src/auth/roles.decorator.ts | 10 + src/backend/src/auth/roles.guard.ts | 55 +++++ src/backend/src/db/README.md | 2 +- src/backend/src/db/seed-data.ts | 28 ++- src/backend/src/events/events.controller.ts | 43 +++- src/backend/src/events/events.module.ts | 6 +- src/backend/src/main.ts | 5 +- src/backend/src/stats/stats.controller.ts | 6 +- src/backend/src/stats/stats.module.ts | 6 +- src/backend/src/users/users.controller.ts | 62 ++++-- src/backend/src/users/users.module.ts | 5 +- src/backend/src/users/users.service.ts | 53 ++++- src/frontend/web-admin/app/_lib/api.ts | 42 +++- src/frontend/web-admin/app/_lib/auth.ts | 21 ++ .../app/components/DashboardShell.tsx | 36 +++- src/frontend/web-admin/app/login/page.tsx | 128 +++++++++++ .../web-admin/app/users/[id]/page.tsx | 201 ++++++++++++++++-- src/frontend/web-admin/middleware.ts | 35 +++ 19 files changed, 691 insertions(+), 88 deletions(-) create mode 100644 src/backend/src/auth/roles.decorator.ts create mode 100644 src/backend/src/auth/roles.guard.ts create mode 100644 src/frontend/web-admin/app/_lib/auth.ts create mode 100644 src/frontend/web-admin/app/login/page.tsx create mode 100644 src/frontend/web-admin/middleware.ts diff --git a/src/backend/package-lock.json b/src/backend/package-lock.json index 723ac5a..7d5b42e 100644 --- a/src/backend/package-lock.json +++ b/src/backend/package-lock.json @@ -229,7 +229,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3201,7 +3200,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3372,7 +3370,6 @@ "resolved": "https://registry.npmmirror.com/@nestjs/common/-/common-11.1.13.tgz", "integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -3405,7 +3402,6 @@ "integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3446,7 +3442,6 @@ "resolved": "https://registry.npmmirror.com/@nestjs/platform-express/-/platform-express-11.1.13.tgz", "integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3862,7 +3857,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3994,7 +3988,6 @@ "integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4015,7 +4008,6 @@ "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -4150,7 +4142,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -4838,7 +4829,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4888,7 +4878,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5324,7 +5313,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5529,7 +5517,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -6425,7 +6412,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6486,7 +6472,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7873,7 +7858,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -9679,7 +9663,6 @@ "resolved": "https://registry.npmmirror.com/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -9965,7 +9948,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10185,8 +10167,7 @@ "version": "0.2.2", "resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -10317,7 +10298,6 @@ "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -11000,7 +10980,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11349,7 +11328,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11497,7 +11475,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11835,6 +11812,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -11853,6 +11831,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -11866,6 +11845,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -11880,6 +11860,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -11889,7 +11870,8 @@ "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", @@ -11897,6 +11879,7 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -11907,6 +11890,7 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -11920,6 +11904,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/src/backend/src/auth/roles.decorator.ts b/src/backend/src/auth/roles.decorator.ts new file mode 100644 index 0000000..48dab2b --- /dev/null +++ b/src/backend/src/auth/roles.decorator.ts @@ -0,0 +1,10 @@ +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..db2abb4 --- /dev/null +++ b/src/backend/src/auth/roles.guard.ts @@ -0,0 +1,55 @@ +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(); + const user = request.user as { sub?: number } | undefined; + if (!user?.sub) { + throw new ForbiddenException('Access denied.'); + } + + const dbUser = await this.usersService.findOneWithRoles(user.sub); + if (!dbUser) { + throw new ForbiddenException('Access denied.'); + } + + const userRoles = new Set(dbUser.roles ?? []); + // Treat isSystemAdmin as implicit 'Admin' role for RBAC checks. + if ((dbUser as any).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/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 84af053..2e8757c 100644 --- a/src/backend/src/db/seed-data.ts +++ b/src/backend/src/db/seed-data.ts @@ -22,32 +22,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(); @@ -56,7 +70,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 diff --git a/src/backend/src/events/events.controller.ts b/src/backend/src/events/events.controller.ts index 6231931..9b40285 100644 --- a/src/backend/src/events/events.controller.ts +++ b/src/backend/src/events/events.controller.ts @@ -1,52 +1,63 @@ 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'; @Controller('events') @UseGuards(JwtAuthGuard) export class EventsController { - constructor(private readonly eventsService: EventsService) {} + constructor( + private readonly eventsService: EventsService, + private readonly usersService: UsersService, + ) {} @Get() + @Roles('Admin', 'Member') findAll() { return this.eventsService.findAll(); } @Get(':id') + @Roles('Admin', 'Member') findOne(@Param('id') id: string, @Query('userId') userId?: string) { const uid = userId != null && userId !== '' ? +userId : undefined; return this.eventsService.findOne(+id, uid); } @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') + @Roles('Member', 'Admin') signup( @Param('id') id: string, @Req() req: any, @@ -56,6 +67,7 @@ export class EventsController { } @Post(':id/checkout-session') + @Roles('Member', 'Admin') createCheckoutSession( @Param('id') id: string, @Req() req: any, @@ -98,6 +110,7 @@ export class EventsController { // Called by the client when Stripe redirects to cancel_url. // Releases the held seat immediately so the user can retry without waiting for expiry. @Post('checkout-session/:sessionId/release') + @Roles('Member', 'Admin') async releaseCheckoutReservation(@Param('sessionId') sessionId: string) { console.log( '[releaseCheckoutReservation] Releasing reservation for session:', @@ -109,15 +122,31 @@ export class EventsController { } @Post(':id/cancel') + @Roles('Member', 'Admin') cancelSignup(@Param('id') id: string, @Req() req: any) { return this.eventsService.cancelSignup(+id, req.user.sub); } @Get('user/:userId/tickets') - getUserTickets(@Param('userId') userId: string, @Req() req: any) { - if (req.user?.sub !== +userId) { + @Roles('Admin', 'Member') + async getUserTickets( + @Param('userId') userId: string, + @Req() req: any, + ) { + const currentUserId = req.user?.sub; + if (!currentUserId) { throw new ForbiddenException('Access denied.'); } + // 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/main.ts b/src/backend/src/main.ts index 723bfe1..a3d240e 100644 --- a/src/backend/src/main.ts +++ b/src/backend/src/main.ts @@ -41,10 +41,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/users/users.controller.ts b/src/backend/src/users/users.controller.ts index 7022adb..f4fa5d9 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 }; @Controller('users') @UseGuards(JwtAuthGuard) @@ -20,36 +24,68 @@ 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: any) { - if (req.user?.sub !== +id) { - throw new ForbiddenException('Access denied.'); - } + @Roles('Admin') + findOne(@Param('id') id: string) { + // Admin panel: any authenticated admin can view user profiles. + // Fine-grained per-user access control will be added later if needed. 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: any) { - if (req.user?.sub !== +id) { - throw new ForbiddenException('Access denied.'); + @Roles('Admin') + async update( + @Param('id') id: string, + @Body() body: UpdateUserBody, + @Req() req: { user?: { sub?: number } }, + ) { + 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 currentUserId = req.user?.sub; + if (!currentUserId) { + throw new UnauthorizedException('Not authenticated.'); + } + const valid = await this.usersService.verifyUserPassword(currentUserId, 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: any) { - if (req.user?.sub !== +id) { - throw new ForbiddenException('Access denied.'); - } + @Roles('Admin') + delete(@Param('id') id: string) { + // Admin panel: any authenticated admin can delete user profiles. + // Fine-grained per-user access control will be added later if needed. 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..bde8926 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'; @@ -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,44 @@ 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() {