From c328f25d3ebce77257b35b4d6019c4d0ef922490 Mon Sep 17 00:00:00 2001 From: Prerna Prabhu Date: Sun, 8 Feb 2026 17:45:36 -0500 Subject: [PATCH 1/9] draft user auth module --- docs/SRS | 2 +- src/backend/.env.example | 13 + src/backend/package-lock.json | 141 ++++++++++ src/backend/package.json | 4 + src/backend/src/app.module.ts | 3 +- src/backend/src/auth/auth.controller.ts | 47 ++++ src/backend/src/auth/auth.module.ts | 13 + src/backend/src/auth/auth.service.ts | 263 ++++++++++++++++++ src/backend/src/auth/auth.types.ts | 9 + src/backend/src/auth/jwt-auth.guard.ts | 40 +++ src/backend/src/auth/onboarding.guard.ts | 39 +++ src/backend/src/db/schema.ts | 13 + src/backend/src/events/events.controller.ts | 19 +- src/backend/src/users/users.controller.ts | 30 +- src/backend/src/users/users.module.ts | 1 + src/backend/src/users/users.service.ts | 14 + src/frontend/mobile/app/(auth)/_layout.tsx | 5 + src/frontend/mobile/app/(auth)/login.tsx | 62 +++++ src/frontend/mobile/app/(auth)/register.tsx | 181 ++++++++++++ src/frontend/mobile/app/(auth)/verify.tsx | 73 +++++ src/frontend/mobile/app/(tabs)/_layout.tsx | 86 ++---- src/frontend/mobile/app/(tabs)/index.tsx | 36 ++- src/frontend/mobile/app/(tabs)/my-signups.tsx | 22 +- src/frontend/mobile/app/(tabs)/profile.tsx | 42 +-- src/frontend/mobile/app/_layout.tsx | 61 ++-- .../mobile/app/context/AuthContext.tsx | 127 +++++++++ .../mobile/app/context/UserContext.tsx | 73 ----- src/frontend/mobile/app/lib/api.ts | 48 +++- src/frontend/mobile/app/lib/auth.ts | 15 + src/frontend/mobile/package-lock.json | 89 +++--- src/frontend/mobile/package.json | 1 + 31 files changed, 1309 insertions(+), 263 deletions(-) create mode 100644 src/backend/src/auth/auth.controller.ts create mode 100644 src/backend/src/auth/auth.module.ts create mode 100644 src/backend/src/auth/auth.service.ts create mode 100644 src/backend/src/auth/auth.types.ts create mode 100644 src/backend/src/auth/jwt-auth.guard.ts create mode 100644 src/backend/src/auth/onboarding.guard.ts create mode 100644 src/frontend/mobile/app/(auth)/_layout.tsx create mode 100644 src/frontend/mobile/app/(auth)/login.tsx create mode 100644 src/frontend/mobile/app/(auth)/register.tsx create mode 100644 src/frontend/mobile/app/(auth)/verify.tsx create mode 100644 src/frontend/mobile/app/context/AuthContext.tsx delete mode 100644 src/frontend/mobile/app/context/UserContext.tsx create mode 100644 src/frontend/mobile/app/lib/auth.ts diff --git a/docs/SRS b/docs/SRS index a91b09e..f951acf 160000 --- a/docs/SRS +++ b/docs/SRS @@ -1 +1 @@ -Subproject commit a91b09e47b99484dd69edf3415c47f6db6d5f3c6 +Subproject commit f951acf6da0a969be838c8e7bbd58d2ac720570a diff --git a/src/backend/.env.example b/src/backend/.env.example index de41f8a..6318498 100644 --- a/src/backend/.env.example +++ b/src/backend/.env.example @@ -9,6 +9,19 @@ 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 + +# 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 52ffe27..6aec46c 100644 --- a/src/backend/package-lock.json +++ b/src/backend/package-lock.json @@ -16,6 +16,8 @@ "@nestjs/platform-fastify": "^11.1.12", "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" @@ -28,7 +30,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", @@ -3957,6 +3961,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", @@ -3964,6 +3979,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", @@ -3975,6 +3997,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", @@ -5344,6 +5376,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", @@ -6158,6 +6196,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", @@ -8666,6 +8713,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", @@ -8813,6 +8903,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", @@ -8827,6 +8953,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", @@ -9223,6 +9355,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 c9c86a5..d714161 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -34,6 +34,8 @@ "@nestjs/platform-fastify": "^11.1.12", "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" @@ -46,7 +48,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 e3c4bce..ece9069 100644 --- a/src/backend/src/app.module.ts +++ b/src/backend/src/app.module.ts @@ -4,9 +4,10 @@ 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'; @Module({ - imports: [DatabaseModule, UsersModule, EventsModule], + imports: [DatabaseModule, UsersModule, EventsModule, AuthModule], controllers: [AppController], providers: [AppService], }) diff --git a/src/backend/src/auth/auth.controller.ts b/src/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..08359ed --- /dev/null +++ b/src/backend/src/auth/auth.controller.ts @@ -0,0 +1,47 @@ +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('verify-code') + verifyCode(@Body() body: { email: string; code: string }) { + return this.authService.verifyCode(body.email, body.code); + } + + @Post('register') + @UseGuards(OnboardingGuard) + register( + @Body() body: { name: string; phone: string; program: 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..ca01142 --- /dev/null +++ b/src/backend/src/auth/auth.service.ts @@ -0,0 +1,263 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { createHash, randomInt } from 'crypto'; +import nodemailer from 'nodemailer'; +import type { Transporter } from 'nodemailer'; +import jwt 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 = '60m'; + +@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() { + return process.env.JWT_EXPIRES_IN || DEFAULT_JWT_EXPIRES_IN; + } + + private isProfileComplete(user: User) { + return Boolean( + user.name?.trim() && + user.phoneNumber?.trim() && + user.program?.trim(), + ); + } + + private issueJwt(payload: JwtPayload | OnboardingJwtPayload) { + return jwt.sign(payload, this.jwtSecret, { + expiresIn: this.getJwtExpiry(), + }); + } + + async requestVerificationCode(rawEmail: string) { + // M8: login(credentials) -> begin passwordless verification sequence. + if (!rawEmail) { + throw new BadRequestException('Email is required.'); + } + const email = this.normalizeEmail(rawEmail); + this.assertMcMasterEmail(email); + + 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) { + // M8: login(credentials) -> return AuthToken after verification succeeds. + if (!rawEmail || !code) { + throw new BadRequestException('Email and code are required.'); + } + const email = this.normalizeEmail(rawEmail); + this.assertMcMasterEmail(email); + + 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 user = await this.usersService.findByEmailWithRoles(email); + + if (!user || !this.isProfileComplete(user)) { + const token = this.issueJwt({ email, onboarding: true }); + return { + token, + needsRegistration: true, + user: user ?? null, + }; + } + + const token = this.issueJwt({ sub: user.id, email: user.email }); + return { + token, + needsRegistration: false, + user, + }; + } + + async registerUser( + email: string, + data: { name: string; phone: string; program: string }, + ) { + // One-time onboarding for newly verified users. + const name = data.name.trim(); + const program = data.program.trim(); + const phone = data.phone.trim(); + + if (!/^[A-Za-z-]+$/.test(name)) { + 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.'); + } + + const existing = await this.usersService.findByEmail(email); + if (existing && this.isProfileComplete(existing)) { + throw new BadRequestException('Profile is already complete.'); + } + + let user: User; + if (existing) { + user = await this.usersService.update(existing.id, { + name, + phoneNumber: normalizedPhone, + program, + }); + } else { + user = await this.usersService.create({ + email, + name, + 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 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..eea4f3b --- /dev/null +++ b/src/backend/src/auth/jwt-auth.guard.ts @@ -0,0 +1,40 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import jwt from 'jsonwebtoken'; +import type { JwtPayload } from './auth.types'; + +@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 payload = jwt.verify( + token, + process.env.JWT_SECRET || 'dev-secret', + ) as JwtPayload; + + if (!payload?.sub || !payload.email) { + throw new UnauthorizedException('Invalid token payload.'); + } + + request.user = payload; + 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..9d42913 --- /dev/null +++ b/src/backend/src/auth/onboarding.guard.ts @@ -0,0 +1,39 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import jwt 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 payload = jwt.verify( + token, + process.env.JWT_SECRET || 'dev-secret', + ) as OnboardingJwtPayload; + + if (!payload?.onboarding || !payload.email) { + throw new UnauthorizedException('Invalid onboarding token.'); + } + + request.onboardingEmail = payload.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 154f848..84ab515 100644 --- a/src/backend/src/db/schema.ts +++ b/src/backend/src/db/schema.ts @@ -25,6 +25,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 1818d1e..4d1d072 100644 --- a/src/backend/src/events/events.controller.ts +++ b/src/backend/src/events/events.controller.ts @@ -6,12 +6,16 @@ import { Delete, 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) {} @@ -41,17 +45,20 @@ export class EventsController { } @Post(':id/signup') - signup(@Param('id') id: string, @Body('userId') userId: number) { - return this.eventsService.signup(+id, userId); + signup(@Param('id') id: string, @Req() req: any) { + return this.eventsService.signup(+id, req.user.sub); } @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 e3af2b1..7022adb 100644 --- a/src/backend/src/users/users.controller.ts +++ b/src/backend/src/users/users.controller.ts @@ -1,8 +1,21 @@ -import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + 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) {} @@ -12,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); } @@ -22,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 9ec4aab..de0a4f1 100644 --- a/src/backend/src/users/users.service.ts +++ b/src/backend/src/users/users.service.ts @@ -40,6 +40,20 @@ export class UsersService { }; } + 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) { const result = await this.dbService.db.insert(users).values(user).returning(); return result[0]; diff --git a/src/frontend/mobile/app/(auth)/_layout.tsx b/src/frontend/mobile/app/(auth)/_layout.tsx new file mode 100644 index 0000000..5c5737d --- /dev/null +++ b/src/frontend/mobile/app/(auth)/_layout.tsx @@ -0,0 +1,5 @@ +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..ff0a758 --- /dev/null +++ b/src/frontend/mobile/app/(auth)/login.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { View, Text, TextInput, Pressable, Alert } from 'react-native'; +import { useRouter } from 'expo-router'; +import { useAuth } from '../context/AuthContext'; + +export default function LoginScreen() { + const router = useRouter(); + const { requestCode } = useAuth(); + const [email, setEmail] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async () => { + const normalized = email.trim().toLowerCase(); + if (!normalized.endsWith('@mcmaster.ca')) { + Alert.alert('Invalid email', 'Please use your @mcmaster.ca email.'); + return; + } + + setSubmitting(true); + try { + await requestCode(normalized); + router.push({ pathname: '/(auth)/verify', params: { email: normalized } }); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to send verification code.'); + } finally { + setSubmitting(false); + } + }; + + return ( + + + Welcome back + + Sign in with your McMaster email to continue. + + + + + Email + + + + + {submitting ? 'Sending code...' : 'Send verification code'} + + + + + ); +} diff --git a/src/frontend/mobile/app/(auth)/register.tsx b/src/frontend/mobile/app/(auth)/register.tsx new file mode 100644 index 0000000..ce36654 --- /dev/null +++ b/src/frontend/mobile/app/(auth)/register.tsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from 'react'; +import { + View, + Text, + TextInput, + Pressable, + Alert, + Modal, + FlatList, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { useAuth } from '../context/AuthContext'; + +const PROGRAM_OPTIONS = [ + 'Computer Science', + 'Software Engineering', + 'Electrical Engineering', + 'Mechanical Engineering', + 'Business', + 'Health Sciences', + 'Humanities', + 'Other', +]; + +export default function RegisterScreen() { + const router = useRouter(); + const { status, pendingEmail, completeRegistration } = useAuth(); + const [name, setName] = useState(''); + const [phone, setPhone] = useState(''); + const [program, setProgram] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [showPrograms, setShowPrograms] = useState(false); + + useEffect(() => { + if (status === 'authenticated') { + router.replace('/(tabs)'); + } + if (status === 'unauthenticated') { + router.replace('/(auth)/login'); + } + }, [status, router]); + + const handleSubmit = async () => { + if (!/^[A-Za-z-]+$/.test(name.trim())) { + Alert.alert('Invalid name', 'Use letters and hyphens only.'); + return; + } + const normalizedPhone = phone.trim().replace(/[\s()-]/g, ''); + if (!/^\+?\d{10,15}$/.test(normalizedPhone)) { + Alert.alert( + 'Invalid phone number', + 'Use 10 digits or include a valid country code.', + ); + return; + } + if (!program.trim()) { + Alert.alert('Missing program', 'Please select your program.'); + return; + } + + setSubmitting(true); + try { + await completeRegistration({ + name: name.trim(), + phone: normalizedPhone, + program: program.trim(), + }); + router.replace('/(tabs)'); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to complete registration.'); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + Complete your profile + + + Tell us a bit more to finish setting up your account. + + + + + Email + + + + Name + + + + + Phone number + + + + + Program + + setShowPrograms(true)} + className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50" + > + + {program || 'Select your program'} + + + + + + {submitting ? 'Saving...' : '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)/verify.tsx b/src/frontend/mobile/app/(auth)/verify.tsx new file mode 100644 index 0000000..dadd63a --- /dev/null +++ b/src/frontend/mobile/app/(auth)/verify.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import { View, Text, TextInput, Pressable, Alert } from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useAuth } from '../context/AuthContext'; + +export default function VerifyScreen() { + const router = useRouter(); + const { email } = useLocalSearchParams<{ email?: string }>(); + const { confirmCode } = useAuth(); + const [code, setCode] = useState(''); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!email) { + router.replace('/(auth)/login'); + } + }, [email, router]); + + const handleSubmit = async () => { + if (!email) return; + if (code.trim().length !== 6) { + Alert.alert('Invalid code', 'Enter the 6-digit verification code.'); + return; + } + + setSubmitting(true); + try { + const result = await confirmCode(String(email), code.trim()); + if (result === 'needsRegistration') { + router.replace('/(auth)/register'); + } else { + router.replace('/(tabs)'); + } + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to verify code.'); + } finally { + setSubmitting(false); + } + }; + + return ( + + + Check your email + + Enter the 6-digit code sent to {email}. + + + + + Verification code + + + + + {submitting ? 'Verifying...' : 'Verify and continue'} + + + + + ); +} diff --git a/src/frontend/mobile/app/(tabs)/_layout.tsx b/src/frontend/mobile/app/(tabs)/_layout.tsx index 5f84054..99c7282 100644 --- a/src/frontend/mobile/app/(tabs)/_layout.tsx +++ b/src/frontend/mobile/app/(tabs)/_layout.tsx @@ -1,82 +1,28 @@ import { Tabs } from "expo-router"; -import { View, Text, Pressable, Modal, FlatList, Platform } from "react-native"; -import { useState } from "react"; +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(); 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={logout} + 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 7727a25..26280c5 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 } from "react"; import { View, Text, @@ -20,7 +20,7 @@ import { getUserTickets, type EventItem, } from "../lib/api"; -import { useUser } from "../context/UserContext"; +import { useAuth } from "../context/AuthContext"; function EventCard({ event, @@ -150,7 +150,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); @@ -176,9 +176,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); @@ -190,7 +190,7 @@ export default function EventsScreen() { useCallback(() => { loadEvents(); loadUserTickets(); - }, [currentUser]) + }, [user]) ); const onRefresh = async () => { @@ -212,14 +212,32 @@ export default function EventsScreen() { }, [events, search]); const handleSignUp = async (eventId: number) => { - // TODO: implement sign up flow + try { + const result = await signupForEvent(eventId); + if (result.error) { + Alert.alert("Unable to sign up", result.error); + return; + } + await loadUserTickets(); + } catch (err: any) { + Alert.alert("Error", err.message || "Failed to sign up."); + } }; const handleCancel = async (eventId: number) => { - // TODO: implement cancel flow + try { + const result = await cancelSignup(eventId); + if (result.error) { + Alert.alert("Unable to cancel", result.error); + return; + } + await loadUserTickets(); + } catch (err: any) { + Alert.alert("Error", err.message || "Failed to cancel sign-up."); + } }; - if (userLoading || loading) { + if (status === "loading" || loading) { return ( diff --git a/src/frontend/mobile/app/(tabs)/my-signups.tsx b/src/frontend/mobile/app/(tabs)/my-signups.tsx index 26748e3..0c0c91a 100644 --- a/src/frontend/mobile/app/(tabs)/my-signups.tsx +++ b/src/frontend/mobile/app/(tabs)/my-signups.tsx @@ -10,18 +10,18 @@ import { RefreshControl, } from "react-native"; import { getUserTickets, cancelSignup, type Ticket } from "../lib/api"; -import { useUser } from "../context/UserContext"; +import { useAuth } from "../context/AuthContext"; export default function MySignUpsScreen() { - const { currentUser, loading: userLoading } = useUser(); + const { user, status } = useAuth(); const [tickets, setTickets] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); 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); @@ -31,11 +31,11 @@ export default function MySignUpsScreen() { }; useEffect(() => { - if (currentUser) { + if (user) { setLoading(true); loadTickets(); } - }, [currentUser]); + }, [user]); const onRefresh = async () => { setRefreshing(true); @@ -44,7 +44,7 @@ export default function MySignUpsScreen() { }; const handleCancel = async (eventId: number) => { - if (!currentUser) return; + if (!user) return; Alert.alert( "Cancel Sign-Up", "Are you sure you want to cancel this sign-up?", @@ -55,7 +55,7 @@ export default function MySignUpsScreen() { style: "destructive", onPress: async () => { try { - const result = await cancelSignup(eventId, currentUser.id); + const result = await cancelSignup(eventId); if (result.error) { Alert.alert("Error", result.error); return; @@ -84,7 +84,7 @@ export default function MySignUpsScreen() { return `$${price.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }; - if (userLoading || loading) { + if (status === "loading" || loading) { return ( @@ -92,11 +92,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 b2f04fc..a02b949 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) => ( { 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/_layout.tsx b/src/frontend/mobile/app/_layout.tsx index 2f585a2..51197a4 100644 --- a/src/frontend/mobile/app/_layout.tsx +++ b/src/frontend/mobile/app/_layout.tsx @@ -1,30 +1,51 @@ import "../global.css"; import { Stack } 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/context/AuthContext.tsx b/src/frontend/mobile/app/context/AuthContext.tsx new file mode 100644 index 0000000..b4447bf --- /dev/null +++ b/src/frontend/mobile/app/context/AuthContext.tsx @@ -0,0 +1,127 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { + getMe, + requestVerificationCode, + verifyCode, + 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; + requestCode: (email: string) => Promise; + confirmCode: (email: string, code: string) => Promise<'needsRegistration' | 'authenticated'>; + completeRegistration: (data: { name: string; phone: string; program: string }) => Promise; + logout: () => Promise; + refreshUser: () => Promise; +} + +const AuthContext = createContext({ + status: 'loading', + user: null, + pendingEmail: null, + isAdmin: false, + requestCode: async () => {}, + confirmCode: async () => 'needsRegistration', + completeRegistration: async () => {}, + logout: async () => {}, + refreshUser: async () => {}, +}); + +function isProfileComplete(user: User) { + return Boolean(user.name?.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 requestCode = async (email: string) => { + await requestVerificationCode(email); + }; + + const confirmCode = async (email: string, code: string) => { + const result = await verifyCode(email, code); + await setAuthToken(result.token); + setPendingEmail(email); + if (result.needsRegistration) { + setStatus('needsRegistration'); + setUser(result.user ?? null); + return 'needsRegistration'; + } + setUser(result.user); + setStatus('authenticated'); + return 'authenticated'; + }; + + const completeRegistration = async (data: { name: string; phone: string; program: 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 938a77d..0000000 --- a/src/frontend/mobile/app/context/UserContext.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { - createContext, - useContext, - useState, - useEffect, - ReactNode, -} from 'react'; -import { getUsers, getUser, type User } from '../lib/api'; - -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); - if (users[0]) { - const user = await getUser(users[0].id); - setCurrentUser(user); - } - } 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); - } 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/lib/api.ts b/src/frontend/mobile/app/lib/api.ts index d01c455..58b283f 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'; // Android emulator uses 10.0.2.2, iOS simulator/web uses localhost const getBaseUrl = () => { @@ -9,8 +10,13 @@ const getBaseUrl = () => { 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) { @@ -19,6 +25,42 @@ async function apiFetch(path: string, options?: RequestInit): Promise { return res.json(); } +// ---------- Auth ---------- +export interface VerifyCodeResponse { + token: string; + needsRegistration: boolean; + user: User | null; +} + +export function requestVerificationCode(email: string) { + return apiFetch('/auth/request-code', { + 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 registerProfile(data: { + name: string; + phone: string; + program: 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; @@ -124,20 +166,16 @@ export function getUserTickets(userId: number): Promise { export function signupForEvent( eventId: number, - userId: number, ): Promise<{ ticket?: any; error?: string }> { return apiFetch(`/events/${eventId}/signup`, { method: 'POST', - body: JSON.stringify({ userId }), }); } 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/package-lock.json b/src/frontend/mobile/package-lock.json index d2023c3..1daa34b 100644 --- a/src/frontend/mobile/package-lock.json +++ b/src/frontend/mobile/package-lock.json @@ -8,6 +8,7 @@ "name": "mobile", "version": "1.0.0", "dependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", "expo": "~52.0.0", "expo-asset": "~10.0.0", "expo-constants": "~17.0.0", @@ -82,6 +83,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -485,7 +487,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" @@ -502,7 +503,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -518,7 +518,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -534,7 +533,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", @@ -552,7 +550,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/traverse": "^7.28.6" @@ -653,7 +650,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" }, @@ -774,7 +770,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -961,7 +956,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -1027,7 +1021,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1074,7 +1067,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" @@ -1143,7 +1135,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" @@ -1160,7 +1151,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1176,7 +1166,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz", "integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" @@ -1193,7 +1182,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1209,7 +1197,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-transform-destructuring": "^7.28.5" @@ -1226,7 +1213,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -1306,7 +1292,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -1352,7 +1337,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1368,7 +1352,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1401,7 +1384,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1", @@ -1420,7 +1402,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1453,7 +1434,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1518,7 +1498,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1" @@ -1614,7 +1593,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1740,7 +1718,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" @@ -1757,7 +1734,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1839,7 +1815,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1855,7 +1830,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1890,7 +1864,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1906,7 +1879,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" @@ -1939,7 +1911,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" @@ -2058,7 +2029,6 @@ "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", @@ -2664,6 +2634,7 @@ "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-4.0.1.tgz", "integrity": "sha512-CRpbLvdJ1T42S+lrYa1iZp1KfDeBp4oeZOK3hdpiS5n0vR0nhD6sC1gGF0sTboCTp64tLteikz5Y3j53dvgOIw==", "license": "MIT", + "peer": true, "peerDependencies": { "react-native": "*" } @@ -3296,6 +3267,18 @@ "react": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.76.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.5.tgz", @@ -3905,6 +3888,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz", "integrity": "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.14.0", "escape-string-regexp": "^4.0.0", @@ -4089,6 +4073,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4203,6 +4188,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4754,6 +4740,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5852,7 +5839,6 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5977,6 +5963,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-52.0.48.tgz", "integrity": "sha512-/HR/vuo57KGEWlvF3GWaquwEAjXuA5hrOCsaLcZ3pMSA8mQ27qKd1jva4GWzpxXYedlzs/7LLP1XpZo6hXTsog==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.22.27", @@ -6209,6 +6196,7 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.8.tgz", "integrity": "sha512-XfWRyQAf1yUNgWZ1TnE8pFBMqGmFP5Gb+SFSgszxDdOoheB/NI5D4p7q86kI2fvGyfTrxAe+D+74nZkfsGvUlg==", "license": "MIT", + "peer": true, "dependencies": { "@expo/config": "~10.0.11", "@expo/env": "~0.4.2" @@ -6223,6 +6211,7 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz", "integrity": "sha512-3KptlJtcYDPWohk0MfJU75MJFh2ybavbtcSd84zEPfw9s1q3hjimw3sXnH03ZxP54kiEWldvKmmnGcVffBDB1g==", "license": "MIT", + "peer": true, "dependencies": { "expo-constants": "~17.0.5", "invariant": "^2.2.4" @@ -7313,6 +7302,15 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -8219,6 +8217,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -9538,6 +9548,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -10114,7 +10125,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -10127,6 +10137,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.12.0.tgz", "integrity": "sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -10137,6 +10148,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.4.0.tgz", "integrity": "sha512-c7zc7Zwjty6/pGyuuvh9gK3YBYqHPOxrhXfG1lF4gHlojQSmIx2piNbNaV+Uykj+RDTmFXK0e/hA+fucw/Qozg==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -10183,7 +10195,6 @@ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.2.tgz", "integrity": "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog==", "license": "MIT", - "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", @@ -10208,7 +10219,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -10225,7 +10235,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", @@ -10246,7 +10255,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -10262,7 +10270,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" @@ -10279,7 +10286,6 @@ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", @@ -10299,7 +10305,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -10766,7 +10771,6 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -11407,6 +11411,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", diff --git a/src/frontend/mobile/package.json b/src/frontend/mobile/package.json index 1bf7490..959e495 100644 --- a/src/frontend/mobile/package.json +++ b/src/frontend/mobile/package.json @@ -9,6 +9,7 @@ "web": "expo start --web" }, "dependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", "expo": "~52.0.0", "expo-asset": "~10.0.0", "expo-constants": "~17.0.0", From d5658cc8f8fdd1e4207069eb540400a4a8be2d31 Mon Sep 17 00:00:00 2001 From: Prerna Prabhu Date: Sun, 8 Feb 2026 23:24:33 -0500 Subject: [PATCH 2/9] edits for text boxes --- src/backend/src/auth/auth.service.ts | 25 ++++++++++++------- src/backend/src/auth/jwt-auth.guard.ts | 16 +++++++----- src/backend/src/auth/onboarding.guard.ts | 18 ++++++++++---- src/frontend/mobile/app/(auth)/login.tsx | 1 + src/frontend/mobile/app/(auth)/register.tsx | 27 ++++++++++++++++----- src/frontend/mobile/app/(auth)/verify.tsx | 1 + src/frontend/mobile/app/(tabs)/_layout.tsx | 8 ++++-- src/frontend/mobile/package-lock.json | 1 + 8 files changed, 69 insertions(+), 28 deletions(-) diff --git a/src/backend/src/auth/auth.service.ts b/src/backend/src/auth/auth.service.ts index ca01142..71c1fc6 100644 --- a/src/backend/src/auth/auth.service.ts +++ b/src/backend/src/auth/auth.service.ts @@ -4,9 +4,10 @@ import { UnauthorizedException, } from '@nestjs/common'; import { createHash, randomInt } from 'crypto'; -import nodemailer from 'nodemailer'; -import type { Transporter } from 'nodemailer'; -import jwt from 'jsonwebtoken'; +// 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'; @@ -16,7 +17,7 @@ import type { JwtPayload, OnboardingJwtPayload } from './auth.types'; const EMAIL_DOMAIN = 'mcmaster.ca'; const DEFAULT_CODE_EXPIRY_MIN = 10; -const DEFAULT_JWT_EXPIRES_IN = '60m'; +const DEFAULT_JWT_EXPIRES_IN_SECONDS = 60 * 60; @Injectable() export class AuthService { @@ -98,8 +99,15 @@ export class AuthService { return Number.isFinite(parsed) ? parsed : DEFAULT_CODE_EXPIRY_MIN; } - private getJwtExpiry() { - return process.env.JWT_EXPIRES_IN || DEFAULT_JWT_EXPIRES_IN; + 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 isProfileComplete(user: User) { @@ -111,9 +119,8 @@ export class AuthService { } private issueJwt(payload: JwtPayload | OnboardingJwtPayload) { - return jwt.sign(payload, this.jwtSecret, { - expiresIn: this.getJwtExpiry(), - }); + const options: SignOptions = { expiresIn: this.getJwtExpiry() }; + return sign(payload, this.jwtSecret, options); } async requestVerificationCode(rawEmail: string) { diff --git a/src/backend/src/auth/jwt-auth.guard.ts b/src/backend/src/auth/jwt-auth.guard.ts index eea4f3b..7b109b0 100644 --- a/src/backend/src/auth/jwt-auth.guard.ts +++ b/src/backend/src/auth/jwt-auth.guard.ts @@ -4,8 +4,7 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; -import jwt from 'jsonwebtoken'; -import type { JwtPayload } from './auth.types'; +import { verify } from 'jsonwebtoken'; @Injectable() export class JwtAuthGuard implements CanActivate { @@ -22,16 +21,21 @@ export class JwtAuthGuard implements CanActivate { } try { - const payload = jwt.verify( + const decoded = verify( token, process.env.JWT_SECRET || 'dev-secret', - ) as JwtPayload; + ); - if (!payload?.sub || !payload.email) { + if (typeof decoded !== 'object' || decoded === null) { throw new UnauthorizedException('Invalid token payload.'); } - request.user = 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 index 9d42913..4eb5b15 100644 --- a/src/backend/src/auth/onboarding.guard.ts +++ b/src/backend/src/auth/onboarding.guard.ts @@ -4,7 +4,7 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; -import jwt from 'jsonwebtoken'; +import { verify } from 'jsonwebtoken'; import type { OnboardingJwtPayload } from './auth.types'; @Injectable() @@ -21,16 +21,24 @@ export class OnboardingGuard implements CanActivate { } try { - const payload = jwt.verify( + const decoded = verify( token, process.env.JWT_SECRET || 'dev-secret', - ) as OnboardingJwtPayload; + ); - if (!payload?.onboarding || !payload.email) { + if (typeof decoded !== 'object' || decoded === null) { throw new UnauthorizedException('Invalid onboarding token.'); } - request.onboardingEmail = payload.email; + 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/frontend/mobile/app/(auth)/login.tsx b/src/frontend/mobile/app/(auth)/login.tsx index ff0a758..0e73072 100644 --- a/src/frontend/mobile/app/(auth)/login.tsx +++ b/src/frontend/mobile/app/(auth)/login.tsx @@ -44,6 +44,7 @@ export default function LoginScreen() { autoCapitalize="none" keyboardType="email-address" placeholder="name@mcmaster.ca" + placeholderTextColor="#C7CBD1" className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50 text-sm" /> diff --git a/src/frontend/mobile/app/(auth)/register.tsx b/src/frontend/mobile/app/(auth)/register.tsx index ce36654..e59389a 100644 --- a/src/frontend/mobile/app/(auth)/register.tsx +++ b/src/frontend/mobile/app/(auth)/register.tsx @@ -22,6 +22,13 @@ const PROGRAM_OPTIONS = [ 'Other', ]; +const formatPhoneInput = (value: string) => { + const digits = value.replace(/\D/g, ''); + if (digits.length <= 3) return digits; + if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; +}; + export default function RegisterScreen() { const router = useRouter(); const { status, pendingEmail, completeRegistration } = useAuth(); @@ -31,6 +38,12 @@ export default function RegisterScreen() { const [submitting, setSubmitting] = useState(false); const [showPrograms, setShowPrograms] = useState(false); + const formattedPhone = formatPhoneInput(phone); + const isNameValid = /^[A-Za-z-]+$/.test(name.trim()); + const phoneDigits = phone.replace(/\D/g, ''); + const isPhoneValid = /^\+?\d{10,15}$/.test(phoneDigits.length >= 10 ? phoneDigits : ''); + const isProgramValid = program.trim().length > 0; + useEffect(() => { if (status === 'authenticated') { router.replace('/(tabs)'); @@ -45,7 +58,7 @@ export default function RegisterScreen() { Alert.alert('Invalid name', 'Use letters and hyphens only.'); return; } - const normalizedPhone = phone.trim().replace(/[\s()-]/g, ''); + const normalizedPhone = phoneDigits; if (!/^\+?\d{10,15}$/.test(normalizedPhone)) { Alert.alert( 'Invalid phone number', @@ -93,28 +106,30 @@ export default function RegisterScreen() { /> - Name + Name {isNameValid && name.trim() ? '✅' : ''} - Phone number + Phone number {isPhoneValid && phoneDigits ? '✅' : ''} setPhone(value)} placeholder="(905) 555-1234" keyboardType="phone-pad" + placeholderTextColor="#C7CBD1" className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50 text-sm" /> - Program + Program {isProgramValid ? '✅' : ''} setShowPrograms(true)} diff --git a/src/frontend/mobile/app/(auth)/verify.tsx b/src/frontend/mobile/app/(auth)/verify.tsx index dadd63a..21fae03 100644 --- a/src/frontend/mobile/app/(auth)/verify.tsx +++ b/src/frontend/mobile/app/(auth)/verify.tsx @@ -55,6 +55,7 @@ export default function VerifyScreen() { keyboardType="number-pad" placeholder="123456" maxLength={6} + placeholderTextColor="#C7CBD1" className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50 text-sm tracking-widest text-center" /> diff --git a/src/frontend/mobile/app/(tabs)/_layout.tsx b/src/frontend/mobile/app/(tabs)/_layout.tsx index 99c7282..2b52226 100644 --- a/src/frontend/mobile/app/(tabs)/_layout.tsx +++ b/src/frontend/mobile/app/(tabs)/_layout.tsx @@ -1,10 +1,11 @@ -import { Tabs } from "expo-router"; +import { Tabs, useRouter } from "expo-router"; import { View, Text, Pressable } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAuth } from "../context/AuthContext"; function HeaderRight() { const { user, logout } = useAuth(); + const router = useRouter(); return ( @@ -17,7 +18,10 @@ function HeaderRight() { {user?.name || "Account"} { + await logout(); + router.replace("/(auth)/login"); + }} className="ml-3 px-3 py-1.5 rounded-full bg-gray-100" > Logout 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", From 6f2fe2f38b74e451233aedce5fc4b37ccf5d2c67 Mon Sep 17 00:00:00 2001 From: Prerna Prabhu Date: Sun, 8 Feb 2026 23:25:10 -0500 Subject: [PATCH 3/9] change paths --- src/frontend/mobile/app/(auth)/register.tsx | 12 +++++++++--- src/frontend/mobile/app/(tabs)/index.tsx | 2 +- src/frontend/mobile/app/(tabs)/profile.tsx | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/frontend/mobile/app/(auth)/register.tsx b/src/frontend/mobile/app/(auth)/register.tsx index e59389a..9448c18 100644 --- a/src/frontend/mobile/app/(auth)/register.tsx +++ b/src/frontend/mobile/app/(auth)/register.tsx @@ -26,7 +26,13 @@ const formatPhoneInput = (value: string) => { const digits = value.replace(/\D/g, ''); if (digits.length <= 3) return digits; if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; - return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; + if (digits.length <= 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; + } + return `+${digits.slice(0, 1)} (${digits.slice(1, 4)}) ${digits.slice( + 4, + 7, + )}-${digits.slice(7, 11)}`; }; export default function RegisterScreen() { @@ -41,7 +47,7 @@ export default function RegisterScreen() { const formattedPhone = formatPhoneInput(phone); const isNameValid = /^[A-Za-z-]+$/.test(name.trim()); const phoneDigits = phone.replace(/\D/g, ''); - const isPhoneValid = /^\+?\d{10,15}$/.test(phoneDigits.length >= 10 ? phoneDigits : ''); + const isPhoneValid = /^\d{10,15}$/.test(phoneDigits); const isProgramValid = program.trim().length > 0; useEffect(() => { @@ -59,7 +65,7 @@ export default function RegisterScreen() { return; } const normalizedPhone = phoneDigits; - if (!/^\+?\d{10,15}$/.test(normalizedPhone)) { + if (!/^\d{10,15}$/.test(normalizedPhone)) { Alert.alert( 'Invalid phone number', 'Use 10 digits or include a valid country code.', diff --git a/src/frontend/mobile/app/(tabs)/index.tsx b/src/frontend/mobile/app/(tabs)/index.tsx index 26280c5..f401d57 100644 --- a/src/frontend/mobile/app/(tabs)/index.tsx +++ b/src/frontend/mobile/app/(tabs)/index.tsx @@ -283,7 +283,7 @@ export default function EventsScreen() { 🔍 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" From afa2a496943d7c5c6cc17b4a7e725616573eb37a Mon Sep 17 00:00:00 2001 From: Prerna Prabhu Date: Tue, 10 Feb 2026 10:33:15 -0500 Subject: [PATCH 4/9] Restore submodule to main --- docs/SRS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SRS b/docs/SRS index f951acf..a91b09e 160000 --- a/docs/SRS +++ b/docs/SRS @@ -1 +1 @@ -Subproject commit f951acf6da0a969be838c8e7bbd58d2ac720570a +Subproject commit a91b09e47b99484dd69edf3415c47f6db6d5f3c6 From 19f22c31865e2635966aa3df2672695ffb8d5662 Mon Sep 17 00:00:00 2001 From: Prerna Prabhu Date: Wed, 11 Feb 2026 18:19:51 -0500 Subject: [PATCH 5/9] make login default, dont show events unless logged in, add error messages for usability --- src/frontend/mobile/app/(auth)/_layout.tsx | 7 +++- src/frontend/mobile/app/(auth)/login.tsx | 21 +++++++++-- src/frontend/mobile/app/_layout.tsx | 42 ++++++++++++---------- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/frontend/mobile/app/(auth)/_layout.tsx b/src/frontend/mobile/app/(auth)/_layout.tsx index 5c5737d..83dbe7b 100644 --- a/src/frontend/mobile/app/(auth)/_layout.tsx +++ b/src/frontend/mobile/app/(auth)/_layout.tsx @@ -1,5 +1,10 @@ import { Stack } from 'expo-router'; export default function AuthLayout() { - return ; + return ( + + ); } diff --git a/src/frontend/mobile/app/(auth)/login.tsx b/src/frontend/mobile/app/(auth)/login.tsx index 0e73072..a10fbeb 100644 --- a/src/frontend/mobile/app/(auth)/login.tsx +++ b/src/frontend/mobile/app/(auth)/login.tsx @@ -8,11 +8,14 @@ export default function LoginScreen() { const { requestCode } = useAuth(); const [email, setEmail] = useState(''); const [submitting, setSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); const handleSubmit = async () => { const normalized = email.trim().toLowerCase(); if (!normalized.endsWith('@mcmaster.ca')) { - Alert.alert('Invalid email', 'Please use your @mcmaster.ca email.'); + const message = 'Please use your @mcmaster.ca email.'; + setErrorMessage(message); + Alert.alert('Invalid email', message); return; } @@ -20,8 +23,11 @@ export default function LoginScreen() { try { await requestCode(normalized); router.push({ pathname: '/(auth)/verify', params: { email: normalized } }); + setErrorMessage(''); } catch (error: any) { - Alert.alert('Error', error.message || 'Failed to send verification code.'); + const message = error.message || 'Failed to send verification code.'; + setErrorMessage(message); + Alert.alert('Error', message); } finally { setSubmitting(false); } @@ -40,13 +46,22 @@ export default function LoginScreen() { Email { + setEmail(value); + if (errorMessage) setErrorMessage(''); + }} autoCapitalize="none" keyboardType="email-address" placeholder="name@mcmaster.ca" placeholderTextColor="#C7CBD1" + returnKeyType="send" + blurOnSubmit + onSubmitEditing={handleSubmit} className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50 text-sm" /> + {!!errorMessage && ( + {errorMessage} + )} - {status === "authenticated" ? ( + <> + + + + + + {status === "authenticated" ? ( + ) : ( - + )} - - - + ); } From 56aa59c71f9d96279ab2c3f009652ff44ebe3915 Mon Sep 17 00:00:00 2001 From: Prerna Prabhu Date: Wed, 11 Feb 2026 18:30:42 -0500 Subject: [PATCH 6/9] fix routes --- src/frontend/mobile/app/_layout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/frontend/mobile/app/_layout.tsx b/src/frontend/mobile/app/_layout.tsx index 0f17de4..133c0d6 100644 --- a/src/frontend/mobile/app/_layout.tsx +++ b/src/frontend/mobile/app/_layout.tsx @@ -37,6 +37,8 @@ function RootNavigator() { {status === "authenticated" ? ( + ) : status === "needsRegistration" ? ( + ) : ( )} From ad2b3cef0e270bb90a8486b16898aa6e26048a79 Mon Sep 17 00:00:00 2001 From: Prerna Prabhu Date: Wed, 11 Feb 2026 18:35:59 -0500 Subject: [PATCH 7/9] add error message for incorrect verification code --- src/frontend/mobile/app/(auth)/verify.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/frontend/mobile/app/(auth)/verify.tsx b/src/frontend/mobile/app/(auth)/verify.tsx index 21fae03..a2d430b 100644 --- a/src/frontend/mobile/app/(auth)/verify.tsx +++ b/src/frontend/mobile/app/(auth)/verify.tsx @@ -9,6 +9,7 @@ export default function VerifyScreen() { const { confirmCode } = useAuth(); const [code, setCode] = useState(''); const [submitting, setSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); useEffect(() => { if (!email) { @@ -19,20 +20,25 @@ export default function VerifyScreen() { const handleSubmit = async () => { if (!email) return; if (code.trim().length !== 6) { - Alert.alert('Invalid code', 'Enter the 6-digit verification code.'); + const message = 'Enter the 6-digit verification code.'; + setErrorMessage(message); + Alert.alert('Invalid code', message); return; } setSubmitting(true); try { const result = await confirmCode(String(email), code.trim()); + setErrorMessage(''); if (result === 'needsRegistration') { router.replace('/(auth)/register'); } else { router.replace('/(tabs)'); } } catch (error: any) { - Alert.alert('Error', error.message || 'Failed to verify code.'); + const message = error.message || 'Failed to verify code.'; + setErrorMessage(message); + Alert.alert('Error', message); } finally { setSubmitting(false); } @@ -51,13 +57,22 @@ export default function VerifyScreen() { Verification code { + setCode(value); + if (errorMessage) setErrorMessage(''); + }} keyboardType="number-pad" placeholder="123456" maxLength={6} placeholderTextColor="#C7CBD1" + returnKeyType="send" + blurOnSubmit + onSubmitEditing={handleSubmit} className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50 text-sm tracking-widest text-center" /> + {!!errorMessage && ( + {errorMessage} + )} Date: Thu, 12 Feb 2026 00:22:13 -0500 Subject: [PATCH 8/9] Change input for firstname lastname, change schemas, and make changes to sql --- src/backend/src/auth/auth.controller.ts | 3 +- src/backend/src/auth/auth.service.ts | 34 ++++++++++++---- src/backend/src/db/schema.ts | 2 + src/frontend/mobile/app/(auth)/register.tsx | 40 ++++++++++++++----- .../mobile/app/context/AuthContext.tsx | 14 ++++++- src/frontend/mobile/app/lib/api.ts | 25 +++++++++++- 6 files changed, 97 insertions(+), 21 deletions(-) diff --git a/src/backend/src/auth/auth.controller.ts b/src/backend/src/auth/auth.controller.ts index 08359ed..f968c23 100644 --- a/src/backend/src/auth/auth.controller.ts +++ b/src/backend/src/auth/auth.controller.ts @@ -27,7 +27,8 @@ export class AuthController { @Post('register') @UseGuards(OnboardingGuard) register( - @Body() body: { name: string; phone: string; program: string }, + @Body() + body: { firstName: string; lastName: string; phone: string; program: string }, @Req() req: any, ) { return this.authService.registerUser(req.onboardingEmail, body); diff --git a/src/backend/src/auth/auth.service.ts b/src/backend/src/auth/auth.service.ts index 71c1fc6..3cddd16 100644 --- a/src/backend/src/auth/auth.service.ts +++ b/src/backend/src/auth/auth.service.ts @@ -111,8 +111,10 @@ export class AuthService { } private isProfileComplete(user: User) { + const hasName = + (user.firstName?.trim() && user.lastName?.trim()) || user.name?.trim(); return Boolean( - user.name?.trim() && + hasName && user.phoneNumber?.trim() && user.program?.trim(), ); @@ -205,16 +207,30 @@ export class AuthService { async registerUser( email: string, - data: { name: string; phone: string; program: string }, + data: { firstName: string; lastName: string; phone: string; program: string }, ) { // One-time onboarding for newly verified users. - const name = data.name.trim(); - const program = data.program.trim(); - const phone = data.phone.trim(); + const safeData = data ?? { + firstName: '', + lastName: '', + phone: '', + program: '', + }; + 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(name)) { + if (!/^[\p{L}\s'-]+$/u.test(firstName) || !/^[\p{L}\s'-]+$/u.test(lastName)) { throw new BadRequestException( - 'Name must contain only letters and hyphens.', + 'Name must contain only letters, spaces, hyphens, and apostrophes.', ); } @@ -238,6 +254,8 @@ export class AuthService { if (existing) { user = await this.usersService.update(existing.id, { name, + firstName, + lastName, phoneNumber: normalizedPhone, program, }); @@ -245,6 +263,8 @@ export class AuthService { user = await this.usersService.create({ email, name, + firstName, + lastName, phoneNumber: normalizedPhone, program, }); diff --git a/src/backend/src/db/schema.ts b/src/backend/src/db/schema.ts index 84ab515..57ae806 100644 --- a/src/backend/src/db/schema.ts +++ b/src/backend/src/db/schema.ts @@ -15,6 +15,8 @@ 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 }), phoneNumber: varchar('phone_number', { length: 255 }).notNull(), program: varchar('program', { length: 255 }), isSystemAdmin: boolean('is_system_admin').default(false), diff --git a/src/frontend/mobile/app/(auth)/register.tsx b/src/frontend/mobile/app/(auth)/register.tsx index 9448c18..87358b9 100644 --- a/src/frontend/mobile/app/(auth)/register.tsx +++ b/src/frontend/mobile/app/(auth)/register.tsx @@ -38,14 +38,21 @@ const formatPhoneInput = (value: string) => { export default function RegisterScreen() { const router = useRouter(); const { status, pendingEmail, completeRegistration } = useAuth(); - const [name, setName] = useState(''); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); const [phone, setPhone] = useState(''); const [program, setProgram] = useState(''); const [submitting, setSubmitting] = useState(false); const [showPrograms, setShowPrograms] = useState(false); const formattedPhone = formatPhoneInput(phone); - const isNameValid = /^[A-Za-z-]+$/.test(name.trim()); + const normalizedFirstName = firstName.replace(/\s+/g, ' ').trim(); + const normalizedLastName = lastName.replace(/\s+/g, ' ').trim(); + const namePattern = /^[\p{L}\s'-]+$/u; + const isFirstNameValid = + normalizedFirstName.length > 0 && namePattern.test(normalizedFirstName); + const isLastNameValid = + normalizedLastName.length > 0 && namePattern.test(normalizedLastName); const phoneDigits = phone.replace(/\D/g, ''); const isPhoneValid = /^\d{10,15}$/.test(phoneDigits); const isProgramValid = program.trim().length > 0; @@ -60,8 +67,11 @@ export default function RegisterScreen() { }, [status, router]); const handleSubmit = async () => { - if (!/^[A-Za-z-]+$/.test(name.trim())) { - Alert.alert('Invalid name', 'Use letters and hyphens only.'); + if (!isFirstNameValid || !isLastNameValid) { + Alert.alert( + 'Invalid name', + 'Use letters, spaces, hyphens, and apostrophes only.', + ); return; } const normalizedPhone = phoneDigits; @@ -80,7 +90,8 @@ export default function RegisterScreen() { setSubmitting(true); try { await completeRegistration({ - name: name.trim(), + firstName: normalizedFirstName, + lastName: normalizedLastName, phone: normalizedPhone, program: program.trim(), }); @@ -112,12 +123,23 @@ export default function RegisterScreen() { /> - Name {isNameValid && name.trim() ? '✅' : ''} + First name {isFirstNameValid ? '✅' : ''} + + + + + Last name {isLastNameValid ? '✅' : ''} diff --git a/src/frontend/mobile/app/context/AuthContext.tsx b/src/frontend/mobile/app/context/AuthContext.tsx index b4447bf..7a7864b 100644 --- a/src/frontend/mobile/app/context/AuthContext.tsx +++ b/src/frontend/mobile/app/context/AuthContext.tsx @@ -17,7 +17,12 @@ interface AuthContextType { isAdmin: boolean; requestCode: (email: string) => Promise; confirmCode: (email: string, code: string) => Promise<'needsRegistration' | 'authenticated'>; - completeRegistration: (data: { name: string; phone: string; program: string }) => Promise; + completeRegistration: (data: { + firstName: string; + lastName: string; + phone: string; + program: string; + }) => Promise; logout: () => Promise; refreshUser: () => Promise; } @@ -85,7 +90,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return 'authenticated'; }; - const completeRegistration = async (data: { name: string; phone: string; program: string }) => { + const completeRegistration = async (data: { + firstName: string; + lastName: string; + phone: string; + program: string; + }) => { const result = await registerProfile(data); await setAuthToken(result.token); setUser(result.user); diff --git a/src/frontend/mobile/app/lib/api.ts b/src/frontend/mobile/app/lib/api.ts index 58b283f..56f372c 100644 --- a/src/frontend/mobile/app/lib/api.ts +++ b/src/frontend/mobile/app/lib/api.ts @@ -20,7 +20,25 @@ async function apiFetch(path: string, options?: RequestInit): Promise { ...options, }); if (!res.ok) { - throw new Error(`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(); } @@ -47,7 +65,8 @@ export function verifyCode(email: string, code: string): Promise Date: Thu, 12 Feb 2026 01:33:18 -0500 Subject: [PATCH 9/9] change to use password on login, verification only for new users --- src/backend/.env.example | 2 + src/backend/package-lock.json | 10 + src/backend/package.json | 1 + src/backend/src/auth/auth.controller.ts | 29 +- src/backend/src/auth/auth.service.ts | 124 +++++- src/backend/src/db/schema.ts | 1 + src/backend/src/users/users.service.ts | 16 +- src/frontend/mobile/app/(auth)/login.tsx | 421 ++++++++++++++++-- src/frontend/mobile/app/(auth)/register.tsx | 223 +--------- src/frontend/mobile/app/(auth)/verify.tsx | 88 +--- .../mobile/app/_context/AuthContext.tsx | 55 ++- src/frontend/mobile/app/_layout.tsx | 2 - src/frontend/mobile/app/_lib/api.ts | 35 +- 13 files changed, 629 insertions(+), 378 deletions(-) diff --git a/src/backend/.env.example b/src/backend/.env.example index 6318498..7eb977c 100644 --- a/src/backend/.env.example +++ b/src/backend/.env.example @@ -14,6 +14,8 @@ 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= diff --git a/src/backend/package-lock.json b/src/backend/package-lock.json index f357ec5..723ac5a 100644 --- a/src/backend/package-lock.json +++ b/src/backend/package-lock.json @@ -14,6 +14,7 @@ "@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", @@ -5234,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", diff --git a/src/backend/package.json b/src/backend/package.json index a2ba71b..13b5cea 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -32,6 +32,7 @@ "@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", diff --git a/src/backend/src/auth/auth.controller.ts b/src/backend/src/auth/auth.controller.ts index f968c23..92cbb80 100644 --- a/src/backend/src/auth/auth.controller.ts +++ b/src/backend/src/auth/auth.controller.ts @@ -19,16 +19,43 @@ export class AuthController { 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 }, + body: { + firstName: string; + lastName: string; + phone: string; + program: string; + password: string; + confirmPassword?: string; + }, @Req() req: any, ) { return this.authService.registerUser(req.onboardingEmail, body); diff --git a/src/backend/src/auth/auth.service.ts b/src/backend/src/auth/auth.service.ts index 3cddd16..cb4f050 100644 --- a/src/backend/src/auth/auth.service.ts +++ b/src/backend/src/auth/auth.service.ts @@ -4,6 +4,7 @@ import { 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 }; @@ -18,6 +19,7 @@ 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 { @@ -110,13 +112,19 @@ export class AuthService { 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.program?.trim() && + user.passwordHash?.trim(), ); } @@ -126,13 +134,34 @@ export class AuthService { } async requestVerificationCode(rawEmail: string) { - // M8: login(credentials) -> begin passwordless verification sequence. + // 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( @@ -151,13 +180,23 @@ export class AuthService { } async verifyCode(rawEmail: string, code: string) { - // M8: login(credentials) -> return AuthToken after verification succeeds. + // 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) @@ -186,28 +225,23 @@ export class AuthService { .set({ usedAt: new Date() }) .where(eq(verificationTokens.id, record.id)); - const user = await this.usersService.findByEmailWithRoles(email); - - if (!user || !this.isProfileComplete(user)) { - const token = this.issueJwt({ email, onboarding: true }); - return { - token, - needsRegistration: true, - user: user ?? null, - }; - } - - const token = this.issueJwt({ sub: user.id, email: user.email }); + const token = this.issueJwt({ email, onboarding: true }); return { token, - needsRegistration: false, - user, + needsRegistration: true, }; } async registerUser( email: string, - data: { firstName: string; lastName: string; phone: string; program: string }, + data: { + firstName: string; + lastName: string; + phone: string; + program: string; + password: string; + confirmPassword?: string; + }, ) { // One-time onboarding for newly verified users. const safeData = data ?? { @@ -215,6 +249,7 @@ export class AuthService { lastName: '', phone: '', program: '', + password: '', }; if (!safeData.firstName || !safeData.lastName) { throw new BadRequestException('First name and last name are required.'); @@ -228,9 +263,9 @@ export class AuthService { const program = safeData.program.trim(); const phone = safeData.phone.trim(); - if (!/^[\p{L}\s'-]+$/u.test(firstName) || !/^[\p{L}\s'-]+$/u.test(lastName)) { + if (!/^[A-Za-z-]+$/.test(firstName) || !/^[A-Za-z-]+$/.test(lastName)) { throw new BadRequestException( - 'Name must contain only letters, spaces, hyphens, and apostrophes.', + 'Name must contain only letters and hyphens.', ); } @@ -245,17 +280,41 @@ export class AuthService { 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 && this.isProfileComplete(existing)) { + 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, }); @@ -265,6 +324,7 @@ export class AuthService { name, firstName, lastName, + passwordHash, phoneNumber: normalizedPhone, program, }); @@ -279,6 +339,28 @@ export class AuthService { }; } + 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); diff --git a/src/backend/src/db/schema.ts b/src/backend/src/db/schema.ts index 00a2724..e7a0b6f 100644 --- a/src/backend/src/db/schema.ts +++ b/src/backend/src/db/schema.ts @@ -17,6 +17,7 @@ export const users = pgTable('users', { 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), diff --git a/src/backend/src/users/users.service.ts b/src/backend/src/users/users.service.ts index 1024be3..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,10 @@ 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) { diff --git a/src/frontend/mobile/app/(auth)/login.tsx b/src/frontend/mobile/app/(auth)/login.tsx index 7b7ad7e..7d62442 100644 --- a/src/frontend/mobile/app/(auth)/login.tsx +++ b/src/frontend/mobile/app/(auth)/login.tsx @@ -1,18 +1,96 @@ -import { useState } from 'react'; -import { View, Text, TextInput, Pressable, Alert } from 'react-native'; +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 { requestCode } = useAuth(); + 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(''); - const handleSubmit = async () => { - const normalized = email.trim().toLowerCase(); - if (!normalized.endsWith('@mcmaster.ca')) { + 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); @@ -21,11 +99,112 @@ export default function LoginScreen() { setSubmitting(true); try { - await requestCode(normalized); - router.push({ pathname: '/(auth)/verify', params: { email: normalized } }); + 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 || 'Failed to send verification code.'; + 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 { @@ -36,43 +215,223 @@ export default function LoginScreen() { return ( - Welcome back - - Sign in with your McMaster email to continue. - + {stepTitle} + {stepSubtitle} - Email - { - setEmail(value); - if (errorMessage) setErrorMessage(''); - }} - autoCapitalize="none" - keyboardType="email-address" - placeholder="name@mcmaster.ca" - placeholderTextColor="#C7CBD1" - returnKeyType="send" - blurOnSubmit - onSubmitEditing={handleSubmit} - className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50 text-sm" - /> + {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 ? 'Sending code...' : 'Send verification code'} + {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 index 6d776d2..cff2783 100644 --- a/src/frontend/mobile/app/(auth)/register.tsx +++ b/src/frontend/mobile/app/(auth)/register.tsx @@ -1,224 +1,5 @@ -import { useEffect, useState } from 'react'; -import { - View, - Text, - TextInput, - Pressable, - Alert, - Modal, - FlatList, -} from 'react-native'; -import { useRouter } from 'expo-router'; -import { useAuth } from '../_context/AuthContext'; - -const PROGRAM_OPTIONS = [ - 'Computer Science', - 'Software Engineering', - 'Electrical Engineering', - 'Mechanical Engineering', - 'Business', - 'Health Sciences', - 'Humanities', - 'Other', -]; - -const formatPhoneInput = (value: string) => { - const digits = value.replace(/\D/g, ''); - if (digits.length <= 3) return digits; - if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; - if (digits.length <= 10) { - return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; - } - return `+${digits.slice(0, 1)} (${digits.slice(1, 4)}) ${digits.slice( - 4, - 7, - )}-${digits.slice(7, 11)}`; -}; +import { Redirect } from 'expo-router'; export default function RegisterScreen() { - const router = useRouter(); - const { status, pendingEmail, completeRegistration } = useAuth(); - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - const [phone, setPhone] = useState(''); - const [program, setProgram] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [showPrograms, setShowPrograms] = useState(false); - - const formattedPhone = formatPhoneInput(phone); - const normalizedFirstName = firstName.replace(/\s+/g, ' ').trim(); - const normalizedLastName = lastName.replace(/\s+/g, ' ').trim(); - const namePattern = /^[\p{L}\s'-]+$/u; - const isFirstNameValid = - normalizedFirstName.length > 0 && namePattern.test(normalizedFirstName); - const isLastNameValid = - normalizedLastName.length > 0 && namePattern.test(normalizedLastName); - const phoneDigits = phone.replace(/\D/g, ''); - const isPhoneValid = /^\d{10,15}$/.test(phoneDigits); - const isProgramValid = program.trim().length > 0; - - useEffect(() => { - if (status === 'authenticated') { - router.replace('/(tabs)'); - } - if (status === 'unauthenticated') { - router.replace('/(auth)/login'); - } - }, [status, router]); - - const handleSubmit = async () => { - if (!isFirstNameValid || !isLastNameValid) { - Alert.alert( - 'Invalid name', - 'Use letters, spaces, hyphens, and apostrophes only.', - ); - return; - } - const normalizedPhone = phoneDigits; - if (!/^\d{10,15}$/.test(normalizedPhone)) { - Alert.alert( - 'Invalid phone number', - 'Use 10 digits or include a valid country code.', - ); - return; - } - if (!program.trim()) { - Alert.alert('Missing program', 'Please select your program.'); - return; - } - - setSubmitting(true); - try { - await completeRegistration({ - firstName: normalizedFirstName, - lastName: normalizedLastName, - phone: normalizedPhone, - program: program.trim(), - }); - router.replace('/(tabs)'); - } catch (error: any) { - Alert.alert('Error', error.message || 'Failed to complete registration.'); - } finally { - setSubmitting(false); - } - }; - - return ( - - - - Complete your profile - - - Tell us a bit more to finish setting up your account. - - - - - Email - - - - First name {isFirstNameValid ? '✅' : ''} - - - - - Last name {isLastNameValid ? '✅' : ''} - - - - - Phone number {isPhoneValid && phoneDigits ? '✅' : ''} - - setPhone(value)} - placeholder="(905) 555-1234" - keyboardType="phone-pad" - placeholderTextColor="#C7CBD1" - className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50 text-sm" - /> - - - Program {isProgramValid ? '✅' : ''} - - setShowPrograms(true)} - className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50" - > - - {program || 'Select your program'} - - - - - - {submitting ? 'Saving...' : '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 - - - - - - - ); + return ; } diff --git a/src/frontend/mobile/app/(auth)/verify.tsx b/src/frontend/mobile/app/(auth)/verify.tsx index 1e9735a..30f75a2 100644 --- a/src/frontend/mobile/app/(auth)/verify.tsx +++ b/src/frontend/mobile/app/(auth)/verify.tsx @@ -1,89 +1,5 @@ -import { useEffect, useState } from 'react'; -import { View, Text, TextInput, Pressable, Alert } from 'react-native'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useAuth } from '../_context/AuthContext'; +import { Redirect } from 'expo-router'; export default function VerifyScreen() { - const router = useRouter(); - const { email } = useLocalSearchParams<{ email?: string }>(); - const { confirmCode } = useAuth(); - const [code, setCode] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - - useEffect(() => { - if (!email) { - router.replace('/(auth)/login'); - } - }, [email, router]); - - const handleSubmit = async () => { - if (!email) return; - if (code.trim().length !== 6) { - const message = 'Enter the 6-digit verification code.'; - setErrorMessage(message); - Alert.alert('Invalid code', message); - return; - } - - setSubmitting(true); - try { - const result = await confirmCode(String(email), code.trim()); - setErrorMessage(''); - if (result === 'needsRegistration') { - router.replace('/(auth)/register'); - } else { - router.replace('/(tabs)'); - } - } catch (error: any) { - const message = error.message || 'Failed to verify code.'; - setErrorMessage(message); - Alert.alert('Error', message); - } finally { - setSubmitting(false); - } - }; - - return ( - - - Check your email - - Enter the 6-digit code sent to {email}. - - - - - Verification code - { - setCode(value); - if (errorMessage) setErrorMessage(''); - }} - keyboardType="number-pad" - placeholder="123456" - maxLength={6} - placeholderTextColor="#C7CBD1" - returnKeyType="send" - blurOnSubmit - onSubmitEditing={handleSubmit} - className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-gray-50 text-sm tracking-widest text-center" - /> - {!!errorMessage && ( - {errorMessage} - )} - - - - {submitting ? 'Verifying...' : 'Verify and continue'} - - - - - ); + return ; } diff --git a/src/frontend/mobile/app/_context/AuthContext.tsx b/src/frontend/mobile/app/_context/AuthContext.tsx index 64b5e4a..2a6f97a 100644 --- a/src/frontend/mobile/app/_context/AuthContext.tsx +++ b/src/frontend/mobile/app/_context/AuthContext.tsx @@ -1,8 +1,12 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import { getMe, + checkEmail, + loginWithPassword, requestVerificationCode, + requestOtp, verifyCode, + verifyOtp, registerProfile, type User, } from '../_lib/api'; @@ -15,13 +19,18 @@ interface AuthContextType { user: User | null; pendingEmail: string | null; isAdmin: boolean; + checkEmail: (email: string) => Promise; + login: (email: string, password: string) => Promise; requestCode: (email: string) => Promise; - confirmCode: (email: string, code: string) => Promise<'needsRegistration' | 'authenticated'>; + 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; @@ -32,7 +41,10 @@ const AuthContext = createContext({ user: null, pendingEmail: null, isAdmin: false, + checkEmail: async () => false, + login: async () => {}, requestCode: async () => {}, + requestOtp: async () => {}, confirmCode: async () => 'needsRegistration', completeRegistration: async () => {}, logout: async () => {}, @@ -40,7 +52,12 @@ const AuthContext = createContext({ }); function isProfileComplete(user: User) { - return Boolean(user.name?.trim() && user.phoneNumber?.trim() && user.program?.trim()); + return Boolean( + (user.firstName?.trim() || user.name?.trim()) && + user.lastName?.trim() && + user.phoneNumber?.trim() && + user.program?.trim(), + ); } export function AuthProvider({ children }: { children: React.ReactNode }) { @@ -72,22 +89,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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 verifyCode(email, code); + const result = await verifyOtp(email, code); await setAuthToken(result.token); setPendingEmail(email); - if (result.needsRegistration) { - setStatus('needsRegistration'); - setUser(result.user ?? null); - return 'needsRegistration'; - } - setUser(result.user); - setStatus('authenticated'); - return 'authenticated'; + setStatus('needsRegistration'); + return 'needsRegistration'; }; const completeRegistration = async (data: { @@ -95,6 +123,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { lastName: string; phone: string; program: string; + password: string; + confirmPassword?: string; }) => { const result = await registerProfile(data); await setAuthToken(result.token); @@ -120,7 +150,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { user, pendingEmail, isAdmin, + checkEmail: checkEmailStatus, + login, requestCode, + requestOtp: requestOtpCode, confirmCode, completeRegistration, logout, diff --git a/src/frontend/mobile/app/_layout.tsx b/src/frontend/mobile/app/_layout.tsx index d4062c8..a130fd3 100644 --- a/src/frontend/mobile/app/_layout.tsx +++ b/src/frontend/mobile/app/_layout.tsx @@ -58,8 +58,6 @@ function RootNavigator() { {status === "authenticated" ? ( - ) : status === "needsRegistration" ? ( - ) : ( )} diff --git a/src/frontend/mobile/app/_lib/api.ts b/src/frontend/mobile/app/_lib/api.ts index ebf84fc..32705dd 100644 --- a/src/frontend/mobile/app/_lib/api.ts +++ b/src/frontend/mobile/app/_lib/api.ts @@ -50,7 +50,24 @@ async function apiFetch(path: string, options?: RequestInit): Promise { export interface VerifyCodeResponse { token: string; needsRegistration: boolean; - user: User | null; + 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) { @@ -60,6 +77,13 @@ export function requestVerificationCode(email: string) { }); } +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', @@ -67,11 +91,20 @@ export function verifyCode(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',