Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions src/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
8 changes: 5 additions & 3 deletions src/backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);
}

Expand Down Expand Up @@ -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 });

Expand Down
12 changes: 5 additions & 7 deletions src/backend/src/auth/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,17 @@ export class JwtAuthGuard implements CanActivate {
// M8: validateToken(token) -> verify JWT before protected routes.
const request = context.switchToHttp().getRequest<RequestWithHeaders>();
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.');
Expand Down
12 changes: 5 additions & 7 deletions src/backend/src/auth/onboarding.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,17 @@ export class OnboardingGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<RequestWithHeaders>();
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.');
Expand Down
9 changes: 9 additions & 0 deletions src/backend/src/auth/roles.decorator.ts
Original file line number Diff line number Diff line change
@@ -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);
66 changes: 66 additions & 0 deletions src/backend/src/auth/roles.guard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
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;
}
}
46 changes: 46 additions & 0 deletions src/backend/src/common/http-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -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<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();

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,
});
}
}
2 changes: 1 addition & 1 deletion src/backend/src/db/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
69 changes: 46 additions & 23 deletions src/backend/src/db/seed-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,32 +25,46 @@ export async function runSeedDb(db: SeedDb): Promise<boolean> {
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();
Expand All @@ -59,7 +73,7 @@ export async function runSeedDb(db: SeedDb): Promise<boolean> {
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
Expand Down Expand Up @@ -145,7 +159,7 @@ export async function runSeedDb(db: SeedDb): Promise<boolean> {
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);
Expand All @@ -166,49 +180,58 @@ export async function runSeedDb(db: SeedDb): Promise<boolean> {
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) {
Expand Down
Loading