diff --git a/src/backend/.env.example b/src/backend/.env.example index de41f8a..7eb977c 100644 --- a/src/backend/.env.example +++ b/src/backend/.env.example @@ -9,6 +9,21 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5434/macsync_db PORT=3000 NODE_ENV=development +# Auth Configuration +JWT_SECRET=change-me +JWT_EXPIRES_IN=60m +VERIFICATION_CODE_EXPIRY_MIN=10 +VERIFICATION_CODE_SECRET=change-me +# Optional password settings +BCRYPT_SALT_ROUNDS=10 + +# Email (SMTP) +SMTP_HOST= +SMTP_PORT= +SMTP_USER= +SMTP_PASS= +EMAIL_FROM=no-reply@mcmaster.ca + # Optional: run seed on startup if DB is empty (idempotent) # RUN_SEED=true diff --git a/src/backend/package-lock.json b/src/backend/package-lock.json index f106812..723ac5a 100644 --- a/src/backend/package-lock.json +++ b/src/backend/package-lock.json @@ -14,8 +14,11 @@ "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-fastify": "^11.1.12", + "bcryptjs": "^3.0.2", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", + "jsonwebtoken": "^9.0.3", + "nodemailer": "^8.0.1", "pg": "^8.18.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -29,7 +32,9 @@ "@nestjs/testing": "^11.0.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.10.7", + "@types/nodemailer": "^7.0.9", "@types/pg": "^8.16.0", "@types/supertest": "^6.0.2", "drizzle-kit": "^0.31.8", @@ -3958,6 +3963,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmmirror.com/@types/methods/-/methods-1.1.4.tgz", @@ -3965,6 +3981,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.9", "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.9.tgz", @@ -3976,6 +3999,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.16.0", "resolved": "https://registry.npmmirror.com/@types/pg/-/pg-8.16.0.tgz", @@ -5202,6 +5235,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", @@ -5345,6 +5387,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", @@ -6159,6 +6207,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", @@ -8667,6 +8724,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", @@ -8814,6 +8914,42 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8828,6 +8964,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", @@ -9224,6 +9366,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/src/backend/package.json b/src/backend/package.json index 982f48d..13b5cea 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -32,8 +32,11 @@ "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-fastify": "^11.1.12", + "bcryptjs": "^3.0.2", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", + "jsonwebtoken": "^9.0.3", + "nodemailer": "^8.0.1", "pg": "^8.18.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -47,7 +50,9 @@ "@nestjs/testing": "^11.0.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.10.7", + "@types/nodemailer": "^7.0.9", "@types/pg": "^8.16.0", "@types/supertest": "^6.0.2", "drizzle-kit": "^0.31.8", diff --git a/src/backend/src/app.module.ts b/src/backend/src/app.module.ts index db7ff94..fdc0acc 100644 --- a/src/backend/src/app.module.ts +++ b/src/backend/src/app.module.ts @@ -4,6 +4,7 @@ import { AppService } from './app.service'; import { DatabaseModule } from './database/database.module'; import { UsersModule } from './users/users.module'; import { EventsModule } from './events/events.module'; +import { AuthModule } from './auth/auth.module'; import { WebhooksModule } from './webhooks/webhooks.module'; import { PaymentsModule } from './payments/payments.module'; import { StatsModule } from './stats/stats.module'; @@ -15,6 +16,7 @@ import { StatsModule } from './stats/stats.module'; EventsModule, WebhooksModule, PaymentsModule, + AuthModule, StatsModule, ], controllers: [AppController], diff --git a/src/backend/src/auth/auth.controller.ts b/src/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..92cbb80 --- /dev/null +++ b/src/backend/src/auth/auth.controller.ts @@ -0,0 +1,75 @@ +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'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('request-code') + requestCode(@Body('email') email: string) { + return this.authService.requestVerificationCode(email); + } + + @Post('check-email') + checkEmail(@Body('email') email: string) { + return this.authService.checkEmail(email); + } + + @Post('login') + login(@Body() body: { email: string; password: string }) { + return this.authService.loginWithPassword(body.email, body.password); + } + + @Post('request-otp') + requestOtp(@Body('email') email: string) { + return this.authService.requestOtp(email); + } + + @Post('verify-code') + verifyCode(@Body() body: { email: string; code: string }) { + return this.authService.verifyCode(body.email, body.code); + } + + @Post('verify-otp') + verifyOtp(@Body() body: { email: string; code: string }) { + return this.authService.verifyOtp(body.email, body.code); + } + + @Post('register') + @UseGuards(OnboardingGuard) + register( + @Body() + body: { + firstName: string; + lastName: string; + phone: string; + program: string; + password: string; + confirmPassword?: string; + }, + @Req() req: any, + ) { + return this.authService.registerUser(req.onboardingEmail, body); + } + + @Get('me') + @UseGuards(JwtAuthGuard) + me(@Req() req: any) { + return this.authService.getUserInfo(req.user.sub); + } + + @Post('logout') + @UseGuards(JwtAuthGuard) + logout() { + return { success: true }; + } +} diff --git a/src/backend/src/auth/auth.module.ts b/src/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..8ff7e98 --- /dev/null +++ b/src/backend/src/auth/auth.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { DatabaseModule } from '../database/database.module'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [DatabaseModule, UsersModule], + controllers: [AuthController], + providers: [AuthService], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/src/backend/src/auth/auth.service.ts b/src/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..cb4f050 --- /dev/null +++ b/src/backend/src/auth/auth.service.ts @@ -0,0 +1,372 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { createHash, randomInt } from 'crypto'; +import bcrypt from 'bcryptjs'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const nodemailer = require('nodemailer'); +type Transporter = { sendMail: (options: any) => Promise }; +import { sign, type SignOptions } from 'jsonwebtoken'; +import { and, desc, eq, gt, isNull } from 'drizzle-orm'; +import { DatabaseService } from '../database/database.service'; +import { verificationTokens } from '../db/schema'; +import type { User } from '../db/schema'; +import { UsersService } from '../users/users.service'; +import type { JwtPayload, OnboardingJwtPayload } from './auth.types'; + +const EMAIL_DOMAIN = 'mcmaster.ca'; +const DEFAULT_CODE_EXPIRY_MIN = 10; +const DEFAULT_JWT_EXPIRES_IN_SECONDS = 60 * 60; +const DEFAULT_PASSWORD_MIN_LENGTH = 8; + +@Injectable() +export class AuthService { + private readonly transporter: Transporter | null; + + constructor( + private readonly dbService: DatabaseService, + private readonly usersService: UsersService, + ) { + this.transporter = this.createTransporter(); + } + + private get jwtSecret() { + return process.env.JWT_SECRET || 'dev-secret'; + } + + private get codeHashSecret() { + return process.env.VERIFICATION_CODE_SECRET || this.jwtSecret; + } + + private normalizeEmail(email: string) { + return email.trim().toLowerCase(); + } + + private assertMcMasterEmail(email: string) { + if (!email.endsWith(`@${EMAIL_DOMAIN}`)) { + throw new BadRequestException( + `Only @${EMAIL_DOMAIN} email addresses are allowed.`, + ); + } + } + + private generateCode() { + return randomInt(0, 1000000).toString().padStart(6, '0'); + } + + private hashCode(code: string) { + return createHash('sha256') + .update(`${code}.${this.codeHashSecret}`) + .digest('hex'); + } + + private createTransporter(): Transporter | null { + const host = process.env.SMTP_HOST; + const port = process.env.SMTP_PORT; + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASS; + + if (!host || !port) { + return null; + } + + return nodemailer.createTransport({ + host, + port: Number(port), + secure: Number(port) === 465, + auth: user && pass ? { user, pass } : undefined, + }); + } + + private async sendVerificationEmail(email: string, code: string) { + if (!this.transporter) { + // Dev fallback when SMTP is not configured. + console.log(`[Auth] Verification code for ${email}: ${code}`); + return; + } + + const from = process.env.EMAIL_FROM || `no-reply@${EMAIL_DOMAIN}`; + await this.transporter.sendMail({ + to: email, + from, + subject: 'Your McMaster verification code', + text: `Your verification code is ${code}. It expires in ${this.getCodeExpiryMinutes()} minutes.`, + }); + } + + private getCodeExpiryMinutes() { + const parsed = Number(process.env.VERIFICATION_CODE_EXPIRY_MIN); + return Number.isFinite(parsed) ? parsed : DEFAULT_CODE_EXPIRY_MIN; + } + + private getJwtExpiry(): SignOptions['expiresIn'] { + const value = process.env.JWT_EXPIRES_IN?.trim(); + if (!value) { + return DEFAULT_JWT_EXPIRES_IN_SECONDS; + } + if (/^\d+$/.test(value)) { + return Number(value); + } + return value as SignOptions['expiresIn']; + } + + private getSaltRounds() { + const parsed = Number(process.env.BCRYPT_SALT_ROUNDS); + return Number.isFinite(parsed) ? parsed : 10; + } + + private isProfileComplete(user: User) { + const hasName = + (user.firstName?.trim() && user.lastName?.trim()) || user.name?.trim(); + return Boolean( + hasName && + user.phoneNumber?.trim() && + user.program?.trim() && + user.passwordHash?.trim(), + ); + } + + private issueJwt(payload: JwtPayload | OnboardingJwtPayload) { + const options: SignOptions = { expiresIn: this.getJwtExpiry() }; + return sign(payload, this.jwtSecret, options); + } + + async requestVerificationCode(rawEmail: string) { + // Legacy OTP request (used by some clients) + return this.requestOtp(rawEmail); + } + + async checkEmail(rawEmail: string) { + if (!rawEmail) { + throw new BadRequestException('Email is required.'); + } + const email = this.normalizeEmail(rawEmail); + this.assertMcMasterEmail(email); + + const user = await this.usersService.findByEmail(email); + return { isRegistered: Boolean(user?.passwordHash) }; + } + + async requestOtp(rawEmail: string) { + // M8: login(credentials) -> begin OTP sequence for new users. + if (!rawEmail) { + throw new BadRequestException('Email is required.'); + } + const email = this.normalizeEmail(rawEmail); + this.assertMcMasterEmail(email); + + const existing = await this.usersService.findByEmail(email); + if (existing?.passwordHash) { + throw new BadRequestException('User already registered.'); + } + + const code = this.generateCode(); + const codeHash = this.hashCode(code); + const expiresAt = new Date( + Date.now() + this.getCodeExpiryMinutes() * 60 * 1000, + ); + + await this.dbService.db.insert(verificationTokens).values({ + email, + codeHash, + expiresAt, + }); + + await this.sendVerificationEmail(email, code); + + return { sent: true, expiresAt }; + } + + async verifyCode(rawEmail: string, code: string) { + // Legacy OTP verify (used by some clients) + return this.verifyOtp(rawEmail, code); + } + + async verifyOtp(rawEmail: string, code: string) { + // M8: login(credentials) -> return onboarding token after OTP succeeds. + if (!rawEmail || !code) { + throw new BadRequestException('Email and code are required.'); + } + const email = this.normalizeEmail(rawEmail); + this.assertMcMasterEmail(email); + + const existing = await this.usersService.findByEmail(email); + if (existing?.passwordHash) { + throw new BadRequestException('User already registered.'); + } + + const records = await this.dbService.db + .select() + .from(verificationTokens) + .where( + and( + eq(verificationTokens.email, email), + isNull(verificationTokens.usedAt), + gt(verificationTokens.expiresAt, new Date()), + ), + ) + .orderBy(desc(verificationTokens.createdAt)) + .limit(1); + + const record = records[0]; + if (!record) { + throw new UnauthorizedException('Verification code expired or invalid.'); + } + + const codeHash = this.hashCode(code.trim()); + if (record.codeHash !== codeHash) { + throw new UnauthorizedException('Invalid verification code.'); + } + + await this.dbService.db + .update(verificationTokens) + .set({ usedAt: new Date() }) + .where(eq(verificationTokens.id, record.id)); + + const token = this.issueJwt({ email, onboarding: true }); + return { + token, + needsRegistration: true, + }; + } + + async registerUser( + email: string, + data: { + firstName: string; + lastName: string; + phone: string; + program: string; + password: string; + confirmPassword?: string; + }, + ) { + // One-time onboarding for newly verified users. + const safeData = data ?? { + firstName: '', + lastName: '', + phone: '', + program: '', + password: '', + }; + if (!safeData.firstName || !safeData.lastName) { + throw new BadRequestException('First name and last name are required.'); + } + const firstName = safeData.firstName.replace(/\s+/g, ' ').trim(); + const lastName = safeData.lastName.replace(/\s+/g, ' ').trim(); + const name = `${firstName} ${lastName}`.trim(); + if (!safeData.phone || !safeData.program) { + throw new BadRequestException('Phone and program are required.'); + } + const program = safeData.program.trim(); + const phone = safeData.phone.trim(); + + if (!/^[A-Za-z-]+$/.test(firstName) || !/^[A-Za-z-]+$/.test(lastName)) { + throw new BadRequestException( + 'Name must contain only letters and hyphens.', + ); + } + + const normalizedPhone = phone.replace(/[\s()-]/g, ''); + if (!/^\+?\d{10,15}$/.test(normalizedPhone)) { + throw new BadRequestException( + 'Phone number must be 10 digits or include a valid country code.', + ); + } + + if (!program) { + throw new BadRequestException('Program is required.'); + } + + if (!safeData.password) { + throw new BadRequestException('Password is required.'); + } + if ( + safeData.confirmPassword != null && + safeData.password !== safeData.confirmPassword + ) { + throw new BadRequestException('Passwords do not match.'); + } + const password = safeData.password.trim(); + if ( + password.length < DEFAULT_PASSWORD_MIN_LENGTH || + !/[A-Z]/.test(password) || + !/[a-z]/.test(password) || + !/\d/.test(password) + ) { + throw new BadRequestException( + 'Password must be at least 8 characters and include upper, lower, and number.', + ); + } + + const existing = await this.usersService.findByEmail(email); + if (existing?.passwordHash) { + throw new BadRequestException('Profile is already complete.'); + } + + const passwordHash = await bcrypt.hash(password, this.getSaltRounds()); + + let user: User; + if (existing) { + user = await this.usersService.update(existing.id, { + name, + firstName, + lastName, + passwordHash, + phoneNumber: normalizedPhone, + program, + }); + } else { + user = await this.usersService.create({ + email, + name, + firstName, + lastName, + passwordHash, + phoneNumber: normalizedPhone, + program, + }); + } + + const userWithRoles = await this.usersService.findOneWithRoles(user.id); + const token = this.issueJwt({ sub: user.id, email: user.email }); + + return { + token, + user: userWithRoles ?? user, + }; + } + + async loginWithPassword(rawEmail: string, password: string) { + if (!rawEmail || !password) { + throw new BadRequestException('Email and password are required.'); + } + const email = this.normalizeEmail(rawEmail); + this.assertMcMasterEmail(email); + + const user = await this.usersService.findByEmail(email); + if (!user?.passwordHash) { + throw new UnauthorizedException('Invalid credentials.'); + } + + const matches = await bcrypt.compare(password, user.passwordHash); + if (!matches) { + throw new UnauthorizedException('Invalid credentials.'); + } + + const token = this.issueJwt({ sub: user.id, email: user.email }); + const userWithRoles = await this.usersService.findOneWithRoles(user.id); + return { token, user: userWithRoles ?? user }; + } + + async getUserInfo(userId: number) { + // M8: getUserInfo(token) -> resolve authenticated user profile. + const user = await this.usersService.findOneWithRoles(userId); + if (!user) { + throw new UnauthorizedException('User not found.'); + } + return user; + } +} diff --git a/src/backend/src/auth/auth.types.ts b/src/backend/src/auth/auth.types.ts new file mode 100644 index 0000000..74c72dd --- /dev/null +++ b/src/backend/src/auth/auth.types.ts @@ -0,0 +1,9 @@ +export interface JwtPayload { + sub: number; + email: string; +} + +export interface OnboardingJwtPayload { + email: string; + onboarding: true; +} diff --git a/src/backend/src/auth/jwt-auth.guard.ts b/src/backend/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..7b109b0 --- /dev/null +++ b/src/backend/src/auth/jwt-auth.guard.ts @@ -0,0 +1,44 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { verify } from 'jsonwebtoken'; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + // 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; + + if (!token) { + throw new UnauthorizedException('Missing authorization token.'); + } + + try { + const decoded = verify( + token, + process.env.JWT_SECRET || 'dev-secret', + ); + + if (typeof decoded !== 'object' || decoded === null) { + throw new UnauthorizedException('Invalid token payload.'); + } + + const { sub, email } = decoded as { sub?: unknown; email?: unknown }; + if (!sub || typeof email !== 'string') { + throw new UnauthorizedException('Invalid token payload.'); + } + + request.user = { sub: Number(sub), email }; + return true; + } catch (error) { + throw new UnauthorizedException('Invalid or expired token.'); + } + } +} diff --git a/src/backend/src/auth/onboarding.guard.ts b/src/backend/src/auth/onboarding.guard.ts new file mode 100644 index 0000000..4eb5b15 --- /dev/null +++ b/src/backend/src/auth/onboarding.guard.ts @@ -0,0 +1,47 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { verify } from 'jsonwebtoken'; +import type { OnboardingJwtPayload } from './auth.types'; + +@Injectable() +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; + + if (!token) { + throw new UnauthorizedException('Missing onboarding token.'); + } + + try { + const decoded = verify( + token, + process.env.JWT_SECRET || 'dev-secret', + ); + + if (typeof decoded !== 'object' || decoded === null) { + throw new UnauthorizedException('Invalid onboarding token.'); + } + + const { onboarding, email } = decoded as { + onboarding?: unknown; + email?: unknown; + }; + if (onboarding !== true || typeof email !== 'string') { + throw new UnauthorizedException('Invalid onboarding token.'); + } + + request.onboardingEmail = email; + return true; + } catch (error) { + throw new UnauthorizedException('Invalid or expired onboarding token.'); + } + } +} diff --git a/src/backend/src/db/schema.ts b/src/backend/src/db/schema.ts index 2d06efb..e7a0b6f 100644 --- a/src/backend/src/db/schema.ts +++ b/src/backend/src/db/schema.ts @@ -15,6 +15,9 @@ export const users = pgTable('users', { id: serial('id').primaryKey(), email: varchar('email', { length: 255 }).notNull().unique(), name: varchar('name', { length: 255 }).notNull(), + firstName: varchar('first_name', { length: 255 }), + lastName: varchar('last_name', { length: 255 }), + passwordHash: varchar('password_hash', { length: 255 }), phoneNumber: varchar('phone_number', { length: 255 }).notNull(), program: varchar('program', { length: 255 }), isSystemAdmin: boolean('is_system_admin').default(false), @@ -25,6 +28,19 @@ export const users = pgTable('users', { export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; +// ------------------- EMAIL VERIFICATION TOKENS ------------------- +export const verificationTokens = pgTable('verification_tokens', { + id: serial('id').primaryKey(), + email: varchar('email', { length: 255 }).notNull(), + codeHash: varchar('code_hash', { length: 255 }).notNull(), + expiresAt: timestamp('expires_at').notNull(), + usedAt: timestamp('used_at'), + createdAt: timestamp('created_at').defaultNow(), +}); + +export type VerificationToken = typeof verificationTokens.$inferSelect; +export type NewVerificationToken = typeof verificationTokens.$inferInsert; + // ------------------- ROLES ------------------- export const roles = pgTable('roles', { id: serial('id').primaryKey(), diff --git a/src/backend/src/events/events.controller.ts b/src/backend/src/events/events.controller.ts index 4e35543..6231931 100644 --- a/src/backend/src/events/events.controller.ts +++ b/src/backend/src/events/events.controller.ts @@ -7,11 +7,16 @@ import { Body, Param, Query, + Req, + UseGuards, + ForbiddenException, } from '@nestjs/common'; import { EventsService } from './events.service'; import type { NewEvent } from '../db/schema'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @Controller('events') +@UseGuards(JwtAuthGuard) export class EventsController { constructor(private readonly eventsService: EventsService) {} @@ -44,18 +49,18 @@ export class EventsController { @Post(':id/signup') signup( @Param('id') id: string, - @Body('userId') userId: number, + @Req() req: any, @Body('selectedTable') selectedTable?: number, ) { - return this.eventsService.signup(+id, userId, selectedTable); + return this.eventsService.signup(+id, req.user.sub, selectedTable); } @Post(':id/checkout-session') createCheckoutSession( @Param('id') id: string, + @Req() req: any, @Body() body: { - userId: number; successUrl?: string; cancelUrl?: string; selectedTable?: number; @@ -83,7 +88,7 @@ export class EventsController { 'http://localhost:8081/payment-cancel'; return this.eventsService.createCheckoutSession( +id, - body.userId, + req.user.sub, successUrl, cancelUrl, body.selectedTable, // Guaranteed table assignment, not just a preference @@ -104,12 +109,15 @@ export class EventsController { } @Post(':id/cancel') - cancelSignup(@Param('id') id: string, @Body('userId') userId: number) { - return this.eventsService.cancelSignup(+id, userId); + 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) { + getUserTickets(@Param('userId') userId: string, @Req() req: any) { + if (req.user?.sub !== +userId) { + throw new ForbiddenException('Access denied.'); + } return this.eventsService.getTicketsForUser(+userId); } } diff --git a/src/backend/src/users/users.controller.ts b/src/backend/src/users/users.controller.ts index 9169a58..7022adb 100644 --- a/src/backend/src/users/users.controller.ts +++ b/src/backend/src/users/users.controller.ts @@ -6,11 +6,16 @@ import { Delete, Body, Param, + Req, + UseGuards, + ForbiddenException, } from '@nestjs/common'; import { UsersService } from './users.service'; import type { NewUser } from '../db/schema'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @Controller('users') +@UseGuards(JwtAuthGuard) export class UsersController { constructor(private readonly usersService: UsersService) {} @@ -20,7 +25,10 @@ export class UsersController { } @Get(':id') - findOne(@Param('id') id: string) { + findOne(@Param('id') id: string, @Req() req: any) { + if (req.user?.sub !== +id) { + throw new ForbiddenException('Access denied.'); + } return this.usersService.findOneWithRoles(+id); } @@ -30,12 +38,18 @@ export class UsersController { } @Put(':id') - update(@Param('id') id: string, @Body() user: Partial) { + update(@Param('id') id: string, @Body() user: Partial, @Req() req: any) { + if (req.user?.sub !== +id) { + throw new ForbiddenException('Access denied.'); + } return this.usersService.update(+id, user); } @Delete(':id') - delete(@Param('id') id: string) { + delete(@Param('id') id: string, @Req() req: any) { + if (req.user?.sub !== +id) { + throw new ForbiddenException('Access denied.'); + } return this.usersService.delete(+id); } } diff --git a/src/backend/src/users/users.module.ts b/src/backend/src/users/users.module.ts index 440ef36..513776d 100644 --- a/src/backend/src/users/users.module.ts +++ b/src/backend/src/users/users.module.ts @@ -5,5 +5,6 @@ import { UsersService } from './users.service'; @Module({ controllers: [UsersController], providers: [UsersService], + exports: [UsersService], }) export class UsersModule {} diff --git a/src/backend/src/users/users.service.ts b/src/backend/src/users/users.service.ts index ddb1dcd..690bccf 100644 --- a/src/backend/src/users/users.service.ts +++ b/src/backend/src/users/users.service.ts @@ -8,8 +8,16 @@ import { eq } from 'drizzle-orm'; export class UsersService { constructor(private readonly dbService: DatabaseService) {} + private stripSensitive(user: T) { + // Avoid leaking password hashes to clients. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { passwordHash, ...rest } = user; + return rest as Omit; + } + async findAll() { - return await this.dbService.db.select().from(users); + const rows = await this.dbService.db.select().from(users); + return rows.map((row) => this.stripSensitive(row)); } async findOne(id: number) { @@ -17,7 +25,7 @@ export class UsersService { .select() .from(users) .where(eq(users.id, id)); - return result[0]; + return result[0] ? this.stripSensitive(result[0]) : undefined; } async findOneWithRoles(id: number) { @@ -34,10 +42,24 @@ export class UsersService { .innerJoin(roles, eq(userRoles.roleId, roles.id)) .where(eq(userRoles.userId, id)); - return { + return this.stripSensitive({ ...user[0], roles: userRoleRows.map((r) => r.roleName), - }; + }); + } + + async findByEmail(email: string) { + const result = await this.dbService.db + .select() + .from(users) + .where(eq(users.email, email)); + return result[0] ?? null; + } + + async findByEmailWithRoles(email: string) { + const user = await this.findByEmail(email); + if (!user) return null; + return this.findOneWithRoles(user.id); } async create(user: NewUser) { diff --git a/src/frontend/mobile/app/(auth)/_layout.tsx b/src/frontend/mobile/app/(auth)/_layout.tsx new file mode 100644 index 0000000..83dbe7b --- /dev/null +++ b/src/frontend/mobile/app/(auth)/_layout.tsx @@ -0,0 +1,10 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ( + + ); +} diff --git a/src/frontend/mobile/app/(auth)/login.tsx b/src/frontend/mobile/app/(auth)/login.tsx new file mode 100644 index 0000000..7d62442 --- /dev/null +++ b/src/frontend/mobile/app/(auth)/login.tsx @@ -0,0 +1,437 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + View, + Text, + TextInput, + Pressable, + Alert, + Modal, + FlatList, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { useAuth } from '../_context/AuthContext'; + +type Step = 'email' | 'password' | 'otp' | 'register'; + +const PROGRAM_OPTIONS = [ + 'Computer Science', + 'Software Engineering', + 'Electrical Engineering', + 'Mechanical Engineering', + 'Business', + 'Health Sciences', + 'Humanities', + 'Other', +]; + +export default function LoginScreen() { + const router = useRouter(); + const { + status, + pendingEmail, + checkEmail, + login, + requestOtp, + confirmCode, + completeRegistration, + } = useAuth(); + const [step, setStep] = useState('email'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [otp, setOtp] = useState(''); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [phone, setPhone] = useState(''); + const [program, setProgram] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPrograms, setShowPrograms] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + if (status === 'needsRegistration') { + if (pendingEmail) { + setEmail(pendingEmail); + } + setStep('register'); + } + }, [status, pendingEmail]); + + const normalizedEmail = email.trim().toLowerCase(); + const normalizedFirstName = firstName.replace(/\s+/g, ' ').trim(); + const normalizedLastName = lastName.replace(/\s+/g, ' ').trim(); + const phoneDigits = phone.replace(/\D/g, ''); + const namePattern = /^[A-Za-z-]+$/; + const isFirstNameValid = + normalizedFirstName.length > 0 && namePattern.test(normalizedFirstName); + const isLastNameValid = + normalizedLastName.length > 0 && namePattern.test(normalizedLastName); + const isPhoneValid = /^\d{10,15}$/.test(phoneDigits); + const isProgramValid = program.trim().length > 0; + const isPasswordComplex = + password.length >= 8 && + /[A-Z]/.test(password) && + /[a-z]/.test(password) && + /\d/.test(password); + const passwordsMatch = password.length > 0 && password === confirmPassword; + + const stepTitle = useMemo(() => { + if (step === 'password') return 'Welcome back'; + if (step === 'otp') return 'Verify your email'; + if (step === 'register') return 'Complete your profile'; + return 'Welcome to MacSync'; + }, [step]); + + const stepSubtitle = useMemo(() => { + if (step === 'password') return 'Enter your password to continue.'; + if (step === 'otp') return `Enter the code sent to ${normalizedEmail}.`; + if (step === 'register') return 'Create your password and profile.'; + return 'Sign in with your McMaster email to continue.'; + }, [step, normalizedEmail]); + + const handleEmailContinue = async () => { + if (!normalizedEmail.endsWith('@mcmaster.ca')) { + const message = 'Please use your @mcmaster.ca email.'; + setErrorMessage(message); + Alert.alert('Invalid email', message); + return; + } + + setSubmitting(true); + try { + const isRegistered = await checkEmail(normalizedEmail); + setErrorMessage(''); + if (isRegistered) { + setStep('password'); + } else { + await requestOtp(normalizedEmail); + setStep('otp'); + } + } catch (error: any) { + const message = error.message || 'Failed to continue.'; + setErrorMessage(message); + Alert.alert('Error', message); + } finally { + setSubmitting(false); + } + }; + + const handlePasswordLogin = async () => { + if (!password) { + const message = 'Password is required.'; + setErrorMessage(message); + Alert.alert('Missing password', message); + return; + } + setSubmitting(true); + try { + await login(normalizedEmail, password); + setErrorMessage(''); + router.replace('/(tabs)'); + } catch (error: any) { + const message = error.message || 'Login failed.'; + setErrorMessage(message); + Alert.alert('Error', message); + } finally { + setSubmitting(false); + } + }; + + const handleVerifyOtp = async () => { + if (otp.trim().length !== 6) { + const message = 'Enter the 6-digit verification code.'; + setErrorMessage(message); + Alert.alert('Invalid code', message); + return; + } + setSubmitting(true); + try { + await confirmCode(normalizedEmail, otp.trim()); + setErrorMessage(''); + setStep('register'); + } catch (error: any) { + const message = error.message || 'Failed to verify code.'; + setErrorMessage(message); + Alert.alert('Error', message); + } finally { + setSubmitting(false); + } + }; + + const handleRegister = async () => { + if (!isFirstNameValid || !isLastNameValid) { + const message = 'Use letters and hyphens only.'; + setErrorMessage(message); + Alert.alert('Invalid name', message); + return; + } + if (!isPhoneValid) { + const message = 'Use 10 digits or include a valid country code.'; + setErrorMessage(message); + Alert.alert('Invalid phone number', message); + return; + } + if (!isProgramValid) { + const message = 'Please select your program.'; + setErrorMessage(message); + Alert.alert('Missing program', message); + return; + } + if (!isPasswordComplex) { + const message = + 'Password must be 8+ chars with upper, lower, and number.'; + setErrorMessage(message); + Alert.alert('Weak password', message); + return; + } + if (!passwordsMatch) { + const message = 'Passwords do not match.'; + setErrorMessage(message); + Alert.alert('Password mismatch', message); + return; + } + + setSubmitting(true); + try { + await completeRegistration({ + firstName: normalizedFirstName, + lastName: normalizedLastName, + phone: phoneDigits, + program: program.trim(), + password, + confirmPassword, + }); + setErrorMessage(''); + router.replace('/(tabs)'); + } catch (error: any) { + const message = error.message || 'Failed to complete registration.'; + setErrorMessage(message); + Alert.alert('Error', message); + } finally { + setSubmitting(false); + } + }; + + return ( + + + {stepTitle} + {stepSubtitle} + + + + {step === 'email' && ( + <> + Email + { + setEmail(value); + if (errorMessage) setErrorMessage(''); + }} + autoCapitalize="none" + keyboardType="email-address" + placeholder="name@mcmaster.ca" + placeholderTextColor="#C7CBD1" + returnKeyType="next" + blurOnSubmit + onSubmitEditing={handleEmailContinue} + className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50 text-sm" + /> + + )} + + {step === 'password' && ( + <> + + Password + + { + setPassword(value); + if (errorMessage) setErrorMessage(''); + }} + secureTextEntry + placeholder="Enter your password" + placeholderTextColor="#C7CBD1" + returnKeyType="send" + blurOnSubmit + onSubmitEditing={handlePasswordLogin} + className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50 text-sm" + /> + + )} + + {step === 'otp' && ( + <> + + Verification code + + { + setOtp(value); + if (errorMessage) setErrorMessage(''); + }} + keyboardType="number-pad" + placeholder="123456" + maxLength={6} + placeholderTextColor="#C7CBD1" + returnKeyType="send" + blurOnSubmit + onSubmitEditing={handleVerifyOtp} + className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50 text-sm tracking-widest text-center" + /> + + )} + + {step === 'register' && ( + <> + + First name {isFirstNameValid ? '✅' : ''} + + + + + Last name {isLastNameValid ? '✅' : ''} + + + + + Phone number {isPhoneValid ? '✅' : ''} + + + + + Program {isProgramValid ? '✅' : ''} + + setShowPrograms(true)} + className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50" + > + + {program || 'Select your program'} + + + + + Password {isPasswordComplex ? '✅' : ''} + + + + + Confirm password {passwordsMatch ? '✅' : ''} + + + + )} + + {!!errorMessage && ( + {errorMessage} + )} + + { + if (step === 'email') return handleEmailContinue(); + if (step === 'password') return handlePasswordLogin(); + if (step === 'otp') return handleVerifyOtp(); + return handleRegister(); + }} + disabled={submitting} + className="mt-5 py-3 bg-maroon rounded-xl active:bg-maroon-dark disabled:opacity-60" + > + + {submitting + ? 'Please wait...' + : step === 'email' + ? 'Continue' + : step === 'password' + ? 'Login' + : step === 'otp' + ? 'Verify code' + : 'Finish registration'} + + + + + + setShowPrograms(false)} + > + e.stopPropagation()} + > + + Select program + + item} + renderItem={({ item }) => ( + { + setProgram(item); + setShowPrograms(false); + }} + className="px-5 py-3.5 border-t border-gray-100" + > + + {item} + + + )} + /> + setShowPrograms(false)} + className="border-t border-gray-200 py-3.5" + > + + Cancel + + + + + + + ); +} diff --git a/src/frontend/mobile/app/(auth)/register.tsx b/src/frontend/mobile/app/(auth)/register.tsx new file mode 100644 index 0000000..cff2783 --- /dev/null +++ b/src/frontend/mobile/app/(auth)/register.tsx @@ -0,0 +1,5 @@ +import { Redirect } from 'expo-router'; + +export default function RegisterScreen() { + return ; +} diff --git a/src/frontend/mobile/app/(auth)/verify.tsx b/src/frontend/mobile/app/(auth)/verify.tsx new file mode 100644 index 0000000..30f75a2 --- /dev/null +++ b/src/frontend/mobile/app/(auth)/verify.tsx @@ -0,0 +1,5 @@ +import { Redirect } from 'expo-router'; + +export default function VerifyScreen() { + return ; +} diff --git a/src/frontend/mobile/app/(tabs)/_layout.tsx b/src/frontend/mobile/app/(tabs)/_layout.tsx index de5cb68..39d4781 100644 --- a/src/frontend/mobile/app/(tabs)/_layout.tsx +++ b/src/frontend/mobile/app/(tabs)/_layout.tsx @@ -1,82 +1,32 @@ -import { Tabs } from "expo-router"; -import { View, Text, Pressable, Modal, FlatList, Platform } from "react-native"; -import { useState } from "react"; +import { Tabs, useRouter } from "expo-router"; +import { View, Text, Pressable } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useUser } from "../_context/UserContext"; +import { useAuth } from "../_context/AuthContext"; function HeaderRight() { - const { currentUser, allUsers, switchUser } = useUser(); - const [showPicker, setShowPicker] = useState(false); + const { user, logout } = useAuth(); + const router = useRouter(); return ( - <> + + + + {user?.name?.charAt(0) || "?"} + + + + {user?.name || "Account"} + setShowPicker(true)} - className="flex-row items-center mr-4 px-3 py-1.5 rounded-full bg-gray-100" + onPress={async () => { + await logout(); + router.replace("/(auth)/login"); + }} + className="ml-3 px-3 py-1.5 rounded-full bg-gray-100" > - - - {currentUser?.name?.charAt(0) || "?"} - - - - {currentUser?.name || "Select User"} - + Logout - - - setShowPicker(false)} - > - e.stopPropagation()} - > - - Switch User (Dev) - - String(item.id)} - renderItem={({ item }) => ( - { - switchUser(item.id); - setShowPicker(false); - }} - className={`px-5 py-3.5 border-t border-gray-100 ${ - currentUser?.id === item.id ? "bg-gray-50" : "" - }`} - > - - {item.name} - - - {item.email} - {item.isSystemAdmin && ( - - - ADMIN - - - )} - - - )} - /> - setShowPicker(false)} - className="border-t border-gray-200 py-3.5" - > - - Cancel - - - - - - + ); } diff --git a/src/frontend/mobile/app/(tabs)/index.tsx b/src/frontend/mobile/app/(tabs)/index.tsx index 572f6bc..8045d68 100644 --- a/src/frontend/mobile/app/(tabs)/index.tsx +++ b/src/frontend/mobile/app/(tabs)/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useMemo, useCallback, useEffect } from "react"; import { View, Text, @@ -22,7 +22,7 @@ import { getUserTickets, type EventItem, } from "../_lib/api"; -import { useUser } from "../_context/UserContext"; +import { useAuth } from "../_context/AuthContext"; function EventCard({ event, @@ -173,7 +173,7 @@ function EventCard({ } export default function EventsScreen() { - const { currentUser, isAdmin, loading: userLoading } = useUser(); + const { user, isAdmin, status } = useAuth(); const [events, setEvents] = useState([]); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(true); @@ -215,9 +215,9 @@ export default function EventsScreen() { }; const loadUserTickets = async () => { - if (!currentUser) return; + if (!user) return; try { - const tickets = await getUserTickets(currentUser.id); + const tickets = await getUserTickets(user.id); setSignedUpEventIds(new Set(tickets.map((t) => t.eventId))); } catch (err) { console.error("Failed to load tickets:", err); @@ -229,7 +229,7 @@ export default function EventsScreen() { useCallback(() => { loadEvents(); loadUserTickets(); - }, [currentUser]) + }, [user]) ); const onRefresh = async () => { @@ -251,23 +251,23 @@ export default function EventsScreen() { }, [events, search]); const handleSignUp = async (eventId: number) => { - if (!currentUser) return; + if (!user) return; router.push(`/event-signup?eventId=${eventId}`); }; const handleCancel = async (eventId: number) => { - if (!currentUser) return; + if (!user) return; console.log("Cancel clicked for event:", eventId); setEventToCancel(eventId); setShowCancelModal(true); }; const confirmCancel = async () => { - if (!currentUser || !eventToCancel) return; + if (!user || !eventToCancel) return; console.log("Cancelling signup..."); setShowCancelModal(false); try { - const result = await cancelSignup(eventToCancel, currentUser.id); + const result = await cancelSignup(eventToCancel); console.log("Cancel result:", result); if (result.error) { if (Platform.OS === 'web') { @@ -296,7 +296,7 @@ export default function EventsScreen() { } }; - if (userLoading || loading) { + if (status === "loading" || loading) { return ( @@ -342,7 +342,7 @@ export default function EventsScreen() { 🔍 ([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -24,9 +24,9 @@ export default function MySignUpsScreen() { const [eventToCancel, setEventToCancel] = useState(null); const loadTickets = async () => { - if (!currentUser) return; + if (!user) return; try { - const data = await getUserTickets(currentUser.id); + const data = await getUserTickets(user.id); setTickets(data); } catch (err) { console.error("Failed to load tickets:", err); @@ -38,11 +38,11 @@ export default function MySignUpsScreen() { // Reload tickets every time this screen gets focus useFocusEffect( useCallback(() => { - if (currentUser) { + if (user) { setLoading(true); loadTickets(); } - }, [currentUser]) + }, [user]) ); const onRefresh = async () => { @@ -52,18 +52,18 @@ export default function MySignUpsScreen() { }; const handleCancel = async (eventId: number) => { - if (!currentUser) return; + if (!user) return; console.log("Cancel clicked for event:", eventId); setEventToCancel(eventId); setShowCancelModal(true); }; const confirmCancel = async () => { - if (!currentUser || !eventToCancel) return; + if (!user || !eventToCancel) return; console.log("Cancelling signup..."); setShowCancelModal(false); try { - const result = await cancelSignup(eventToCancel, currentUser.id); + const result = await cancelSignup(eventToCancel); console.log("Cancel result:", result); if (result.error) { if (Platform.OS === 'web') { @@ -105,7 +105,7 @@ export default function MySignUpsScreen() { return `$${(price / 100).toFixed(2)}`; }; - if (userLoading || loading) { + if (status === "loading" || loading) { return ( @@ -113,11 +113,11 @@ export default function MySignUpsScreen() { ); } - if (!currentUser) { + if (!user) { return ( - Please select a user to view sign-ups. + Please sign in to view your sign-ups. ); diff --git a/src/frontend/mobile/app/(tabs)/profile.tsx b/src/frontend/mobile/app/(tabs)/profile.tsx index 78dfaee..342c7ea 100644 --- a/src/frontend/mobile/app/(tabs)/profile.tsx +++ b/src/frontend/mobile/app/(tabs)/profile.tsx @@ -9,10 +9,10 @@ import { Alert, } from "react-native"; import { updateUser } from "../_lib/api"; -import { useUser } from "../_context/UserContext"; +import { useAuth } from "../_context/AuthContext"; export default function ProfileScreen() { - const { currentUser, isAdmin, loading, switchUser } = useUser(); + const { user, isAdmin, status, refreshUser } = useAuth(); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const [form, setForm] = useState({ @@ -23,30 +23,30 @@ export default function ProfileScreen() { }); useEffect(() => { - if (currentUser) { + if (user) { setForm({ - name: currentUser.name, - email: currentUser.email, - phoneNumber: currentUser.phoneNumber, - program: currentUser.program || "", + name: user.name, + email: user.email, + phoneNumber: user.phoneNumber, + program: user.program || "", }); setEditing(false); } - }, [currentUser]); + }, [user]); const handleSave = async () => { - if (!currentUser) return; + if (!user) return; setSaving(true); try { - await updateUser(currentUser.id, { + await updateUser(user.id, { name: form.name, phoneNumber: form.phoneNumber, program: form.program || null, } as any); Alert.alert("Success", "Profile updated successfully."); setEditing(false); - switchUser(currentUser.id); + await refreshUser(); } catch (err: any) { Alert.alert("Error", err.message || "Failed to update profile"); } finally { @@ -54,7 +54,7 @@ export default function ProfileScreen() { } }; - if (loading) { + if (status === "loading") { return ( @@ -62,11 +62,11 @@ export default function ProfileScreen() { ); } - if (!currentUser) { + if (!user) { return ( - Please select a user to view profile. + Please sign in to view your profile. ); @@ -91,12 +91,12 @@ export default function ProfileScreen() { - {currentUser.name.charAt(0)} + {user.name.charAt(0)} - {currentUser.name} + {user.name} {isAdmin && ( @@ -107,7 +107,7 @@ export default function ProfileScreen() { )} - {currentUser.roles?.map((role) => ( + {user.roles?.map((role) => ( setForm((p) => ({ ...p, program: v }))} editable={editing} placeholder={editing ? "e.g. Computer Science" : "—"} - placeholderTextColor="#9CA3AF" + placeholderTextColor="#C7CBD1" className={`w-full px-4 py-3 border rounded-xl text-sm ${ editing ? "border-gray-300 bg-white text-gray-900" @@ -202,10 +202,10 @@ export default function ProfileScreen() { onPress={() => { setEditing(false); setForm({ - name: currentUser.name, - email: currentUser.email, - phoneNumber: currentUser.phoneNumber, - program: currentUser.program || "", + name: user.name, + email: user.email, + phoneNumber: user.phoneNumber, + program: user.program || "", }); }} className="flex-1 py-3 border border-gray-300 rounded-xl active:bg-gray-50" diff --git a/src/frontend/mobile/app/_context/AuthContext.tsx b/src/frontend/mobile/app/_context/AuthContext.tsx new file mode 100644 index 0000000..2a6f97a --- /dev/null +++ b/src/frontend/mobile/app/_context/AuthContext.tsx @@ -0,0 +1,170 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { + getMe, + checkEmail, + loginWithPassword, + requestVerificationCode, + requestOtp, + verifyCode, + verifyOtp, + registerProfile, + type User, +} from '../_lib/api'; +import { getAuthToken, setAuthToken } from '../_lib/auth'; + +type AuthStatus = 'loading' | 'unauthenticated' | 'needsRegistration' | 'authenticated'; + +interface AuthContextType { + status: AuthStatus; + user: User | null; + pendingEmail: string | null; + isAdmin: boolean; + checkEmail: (email: string) => Promise; + login: (email: string, password: string) => Promise; + requestCode: (email: string) => Promise; + requestOtp: (email: string) => Promise; + confirmCode: (email: string, code: string) => Promise<'needsRegistration'>; + completeRegistration: (data: { + firstName: string; + lastName: string; + phone: string; + program: string; + password: string; + confirmPassword?: string; + }) => Promise; + logout: () => Promise; + refreshUser: () => Promise; +} + +const AuthContext = createContext({ + status: 'loading', + user: null, + pendingEmail: null, + isAdmin: false, + checkEmail: async () => false, + login: async () => {}, + requestCode: async () => {}, + requestOtp: async () => {}, + confirmCode: async () => 'needsRegistration', + completeRegistration: async () => {}, + logout: async () => {}, + refreshUser: async () => {}, +}); + +function isProfileComplete(user: User) { + return Boolean( + (user.firstName?.trim() || user.name?.trim()) && + user.lastName?.trim() && + user.phoneNumber?.trim() && + user.program?.trim(), + ); +} + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [status, setStatus] = useState('loading'); + const [user, setUser] = useState(null); + const [pendingEmail, setPendingEmail] = useState(null); + + const refreshUser = async () => { + try { + const me = await getMe(); + setUser(me); + setStatus(isProfileComplete(me) ? 'authenticated' : 'needsRegistration'); + } catch (error) { + await setAuthToken(null); + setUser(null); + setStatus('unauthenticated'); + } + }; + + useEffect(() => { + async function init() { + const token = await getAuthToken(); + if (!token) { + setStatus('unauthenticated'); + return; + } + await refreshUser(); + } + init(); + }, []); + + const checkEmailStatus = async (email: string) => { + const result = await checkEmail(email); + return result.isRegistered; + }; + + const login = async (email: string, password: string) => { + const result = await loginWithPassword(email, password); + await setAuthToken(result.token); + setUser(result.user); + setPendingEmail(null); + setStatus('authenticated'); + }; + + const requestCode = async (email: string) => { + await requestVerificationCode(email); + }; + + const requestOtpCode = async (email: string) => { + await requestOtp(email); + }; + + const confirmCode = async (email: string, code: string) => { + const result = await verifyOtp(email, code); + await setAuthToken(result.token); + setPendingEmail(email); + setStatus('needsRegistration'); + return 'needsRegistration'; + }; + + const completeRegistration = async (data: { + firstName: string; + lastName: string; + phone: string; + program: string; + password: string; + confirmPassword?: string; + }) => { + const result = await registerProfile(data); + await setAuthToken(result.token); + setUser(result.user); + setPendingEmail(null); + setStatus('authenticated'); + }; + + const logout = async () => { + await setAuthToken(null); + setUser(null); + setPendingEmail(null); + setStatus('unauthenticated'); + }; + + const isAdmin = + user?.isSystemAdmin === true || (user?.roles?.includes('Admin') ?? false); + + return ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/src/frontend/mobile/app/_context/UserContext.tsx b/src/frontend/mobile/app/_context/UserContext.tsx deleted file mode 100644 index ab4cae9..0000000 --- a/src/frontend/mobile/app/_context/UserContext.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, { - createContext, - useContext, - useState, - useEffect, - ReactNode, -} from 'react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { getUsers, getUser, type User } from '../_lib/api'; - -const STORAGE_KEY_USER_ID = '@macsync_current_user_id'; - -interface UserContextType { - currentUser: User | null; - allUsers: User[]; - switchUser: (id: number) => void; - isAdmin: boolean; - loading: boolean; -} - -const UserContext = createContext({ - currentUser: null, - allUsers: [], - switchUser: () => {}, - isAdmin: false, - loading: true, -}); - -export function UserProvider({ children }: { children: ReactNode }) { - const [currentUser, setCurrentUser] = useState(null); - const [allUsers, setAllUsers] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function init() { - try { - const users = await getUsers(); - setAllUsers(users); - const storedId = await AsyncStorage.getItem(STORAGE_KEY_USER_ID); - const userId = storedId ? parseInt(storedId, 10) : null; - const preferred = - userId != null && !Number.isNaN(userId) - ? users.find((u) => u.id === userId) - : null; - const targetUser = preferred ?? users[0]; - if (targetUser) { - const user = await getUser(targetUser.id); - setCurrentUser(user); - await AsyncStorage.setItem(STORAGE_KEY_USER_ID, String(user.id)); - } - } catch (err) { - console.error('Failed to load users:', err); - } finally { - setLoading(false); - } - } - init(); - }, []); - - const switchUser = async (id: number) => { - try { - const user = await getUser(id); - setCurrentUser(user); - await AsyncStorage.setItem(STORAGE_KEY_USER_ID, String(user.id)); - } catch (err) { - console.error('Failed to switch user:', err); - } - }; - - const isAdmin = - currentUser?.isSystemAdmin === true || - (currentUser?.roles?.includes('Admin') ?? false); - - return ( - - {children} - - ); -} - -export function useUser() { - return useContext(UserContext); -} diff --git a/src/frontend/mobile/app/_layout.tsx b/src/frontend/mobile/app/_layout.tsx index 444ba8f..a130fd3 100644 --- a/src/frontend/mobile/app/_layout.tsx +++ b/src/frontend/mobile/app/_layout.tsx @@ -1,51 +1,76 @@ import "../global.css"; -import { Stack } from "expo-router"; +import { Stack, Redirect } from "expo-router"; import { SafeAreaProvider } from "react-native-safe-area-context"; -import { UserProvider } from "./_context/UserContext"; +import { View, ActivityIndicator } from "react-native"; +import { AuthProvider, useAuth } from "./_context/AuthContext"; + +function RootNavigator() { + const { status } = useAuth(); + + if (status === "loading") { + return ( + + + + ); + } + + return ( + <> + + + + + + + + + + {status === "authenticated" ? ( + + ) : ( + + )} + + ); +} export default function RootLayout() { return ( - - - - - - - - - - + + + ); } diff --git a/src/frontend/mobile/app/_lib/api.ts b/src/frontend/mobile/app/_lib/api.ts index 4fc345a..32705dd 100644 --- a/src/frontend/mobile/app/_lib/api.ts +++ b/src/frontend/mobile/app/_lib/api.ts @@ -1,4 +1,5 @@ import { Platform } from 'react-native'; +import { getAuthToken } from './auth'; // Set EXPO_PUBLIC_API_URL in .env for physical device (e.g. http://192.168.1.100:3000) // Otherwise: Android emulator -> 10.0.2.2:3000, iOS simulator -> localhost:3000 @@ -12,22 +13,116 @@ const getBaseUrl = (): string => { const API_BASE = getBaseUrl(); async function apiFetch(path: string, options?: RequestInit): Promise { + const token = await getAuthToken(); const res = await fetch(`${API_BASE}${path}`, { - headers: { 'Content-Type': 'application/json', ...options?.headers }, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options?.headers, + }, ...options, }); if (!res.ok) { - const text = await res.text(); - throw new Error(text || `API error: ${res.status} ${res.statusText}`); + let detail = `${res.status} ${res.statusText}`; + try { + const text = await res.text(); + if (text) { + try { + const parsed = JSON.parse(text); + const message = + parsed?.message ?? + parsed?.error ?? + (Array.isArray(parsed) ? parsed.join(', ') : null); + detail = message ? `${detail} - ${message}` : `${detail} - ${text}`; + } catch { + detail = `${detail} - ${text}`; + } + } + } catch { + // ignore parsing errors + } + throw new Error(`API error: ${detail}`); } return res.json(); } +// ---------- Auth ---------- +export interface VerifyCodeResponse { + token: string; + needsRegistration: boolean; + user?: User | null; +} + +export function checkEmail(email: string): Promise<{ isRegistered: boolean }> { + return apiFetch('/auth/check-email', { + method: 'POST', + body: JSON.stringify({ email }), + }); +} + +export function loginWithPassword( + email: string, + password: string, +): Promise<{ token: string; user: User }> { + return apiFetch('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); +} + +export function requestVerificationCode(email: string) { + return apiFetch('/auth/request-code', { + method: 'POST', + body: JSON.stringify({ email }), + }); +} + +export function requestOtp(email: string) { + return apiFetch('/auth/request-otp', { + method: 'POST', + body: JSON.stringify({ email }), + }); +} + +export function verifyCode(email: string, code: string): Promise { + return apiFetch('/auth/verify-code', { + method: 'POST', + body: JSON.stringify({ email, code }), + }); +} + +export function verifyOtp(email: string, code: string): Promise { + return apiFetch('/auth/verify-otp', { + method: 'POST', + body: JSON.stringify({ email, code }), + }); +} + +export function registerProfile(data: { + firstName: string; + lastName: string; + phone: string; + program: string; + password: string; + confirmPassword?: string; +}) { + return apiFetch('/auth/register', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +export function getMe(): Promise { + return apiFetch('/auth/me'); +} + // ---------- Users ---------- export interface User { id: number; email: string; name: string; + firstName?: string | null; + lastName?: string | null; phoneNumber: string; program: string | null; isSystemAdmin: boolean; @@ -136,18 +231,16 @@ export function getUserTickets(userId: number): Promise { export function signupForEvent( eventId: number, - userId: number, selectedTable?: number, ): Promise<{ ticket?: any; error?: string }> { return apiFetch(`/events/${eventId}/signup`, { method: 'POST', - body: JSON.stringify({ userId, selectedTable }), + body: JSON.stringify({ selectedTable }), }); } export function createCheckoutSession( eventId: number, - userId: number, options: { successUrl?: string; cancelUrl?: string; @@ -160,7 +253,6 @@ export function createCheckoutSession( method: 'POST', signal: controller.signal, body: JSON.stringify({ - userId, successUrl: options.successUrl, cancelUrl: options.cancelUrl, selectedTable: options.selectedTable, @@ -179,10 +271,8 @@ export function releaseCheckoutReservation( export function cancelSignup( eventId: number, - userId: number, ): Promise<{ cancelled?: boolean; error?: string }> { return apiFetch(`/events/${eventId}/cancel`, { method: 'POST', - body: JSON.stringify({ userId }), }); } diff --git a/src/frontend/mobile/app/_lib/auth.ts b/src/frontend/mobile/app/_lib/auth.ts new file mode 100644 index 0000000..dc7c2cf --- /dev/null +++ b/src/frontend/mobile/app/_lib/auth.ts @@ -0,0 +1,15 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const TOKEN_KEY = 'auth_token'; + +export async function getAuthToken() { + return AsyncStorage.getItem(TOKEN_KEY); +} + +export async function setAuthToken(token: string | null) { + if (token) { + await AsyncStorage.setItem(TOKEN_KEY, token); + } else { + await AsyncStorage.removeItem(TOKEN_KEY); + } +} diff --git a/src/frontend/mobile/app/event-signup.tsx b/src/frontend/mobile/app/event-signup.tsx index 720d275..080d980 100644 --- a/src/frontend/mobile/app/event-signup.tsx +++ b/src/frontend/mobile/app/event-signup.tsx @@ -18,7 +18,7 @@ import { createCheckoutSession, type EventItem, } from "./_lib/api"; -import { useUser } from "./_context/UserContext"; +import { useAuth } from "./_context/AuthContext"; type NotificationType = "success" | "error" | "info"; @@ -33,7 +33,7 @@ interface NotificationState { export default function EventSignupScreen() { const router = useRouter(); const { eventId } = useLocalSearchParams(); - const { currentUser } = useUser(); + const { user } = useAuth(); const insets = useSafeAreaInsets(); const [event, setEvent] = useState(null); @@ -63,13 +63,13 @@ export default function EventSignupScreen() { useEffect(() => { loadEvent(); - }, [eventId, currentUser?.id]); + }, [eventId, user?.id]); const loadEvent = async () => { try { const data = await getEvent( Number(eventId), - currentUser?.id, + user?.id, ); setEvent(data); if (data.userTicket) { @@ -127,7 +127,7 @@ export default function EventSignupScreen() { }; const handleProceedToPayment = async () => { - if (!currentUser || !event) return; + if (!user || !event) return; if (event.requiresTableSignup && selectedTable === null) { showNotification("error", "Error", "Please select a table"); return; @@ -142,7 +142,7 @@ export default function EventSignupScreen() { const cancelUrlWithEvent = `${cancelUrl}${ cancelUrl.includes("?") ? "&" : "?" }eventId=${encodeURIComponent(event.id)}`; - const result = await createCheckoutSession(event.id, currentUser.id, { + const result = await createCheckoutSession(event.id, { successUrl: successUrlWithEvent, cancelUrl: cancelUrlWithEvent, selectedTable: selectedTable ?? undefined, @@ -188,7 +188,7 @@ export default function EventSignupScreen() { const handleCompleteSignup = async () => { if (!event) return; - if (!currentUser) { + if (!user) { showNotification( "error", "Sign in required", @@ -203,11 +203,7 @@ export default function EventSignupScreen() { setSubmitting(true); try { - const result = await signupForEvent( - event.id, - currentUser.id, - selectedTable ?? undefined - ); + const result = await signupForEvent(event.id, selectedTable ?? undefined); if (result.error) { showNotification( "error", @@ -217,7 +213,7 @@ export default function EventSignupScreen() { return; } // Refetch event so UI shows "You're signed up" even if notification doesn't appear - const updated = await getEvent(event.id, currentUser.id); + const updated = await getEvent(event.id, user.id); setEvent(updated); const seatParts: string[] = []; diff --git a/src/frontend/mobile/package-lock.json b/src/frontend/mobile/package-lock.json index 1daa34b..29e911a 100644 --- a/src/frontend/mobile/package-lock.json +++ b/src/frontend/mobile/package-lock.json @@ -9981,6 +9981,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.76.5.tgz", "integrity": "sha512-op2p2kB+lqMF1D7AdX4+wvaR0OPFbvWYs+VBE7bwsb99Cn9xISrLRLAgFflZedQsa5HvnOGrULhtnmItbIKVVw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native/assets-registry": "0.76.5",