From 4897f0a52c40c82177a9a994e6d8336b4f7deaff Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:20:25 -0600 Subject: [PATCH] feat: delete users - not tested --- backend/package-lock.json | 38 ++- backend/package.json | 3 +- backend/src/app.ts | 8 +- .../src/core/auth/auth0-management.client.ts | 200 +++++++++++++++ backend/src/core/config/config-loader.ts | 13 + backend/src/core/plugins/auth.plugin.ts | 102 +++++++- backend/src/core/scheduler/index.ts | 20 +- .../data/user-preferences.repository.ts | 18 +- .../user-preferences.types.ts | 2 + .../src/features/admin/api/admin.routes.ts | 6 + .../features/admin/api/users.controller.ts | 69 ++++++ backend/src/features/auth/README.md | 162 ++++++++++++ .../src/features/auth/api/auth.controller.ts | 122 +++++++++ backend/src/features/auth/api/auth.routes.ts | 30 +++ .../src/features/auth/api/auth.validation.ts | 23 ++ .../src/features/auth/domain/auth.service.ts | 130 ++++++++++ .../src/features/auth/domain/auth.types.ts | 28 +++ backend/src/features/auth/index.ts | 18 ++ .../integration/auth.integration.test.ts | 214 ++++++++++++++++ .../auth/tests/unit/auth.service.test.ts | 205 ++++++++++++++++ backend/src/features/onboarding/README.md | 186 ++++++++++++++ .../onboarding/api/onboarding.controller.ts | 143 +++++++++++ .../onboarding/api/onboarding.routes.ts | 33 +++ .../onboarding/api/onboarding.validation.ts | 21 ++ .../onboarding/domain/onboarding.service.ts | 155 ++++++++++++ .../onboarding/domain/onboarding.types.ts | 40 +++ backend/src/features/onboarding/index.ts | 20 ++ .../api/user-profile.controller.ts | 184 +++++++++++++- .../user-profile/api/user-profile.routes.ts | 20 +- .../api/user-profile.validation.ts | 9 + .../data/user-profile.repository.ts | 232 +++++++++++++++++- .../domain/user-profile.service.ts | 176 +++++++++++++ .../user-profile/domain/user-profile.types.ts | 18 ++ .../user-profile/jobs/account-purge.job.ts | 87 +++++++ .../003_add_email_verified_and_onboarding.sql | 23 ++ .../migrations/004_add_deletion_request.sql | 18 ++ docker-compose.yml | 2 + docs/PROMPTS.md | 9 +- frontend/src/App.tsx | 67 ++++- frontend/src/features/admin/api/admin.api.ts | 8 + frontend/src/features/admin/hooks/useUsers.ts | 23 ++ .../admin/mobile/AdminUsersMobileScreen.tsx | 118 +++++++++ frontend/src/features/auth/api/auth.api.ts | 40 +++ .../features/auth/components/SignupForm.tsx | 148 +++++++++++ frontend/src/features/auth/hooks/useSignup.ts | 32 +++ .../features/auth/hooks/useVerifyStatus.ts | 54 ++++ frontend/src/features/auth/index.ts | 24 ++ .../auth/mobile/SignupMobileScreen.tsx | 54 ++++ .../auth/mobile/VerifyEmailMobileScreen.tsx | 96 ++++++++ .../src/features/auth/pages/SignupPage.tsx | 50 ++++ .../features/auth/pages/VerifyEmailPage.tsx | 90 +++++++ .../src/features/auth/types/auth.types.ts | 23 ++ .../features/onboarding/api/onboarding.api.ts | 23 ++ .../onboarding/components/AddVehicleStep.tsx | 114 +++++++++ .../onboarding/components/CompleteStep.tsx | 70 ++++++ .../onboarding/components/PreferencesStep.tsx | 141 +++++++++++ .../onboarding/hooks/useOnboarding.ts | 63 +++++ frontend/src/features/onboarding/index.ts | 28 +++ .../mobile/OnboardingMobileScreen.tsx | 150 +++++++++++ .../onboarding/pages/OnboardingPage.tsx | 147 +++++++++++ .../onboarding/types/onboarding.types.ts | 17 ++ .../src/features/settings/api/profile.api.ts | 12 +- .../components/DeleteAccountDialog.tsx | 102 ++++++++ .../components/PendingDeletionBanner.tsx | 52 ++++ .../features/settings/hooks/useDeletion.ts | 74 ++++++ .../settings/mobile/DeleteAccountModal.tsx | 116 +++++++++ .../settings/mobile/MobileSettingsScreen.tsx | 35 +-- .../settings/mobile/PendingDeletionBanner.tsx | 53 ++++ .../features/settings/types/profile.types.ts | 23 ++ frontend/src/pages/HomePage.tsx | 16 ++ frontend/src/pages/SettingsPage.tsx | 12 +- frontend/src/pages/admin/AdminUsersPage.tsx | 117 +++++++++ scripts/inject-secrets.sh | 6 + 73 files changed, 4923 insertions(+), 62 deletions(-) create mode 100644 backend/src/core/auth/auth0-management.client.ts create mode 100644 backend/src/features/auth/README.md create mode 100644 backend/src/features/auth/api/auth.controller.ts create mode 100644 backend/src/features/auth/api/auth.routes.ts create mode 100644 backend/src/features/auth/api/auth.validation.ts create mode 100644 backend/src/features/auth/domain/auth.service.ts create mode 100644 backend/src/features/auth/domain/auth.types.ts create mode 100644 backend/src/features/auth/index.ts create mode 100644 backend/src/features/auth/tests/integration/auth.integration.test.ts create mode 100644 backend/src/features/auth/tests/unit/auth.service.test.ts create mode 100644 backend/src/features/onboarding/README.md create mode 100644 backend/src/features/onboarding/api/onboarding.controller.ts create mode 100644 backend/src/features/onboarding/api/onboarding.routes.ts create mode 100644 backend/src/features/onboarding/api/onboarding.validation.ts create mode 100644 backend/src/features/onboarding/domain/onboarding.service.ts create mode 100644 backend/src/features/onboarding/domain/onboarding.types.ts create mode 100644 backend/src/features/onboarding/index.ts create mode 100644 backend/src/features/user-profile/jobs/account-purge.job.ts create mode 100644 backend/src/features/user-profile/migrations/003_add_email_verified_and_onboarding.sql create mode 100644 backend/src/features/user-profile/migrations/004_add_deletion_request.sql create mode 100644 frontend/src/features/auth/api/auth.api.ts create mode 100644 frontend/src/features/auth/components/SignupForm.tsx create mode 100644 frontend/src/features/auth/hooks/useSignup.ts create mode 100644 frontend/src/features/auth/hooks/useVerifyStatus.ts create mode 100644 frontend/src/features/auth/index.ts create mode 100644 frontend/src/features/auth/mobile/SignupMobileScreen.tsx create mode 100644 frontend/src/features/auth/mobile/VerifyEmailMobileScreen.tsx create mode 100644 frontend/src/features/auth/pages/SignupPage.tsx create mode 100644 frontend/src/features/auth/pages/VerifyEmailPage.tsx create mode 100644 frontend/src/features/auth/types/auth.types.ts create mode 100644 frontend/src/features/onboarding/api/onboarding.api.ts create mode 100644 frontend/src/features/onboarding/components/AddVehicleStep.tsx create mode 100644 frontend/src/features/onboarding/components/CompleteStep.tsx create mode 100644 frontend/src/features/onboarding/components/PreferencesStep.tsx create mode 100644 frontend/src/features/onboarding/hooks/useOnboarding.ts create mode 100644 frontend/src/features/onboarding/index.ts create mode 100644 frontend/src/features/onboarding/mobile/OnboardingMobileScreen.tsx create mode 100644 frontend/src/features/onboarding/pages/OnboardingPage.tsx create mode 100644 frontend/src/features/onboarding/types/onboarding.types.ts create mode 100644 frontend/src/features/settings/components/DeleteAccountDialog.tsx create mode 100644 frontend/src/features/settings/components/PendingDeletionBanner.tsx create mode 100644 frontend/src/features/settings/hooks/useDeletion.ts create mode 100644 frontend/src/features/settings/mobile/DeleteAccountModal.tsx create mode 100644 frontend/src/features/settings/mobile/PendingDeletionBanner.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index 59920d7..e9c8fe2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "@fastify/multipart": "^9.0.1", "@fastify/type-provider-typebox": "^6.1.0", "@sinclair/typebox": "^0.34.0", + "auth0": "^4.12.0", "axios": "^1.7.9", "fastify": "^5.2.0", "fastify-plugin": "^5.0.1", @@ -2516,6 +2517,33 @@ "node": ">=8.0.0" } }, + "node_modules/auth0": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/auth0/-/auth0-4.37.0.tgz", + "integrity": "sha512-+TqJRxh4QvbD4TQIYx1ak2vanykQkG/nIZLuR6o8LoQj425gjVG3tFuUbbOeh/nCpP1rnvU0CCV1ChZHYXLU/A==", + "license": "MIT", + "dependencies": { + "jose": "^4.13.2", + "undici-types": "^6.15.0", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/auth0/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/avvio": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz", @@ -5511,6 +5539,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-beautify": { "version": "1.15.4", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", @@ -8008,7 +8045,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { diff --git a/backend/package.json b/backend/package.json index 36e2d36..b401b5a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,7 +37,8 @@ "get-jwks": "^11.0.3", "file-type": "^16.5.4", "resend": "^3.0.0", - "node-cron": "^3.0.3" + "node-cron": "^3.0.3", + "auth0": "^4.12.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index cf2aa91..65eaec6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -15,6 +15,7 @@ import errorPlugin from './core/plugins/error.plugin'; import { appConfig } from './core/config/config-loader'; // Fastify feature routes +import { authRoutes } from './features/auth'; import { vehiclesRoutes } from './features/vehicles/api/vehicles.routes'; import { fuelLogsRoutes } from './features/fuel-logs/api/fuel-logs.routes'; import { stationsRoutes } from './features/stations/api/stations.routes'; @@ -25,6 +26,7 @@ import { platformRoutes } from './features/platform'; import { adminRoutes } from './features/admin/api/admin.routes'; import { notificationsRoutes } from './features/notifications'; import { userProfileRoutes } from './features/user-profile'; +import { onboardingRoutes } from './features/onboarding'; import { pool } from './core/config/database'; async function buildApp(): Promise { @@ -82,7 +84,7 @@ async function buildApp(): Promise { status: 'healthy', timestamp: new Date().toISOString(), environment: process.env['NODE_ENV'], - features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile'] + features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile'] }); }); @@ -92,7 +94,7 @@ async function buildApp(): Promise { status: 'healthy', scope: 'api', timestamp: new Date().toISOString(), - features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile'] + features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile'] }); }); @@ -118,6 +120,8 @@ async function buildApp(): Promise { }); // Register Fastify feature routes + await app.register(authRoutes, { prefix: '/api' }); + await app.register(onboardingRoutes, { prefix: '/api' }); await app.register(platformRoutes, { prefix: '/api' }); await app.register(vehiclesRoutes, { prefix: '/api' }); await app.register(documentsRoutes, { prefix: '/api' }); diff --git a/backend/src/core/auth/auth0-management.client.ts b/backend/src/core/auth/auth0-management.client.ts new file mode 100644 index 0000000..734d36a --- /dev/null +++ b/backend/src/core/auth/auth0-management.client.ts @@ -0,0 +1,200 @@ +/** + * Auth0 Management API Client + * Provides methods for user management operations via Auth0 Management API + */ +import { ManagementClient } from 'auth0'; +import { appConfig } from '../config/config-loader'; +import { logger } from '../logging/logger'; + +interface CreateUserParams { + email: string; + password: string; +} + +interface UserDetails { + userId: string; + email: string; + emailVerified: boolean; +} + +class Auth0ManagementClientSingleton { + private client: ManagementClient | null = null; + private readonly CONNECTION_NAME = 'Username-Password-Authentication'; + + /** + * Lazy initialization of ManagementClient to avoid startup issues + */ + private getClient(): ManagementClient { + if (!this.client) { + const config = appConfig.getAuth0ManagementConfig(); + + this.client = new ManagementClient({ + domain: config.domain, + clientId: config.clientId, + clientSecret: config.clientSecret, + }); + + logger.info('Auth0 Management API client initialized'); + } + + return this.client; + } + + /** + * Create a new user in Auth0 + * @param email User's email address + * @param password User's password + * @returns Auth0 user ID + */ + async createUser({ email, password }: CreateUserParams): Promise { + try { + const client = this.getClient(); + + const response = await client.users.create({ + connection: this.CONNECTION_NAME, + email, + password, + email_verified: false, + }); + + const user = response.data; + if (!user.user_id) { + throw new Error('Auth0 did not return a user_id'); + } + + logger.info('User created in Auth0', { userId: user.user_id, email }); + return user.user_id; + } catch (error) { + logger.error('Failed to create user in Auth0', { email, error }); + throw new Error(`Auth0 user creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Get user details from Auth0 + * @param userId Auth0 user ID (format: auth0|xxx or google-oauth2|xxx) + * @returns User details including email and verification status + */ + async getUser(userId: string): Promise { + try { + const client = this.getClient(); + + const response = await client.users.get({ id: userId }); + const user = response.data; + + if (!user.email) { + throw new Error('User email not found in Auth0 response'); + } + + logger.info('Retrieved user from Auth0', { userId, email: user.email }); + + return { + userId: user.user_id || userId, + email: user.email, + emailVerified: user.email_verified || false, + }; + } catch (error) { + logger.error('Failed to get user from Auth0', { userId, error }); + throw new Error(`Auth0 user retrieval failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Resend email verification to user + * @param userId Auth0 user ID + */ + async resendVerificationEmail(userId: string): Promise { + try { + const client = this.getClient(); + + await client.jobs.verifyEmail({ + user_id: userId, + }); + + logger.info('Verification email sent', { userId }); + } catch (error) { + logger.error('Failed to send verification email', { userId, error }); + throw new Error(`Failed to send verification email: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Check if user's email is verified + * @param userId Auth0 user ID + * @returns true if email is verified, false otherwise + */ + async checkEmailVerified(userId: string): Promise { + try { + const user = await this.getUser(userId); + return user.emailVerified; + } catch (error) { + logger.error('Failed to check email verification status', { userId, error }); + throw new Error(`Failed to check email verification: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Delete a user from Auth0 (permanent deletion) + * @param userId Auth0 user ID (format: auth0|xxx or google-oauth2|xxx) + */ + async deleteUser(userId: string): Promise { + try { + const client = this.getClient(); + + await client.users.delete({ id: userId }); + + logger.info('User deleted from Auth0', { userId }); + } catch (error) { + logger.error('Failed to delete user from Auth0', { userId, error }); + throw new Error(`Auth0 user deletion failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Verify user password using Resource Owner Password Grant + * @param email User's email address + * @param password User's password to verify + * @returns true if password is valid, false otherwise + */ + async verifyPassword(email: string, password: string): Promise { + try { + const config = appConfig.getAuth0ManagementConfig(); + + const response = await fetch(`https://${config.domain}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'password', + username: email, + password, + client_id: config.clientId, + client_secret: config.clientSecret, + audience: `https://${config.domain}/api/v2/`, + scope: 'openid profile email', + }), + }); + + if (response.ok) { + logger.info('Password verification successful', { email }); + return true; + } + + if (response.status === 403 || response.status === 401) { + logger.info('Password verification failed - invalid credentials', { email }); + return false; + } + + const errorBody = await response.text(); + logger.error('Password verification request failed', { email, status: response.status, error: errorBody }); + throw new Error(`Password verification failed with status ${response.status}`); + } catch (error) { + logger.error('Failed to verify password', { email, error }); + throw new Error(`Password verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } +} + +// Export singleton instance +export const auth0ManagementClient = new Auth0ManagementClientSingleton(); diff --git a/backend/src/core/config/config-loader.ts b/backend/src/core/config/config-loader.ts index 3a456a2..6035e52 100644 --- a/backend/src/core/config/config-loader.ts +++ b/backend/src/core/config/config-loader.ts @@ -122,6 +122,8 @@ const configSchema = z.object({ const secretsSchema = z.object({ postgres_password: z.string(), auth0_client_secret: z.string(), + auth0_management_client_id: z.string(), + auth0_management_client_secret: z.string(), google_maps_api_key: z.string(), resend_api_key: z.string(), }); @@ -137,6 +139,7 @@ export interface AppConfiguration { getDatabaseUrl(): string; getRedisUrl(): string; getAuth0Config(): { domain: string; audience: string; clientSecret: string }; + getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string }; } class ConfigurationLoader { @@ -171,6 +174,8 @@ class ConfigurationLoader { const secretFiles = [ 'postgres-password', 'auth0-client-secret', + 'auth0-management-client-id', + 'auth0-management-client-secret', 'google-maps-api-key', 'resend-api-key', ]; @@ -227,6 +232,14 @@ class ConfigurationLoader { clientSecret: secrets.auth0_client_secret, }; }, + + getAuth0ManagementConfig() { + return { + domain: config.auth0.domain, + clientId: secrets.auth0_management_client_id, + clientSecret: secrets.auth0_management_client_secret, + }; + }, }; // Set RESEND_API_KEY in environment for EmailService diff --git a/backend/src/core/plugins/auth.plugin.ts b/backend/src/core/plugins/auth.plugin.ts index ed2a047..a9cda41 100644 --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -1,6 +1,7 @@ /** * @ai-summary Fastify JWT authentication plugin using Auth0 * @ai-context Validates JWT tokens against Auth0 JWKS endpoint, hydrates userContext with profile + * @ai-context Includes email verification guard to block unverified users */ import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'; import fp from 'fastify-plugin'; @@ -10,6 +11,15 @@ import { appConfig } from '../config/config-loader'; import { logger } from '../logging/logger'; import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository'; import { pool } from '../config/database'; +import { auth0ManagementClient } from '../auth/auth0-management.client'; + +// Routes that don't require email verification +const VERIFICATION_EXEMPT_ROUTES = [ + '/api/auth/', + '/api/onboarding/', + '/api/health', + '/health', +]; // Define the Auth0 JWT payload type interface Auth0JwtPayload { @@ -42,6 +52,8 @@ declare module 'fastify' { userId: string; email?: string; displayName?: string; + emailVerified: boolean; + onboardingCompleted: boolean; isAdmin: boolean; adminRecord?: any; }; @@ -97,31 +109,91 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { // Initialize profile repository for user profile hydration const profileRepo = new UserProfileRepository(pool); + // Helper to check if route is exempt from verification + const isVerificationExempt = (url: string): boolean => { + return VERIFICATION_EXEMPT_ROUTES.some(route => url.startsWith(route)); + }; + // Decorate with authenticate function that validates JWT fastify.decorate('authenticate', async function(request: FastifyRequest, reply: FastifyReply) { try { await request.jwtVerify(); const userId = request.user?.sub; + if (!userId) { + throw new Error('Missing user ID in JWT'); + } // Get or create user profile from database - // This ensures we have reliable email/displayName for notifications let email = request.user?.email; let displayName: string | undefined; + let emailVerified = false; + let onboardingCompleted = false; try { + // If JWT doesn't have email, fetch from Auth0 Management API + if (!email || email.includes('@unknown.local')) { + try { + const auth0User = await auth0ManagementClient.getUser(userId); + if (auth0User.email) { + email = auth0User.email; + emailVerified = auth0User.emailVerified; + logger.info('Fetched email from Auth0 Management API', { + userId: userId.substring(0, 8) + '...', + hasEmail: true, + }); + } + } catch (auth0Error) { + logger.warn('Failed to fetch user from Auth0 Management API', { + userId: userId.substring(0, 8) + '...', + error: auth0Error instanceof Error ? auth0Error.message : 'Unknown error', + }); + } + } + + // Get or create profile with correct email const profile = await profileRepo.getOrCreate(userId, { - email: request.user?.email || `${userId}@unknown.local`, + email: email || `${userId}@unknown.local`, displayName: request.user?.name || request.user?.nickname, }); + // If profile has placeholder email but we now have real email, update it + if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) { + await profileRepo.updateEmail(userId, email); + logger.info('Updated profile with correct email from Auth0', { + userId: userId.substring(0, 8) + '...', + }); + } + // Use notificationEmail if set, otherwise fall back to profile email email = profile.notificationEmail || profile.email; displayName = profile.displayName || undefined; + emailVerified = profile.emailVerified; + onboardingCompleted = profile.onboardingCompletedAt !== null; + + // Sync email verification status from Auth0 if needed + if (!emailVerified) { + try { + const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(userId); + if (isVerifiedInAuth0 && !profile.emailVerified) { + await profileRepo.updateEmailVerified(userId, true); + emailVerified = true; + logger.info('Synced email verification status from Auth0', { + userId: userId.substring(0, 8) + '...', + }); + } + } catch (syncError) { + // Don't fail auth if sync fails, just log + logger.warn('Failed to sync email verification status', { + userId: userId.substring(0, 8) + '...', + error: syncError instanceof Error ? syncError.message : 'Unknown error', + }); + } + } } catch (profileError) { // Log but don't fail auth if profile fetch fails logger.warn('Failed to fetch user profile', { - userId: userId?.substring(0, 8) + '...', + userId: userId.substring(0, 8) + '...', error: profileError instanceof Error ? profileError.message : 'Unknown error', }); // Fall back to JWT email if available @@ -133,13 +205,29 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { userId, email, displayName, + emailVerified, + onboardingCompleted, isAdmin: false, // Default to false; admin status checked by admin guard }; + // Email verification guard - block unverified users from non-exempt routes + if (!emailVerified && !isVerificationExempt(request.url)) { + logger.warn('Blocked unverified user from accessing protected route', { + userId: userId.substring(0, 8) + '...', + path: request.url, + }); + return reply.code(403).send({ + error: 'Email not verified', + message: 'Please verify your email address before accessing the application', + code: 'EMAIL_NOT_VERIFIED', + }); + } + logger.info('JWT authentication successful', { - userId: userId?.substring(0, 8) + '...', + userId: userId.substring(0, 8) + '...', hasEmail: !!email, - audience: auth0Config.audience + emailVerified, + audience: auth0Config.audience, }); } catch (error) { logger.warn('JWT authentication failed', { @@ -148,9 +236,9 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { error: error instanceof Error ? error.message : 'Unknown error', }); - reply.code(401).send({ + return reply.code(401).send({ error: 'Unauthorized', - message: 'Invalid or missing JWT token' + message: 'Invalid or missing JWT token', }); } }); diff --git a/backend/src/core/scheduler/index.ts b/backend/src/core/scheduler/index.ts index 6757f96..cc9a071 100644 --- a/backend/src/core/scheduler/index.ts +++ b/backend/src/core/scheduler/index.ts @@ -6,6 +6,7 @@ import cron from 'node-cron'; import { logger } from '../logging/logger'; import { processScheduledNotifications } from '../../features/notifications/jobs/notification-processor.job'; +import { processAccountPurges } from '../../features/user-profile/jobs/account-purge.job'; let schedulerInitialized = false; @@ -29,8 +30,25 @@ export function initializeScheduler(): void { } }); + // Daily account purge job at 2 AM (GDPR compliance - 30-day grace period) + cron.schedule('0 2 * * *', async () => { + logger.info('Running account purge job'); + try { + const result = await processAccountPurges(); + logger.info('Account purge job completed successfully', { + processed: result.processed, + deleted: result.deleted, + errors: result.errors.length, + }); + } catch (error) { + logger.error('Account purge job failed', { + error: error instanceof Error ? error.message : String(error) + }); + } + }); + schedulerInitialized = true; - logger.info('Cron scheduler initialized - notification job scheduled for 8 AM daily'); + logger.info('Cron scheduler initialized - notification job (8 AM) and account purge job (2 AM) scheduled daily'); } export function isSchedulerInitialized(): boolean { diff --git a/backend/src/core/user-preferences/data/user-preferences.repository.ts b/backend/src/core/user-preferences/data/user-preferences.repository.ts index 408dac2..03642b1 100644 --- a/backend/src/core/user-preferences/data/user-preferences.repository.ts +++ b/backend/src/core/user-preferences/data/user-preferences.repository.ts @@ -26,12 +26,12 @@ export class UserPreferencesRepository { VALUES ($1, $2, $3, $4) RETURNING * `; - + const values = [ data.userId, data.unitSystem || 'imperial', - (data as any).currencyCode || 'USD', - (data as any).timeZone || 'UTC' + data.currencyCode || 'USD', + data.timeZone || 'UTC' ]; const result = await this.db.query(query, values); @@ -47,13 +47,13 @@ export class UserPreferencesRepository { fields.push(`unit_system = $${paramCount++}`); values.push(data.unitSystem); } - if ((data as any).currencyCode !== undefined) { + if (data.currencyCode !== undefined) { fields.push(`currency_code = $${paramCount++}`); - values.push((data as any).currencyCode); + values.push(data.currencyCode); } - if ((data as any).timeZone !== undefined) { + if (data.timeZone !== undefined) { fields.push(`time_zone = $${paramCount++}`); - values.push((data as any).timeZone); + values.push(data.timeZone); } if (fields.length === 0) { @@ -61,12 +61,12 @@ export class UserPreferencesRepository { } const query = ` - UPDATE user_preferences + UPDATE user_preferences SET ${fields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE user_id = $${paramCount} RETURNING * `; - + values.push(userId); const result = await this.db.query(query, values); return result.rows.length > 0 ? this.mapRow(result.rows[0]) : null; diff --git a/backend/src/core/user-preferences/user-preferences.types.ts b/backend/src/core/user-preferences/user-preferences.types.ts index d5d0914..c35544c 100644 --- a/backend/src/core/user-preferences/user-preferences.types.ts +++ b/backend/src/core/user-preferences/user-preferences.types.ts @@ -18,6 +18,8 @@ export interface UserPreferences { export interface CreateUserPreferencesRequest { userId: string; unitSystem?: UnitSystem; + currencyCode?: string; + timeZone?: string; } export interface UpdateUserPreferencesRequest { diff --git a/backend/src/features/admin/api/admin.routes.ts b/backend/src/features/admin/api/admin.routes.ts index c3aa74d..04e3b4f 100644 --- a/backend/src/features/admin/api/admin.routes.ts +++ b/backend/src/features/admin/api/admin.routes.ts @@ -155,6 +155,12 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => { handler: usersController.promoteToAdmin.bind(usersController) }); + // DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent) + fastify.delete<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', { + preHandler: [fastify.requireAdmin], + handler: usersController.hardDeleteUser.bind(usersController) + }); + // Phase 3: Catalog CRUD endpoints // Makes endpoints diff --git a/backend/src/features/admin/api/users.controller.ts b/backend/src/features/admin/api/users.controller.ts index e157844..0c65a42 100644 --- a/backend/src/features/admin/api/users.controller.ts +++ b/backend/src/features/admin/api/users.controller.ts @@ -486,4 +486,73 @@ export class UsersController { }); } } + + /** + * DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent) + */ + async hardDeleteUser( + request: FastifyRequest<{ Params: UserAuth0SubInput }>, + reply: FastifyReply + ) { + try { + const actorId = request.userContext?.userId; + if (!actorId) { + return reply.code(401).send({ + error: 'Unauthorized', + message: 'User context missing', + }); + } + + // Validate path param + const paramsResult = userAuth0SubSchema.safeParse(request.params); + if (!paramsResult.success) { + return reply.code(400).send({ + error: 'Validation error', + message: paramsResult.error.errors.map(e => e.message).join(', '), + }); + } + + const { auth0Sub } = paramsResult.data; + + // Optional reason from query params + const reason = (request.query as any)?.reason; + + // Hard delete user + await this.userProfileService.adminHardDeleteUser( + auth0Sub, + actorId, + reason + ); + + return reply.code(200).send({ + message: 'User permanently deleted', + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + logger.error('Error hard deleting user', { + error: errorMessage, + auth0Sub: request.params?.auth0Sub, + }); + + if (errorMessage === 'Cannot delete your own account') { + return reply.code(400).send({ + error: 'Bad request', + message: errorMessage, + }); + } + + if (errorMessage === 'User not found') { + return reply.code(404).send({ + error: 'Not found', + message: errorMessage, + }); + } + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to delete user', + }); + } + } } diff --git a/backend/src/features/auth/README.md b/backend/src/features/auth/README.md new file mode 100644 index 0000000..8cb01b8 --- /dev/null +++ b/backend/src/features/auth/README.md @@ -0,0 +1,162 @@ +# Auth Feature + +User signup and email verification workflow using Auth0. + +## Overview + +This feature provides API endpoints for user registration and email verification management. It integrates with Auth0 for authentication and manages user profiles in the local database. + +## Architecture + +- **API Layer**: Controllers and routes for HTTP request/response handling +- **Domain Layer**: Business logic in AuthService +- **Integration**: Auth0 Management API client and UserProfileRepository + +## API Endpoints + +### POST /api/auth/signup (Public) +Create a new user account. Auth0 automatically sends verification email upon account creation. + +**Request:** +```json +{ + "email": "user@example.com", + "password": "Password123" +} +``` + +**Validation:** +- Email: Valid email format required +- Password: Minimum 8 characters, at least one uppercase letter and one number + +**Response (201 Created):** +```json +{ + "userId": "auth0|123456", + "email": "user@example.com", + "message": "Account created successfully. Please check your email to verify your account." +} +``` + +**Error Responses:** +- 400: Invalid email or weak password +- 409: Email already exists +- 500: Auth0 API error or database error + +--- + +### GET /api/auth/verify-status (Protected) +Check email verification status. Updates local database if status changed in Auth0. + +**Authentication:** Requires JWT + +**Response (200 OK):** +```json +{ + "emailVerified": true, + "email": "user@example.com" +} +``` + +**Error Responses:** +- 401: Unauthorized (no JWT or invalid JWT) +- 500: Auth0 API error + +--- + +### POST /api/auth/resend-verification (Protected) +Resend email verification. Skips if email is already verified. + +**Authentication:** Requires JWT + +**Response (200 OK):** +```json +{ + "message": "Verification email sent. Please check your inbox." +} +``` + +or if already verified: + +```json +{ + "message": "Email is already verified" +} +``` + +**Error Responses:** +- 401: Unauthorized (no JWT or invalid JWT) +- 500: Auth0 API error + +## Business Logic + +### Signup Flow +1. Validate email and password format +2. Create user in Auth0 via Management API +3. Auth0 automatically sends verification email +4. Create local user profile with `emailVerified=false` +5. Return success with user ID + +### Verify Status Flow +1. Extract Auth0 user ID from JWT +2. Query Auth0 Management API for `email_verified` status +3. Update local database if status changed +4. Return current verification status + +### Resend Verification Flow +1. Extract Auth0 user ID from JWT +2. Check if already verified (skip if true) +3. Call Auth0 Management API to resend verification email +4. Return success message + +## Integration Points + +- **Auth0 Management API**: User creation, verification status, resend email +- **User Profile Repository**: Local user profile management +- **Core Logger**: Structured logging for all operations + +## Error Handling + +- **Validation errors** (400): Invalid email format, weak password +- **Conflict errors** (409): Email already exists in Auth0 +- **Unauthorized** (401): Missing or invalid JWT for protected endpoints +- **Server errors** (500): Auth0 API failures, database errors + +## Testing + +### Unit Tests +Location: `tests/unit/auth.service.test.ts` + +Tests business logic with mocked Auth0 client and repository: +- User creation success and failure scenarios +- Email verification status retrieval and updates +- Resend verification logic + +### Integration Tests +Location: `tests/integration/auth.integration.test.ts` + +Tests complete API workflows with test database: +- Signup with valid and invalid inputs +- Verification status checks +- Resend verification email + +Run tests: +```bash +npm test -- features/auth +``` + +## Dependencies + +- Auth0 Management API client (`core/auth/auth0-management.client.ts`) +- UserProfileRepository (`features/user-profile/data/user-profile.repository.ts`) +- Core logger (`core/logging/logger.ts`) +- Database pool (`core/config/database.ts`) + +## Configuration + +Auth0 Management API credentials are configured via environment variables: +- `AUTH0_DOMAIN` +- `AUTH0_MANAGEMENT_CLIENT_ID` +- `AUTH0_MANAGEMENT_CLIENT_SECRET` + +See `core/config/config-loader.ts` for configuration details. diff --git a/backend/src/features/auth/api/auth.controller.ts b/backend/src/features/auth/api/auth.controller.ts new file mode 100644 index 0000000..83eb666 --- /dev/null +++ b/backend/src/features/auth/api/auth.controller.ts @@ -0,0 +1,122 @@ +/** + * @ai-summary Fastify route handlers for auth API + * @ai-context HTTP request/response handling with Fastify reply methods + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { AuthService } from '../domain/auth.service'; +import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; +import { pool } from '../../../core/config/database'; +import { logger } from '../../../core/logging/logger'; +import { signupSchema } from './auth.validation'; + +export class AuthController { + private authService: AuthService; + + constructor() { + const userProfileRepository = new UserProfileRepository(pool); + this.authService = new AuthService(userProfileRepository); + } + + /** + * POST /api/auth/signup + * Create new user account + * Public endpoint - no JWT required + */ + async signup(request: FastifyRequest, reply: FastifyReply) { + try { + const validation = signupSchema.safeParse(request.body); + if (!validation.success) { + return reply.code(400).send({ + error: 'Validation error', + message: validation.error.errors[0]?.message || 'Invalid input', + details: validation.error.errors, + }); + } + + const { email, password } = validation.data; + + const result = await this.authService.signup({ email, password }); + + logger.info('User signup successful', { email, userId: result.userId }); + + return reply.code(201).send(result); + } catch (error: any) { + logger.error('Signup failed', { error, email: (request.body as any)?.email }); + + if (error.message === 'Email already exists') { + return reply.code(409).send({ + error: 'Conflict', + message: 'An account with this email already exists', + }); + } + + // Auth0 API errors + if (error.message?.includes('Auth0')) { + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to create account. Please try again later.', + }); + } + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to create account', + }); + } + } + + /** + * GET /api/auth/verify-status + * Check email verification status + * Protected endpoint - requires JWT + */ + async getVerifyStatus(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = (request as any).user.sub; + + const result = await this.authService.getVerifyStatus(userId); + + logger.info('Verification status checked', { userId, emailVerified: result.emailVerified }); + + return reply.code(200).send(result); + } catch (error: any) { + logger.error('Failed to get verification status', { + error, + userId: (request as any).user?.sub, + }); + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to check verification status', + }); + } + } + + /** + * POST /api/auth/resend-verification + * Resend verification email + * Protected endpoint - requires JWT + */ + async resendVerification(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = (request as any).user.sub; + + const result = await this.authService.resendVerification(userId); + + logger.info('Verification email resent', { userId }); + + return reply.code(200).send(result); + } catch (error: any) { + logger.error('Failed to resend verification email', { + error, + userId: (request as any).user?.sub, + }); + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to resend verification email', + }); + } + } +} diff --git a/backend/src/features/auth/api/auth.routes.ts b/backend/src/features/auth/api/auth.routes.ts new file mode 100644 index 0000000..ba213e7 --- /dev/null +++ b/backend/src/features/auth/api/auth.routes.ts @@ -0,0 +1,30 @@ +/** + * @ai-summary Fastify routes for auth API + * @ai-context Route definitions with Zod validation and authentication + */ + +import { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import { FastifyPluginAsync } from 'fastify'; +import { AuthController } from './auth.controller'; + +export const authRoutes: FastifyPluginAsync = async ( + fastify: FastifyInstance, + _opts: FastifyPluginOptions +) => { + const authController = new AuthController(); + + // POST /api/auth/signup - Create new user (public, no JWT required) + fastify.post('/auth/signup', authController.signup.bind(authController)); + + // GET /api/auth/verify-status - Check verification status (requires JWT) + fastify.get('/auth/verify-status', { + preHandler: [fastify.authenticate], + handler: authController.getVerifyStatus.bind(authController), + }); + + // POST /api/auth/resend-verification - Resend verification email (requires JWT) + fastify.post('/auth/resend-verification', { + preHandler: [fastify.authenticate], + handler: authController.resendVerification.bind(authController), + }); +}; diff --git a/backend/src/features/auth/api/auth.validation.ts b/backend/src/features/auth/api/auth.validation.ts new file mode 100644 index 0000000..f51694b --- /dev/null +++ b/backend/src/features/auth/api/auth.validation.ts @@ -0,0 +1,23 @@ +/** + * @ai-summary Request validation schemas for auth API + * @ai-context Uses Zod for runtime validation and type safety + */ + +import { z } from 'zod'; + +// Password requirements: +// - Minimum 8 characters +// - At least one uppercase letter +// - At least one number +const passwordSchema = z + .string() + .min(8, 'Password must be at least 8 characters long') + .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') + .regex(/[0-9]/, 'Password must contain at least one number'); + +export const signupSchema = z.object({ + email: z.string().email('Invalid email format'), + password: passwordSchema, +}); + +export type SignupInput = z.infer; diff --git a/backend/src/features/auth/domain/auth.service.ts b/backend/src/features/auth/domain/auth.service.ts new file mode 100644 index 0000000..62645cc --- /dev/null +++ b/backend/src/features/auth/domain/auth.service.ts @@ -0,0 +1,130 @@ +/** + * @ai-summary Auth service business logic + * @ai-context Coordinates between Auth0 Management API and local user profile database + */ + +import { auth0ManagementClient } from '../../../core/auth/auth0-management.client'; +import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; +import { logger } from '../../../core/logging/logger'; +import { + SignupRequest, + SignupResponse, + VerifyStatusResponse, + ResendVerificationResponse, +} from './auth.types'; + +export class AuthService { + constructor(private userProfileRepository: UserProfileRepository) {} + + /** + * Create a new user account + * 1. Create user in Auth0 (which automatically sends verification email) + * 2. Create local user profile with emailVerified=false + */ + async signup(request: SignupRequest): Promise { + const { email, password } = request; + + try { + // Create user in Auth0 Management API + // Auth0 automatically sends verification email on user creation + const auth0UserId = await auth0ManagementClient.createUser({ + email, + password, + }); + + logger.info('Auth0 user created', { auth0UserId, email }); + + // Create local user profile + const userProfile = await this.userProfileRepository.create( + auth0UserId, + email, + undefined // displayName is optional + ); + + logger.info('User profile created', { userId: userProfile.id, email }); + + return { + userId: auth0UserId, + email, + message: 'Account created successfully. Please check your email to verify your account.', + }; + } catch (error) { + logger.error('Signup failed', { email, error }); + + // Check for duplicate email error from Auth0 + if (error instanceof Error && error.message.includes('already exists')) { + throw new Error('Email already exists'); + } + + throw error; + } + } + + /** + * Check email verification status + * Queries Auth0 for current verification status and updates local database if changed + */ + async getVerifyStatus(auth0Sub: string): Promise { + try { + // Get user details from Auth0 + const userDetails = await auth0ManagementClient.getUser(auth0Sub); + + logger.info('Retrieved user verification status from Auth0', { + auth0Sub, + emailVerified: userDetails.emailVerified, + }); + + // Update local database if verification status changed + const localProfile = await this.userProfileRepository.getByAuth0Sub(auth0Sub); + + if (localProfile && localProfile.emailVerified !== userDetails.emailVerified) { + await this.userProfileRepository.updateEmailVerified( + auth0Sub, + userDetails.emailVerified + ); + logger.info('Local email verification status updated', { + auth0Sub, + emailVerified: userDetails.emailVerified, + }); + } + + return { + emailVerified: userDetails.emailVerified, + email: userDetails.email, + }; + } catch (error) { + logger.error('Failed to get verification status', { auth0Sub, error }); + throw error; + } + } + + /** + * Resend verification email + * Calls Auth0 Management API to trigger verification email + */ + async resendVerification(auth0Sub: string): Promise { + try { + // Check if already verified + const verified = await auth0ManagementClient.checkEmailVerified(auth0Sub); + + if (verified) { + logger.info('Email already verified, skipping resend', { auth0Sub }); + return { + message: 'Email is already verified', + }; + } + + // Request Auth0 to resend verification email + await auth0ManagementClient.resendVerificationEmail(auth0Sub); + + logger.info('Verification email resent', { auth0Sub }); + + return { + message: 'Verification email sent. Please check your inbox.', + }; + } catch (error) { + logger.error('Failed to resend verification email', { auth0Sub, error }); + throw error; + } + } +} diff --git a/backend/src/features/auth/domain/auth.types.ts b/backend/src/features/auth/domain/auth.types.ts new file mode 100644 index 0000000..48a0a6e --- /dev/null +++ b/backend/src/features/auth/domain/auth.types.ts @@ -0,0 +1,28 @@ +/** + * @ai-summary Auth domain types + * @ai-context Type definitions for authentication and signup workflows + */ + +// Request to create a new user account +export interface SignupRequest { + email: string; + password: string; +} + +// Response from signup endpoint +export interface SignupResponse { + userId: string; + email: string; + message: string; +} + +// Response from verify status endpoint +export interface VerifyStatusResponse { + emailVerified: boolean; + email: string; +} + +// Response from resend verification endpoint +export interface ResendVerificationResponse { + message: string; +} diff --git a/backend/src/features/auth/index.ts b/backend/src/features/auth/index.ts new file mode 100644 index 0000000..6ce56b2 --- /dev/null +++ b/backend/src/features/auth/index.ts @@ -0,0 +1,18 @@ +/** + * @ai-summary Public API for auth feature capsule + * @ai-note This is the ONLY file other features should import from + */ + +// Export service for use by other features (if needed) +export { AuthService } from './domain/auth.service'; + +// Export types needed by other features +export type { + SignupRequest, + SignupResponse, + VerifyStatusResponse, + ResendVerificationResponse, +} from './domain/auth.types'; + +// Internal: Register routes with Fastify app +export { authRoutes } from './api/auth.routes'; diff --git a/backend/src/features/auth/tests/integration/auth.integration.test.ts b/backend/src/features/auth/tests/integration/auth.integration.test.ts new file mode 100644 index 0000000..e783ed2 --- /dev/null +++ b/backend/src/features/auth/tests/integration/auth.integration.test.ts @@ -0,0 +1,214 @@ +/** + * @ai-summary Integration tests for auth API endpoints + * @ai-context Tests complete request/response cycle with mocked Auth0 + */ + +import request from 'supertest'; +import { buildApp } from '../../../../app'; +import { pool } from '../../../../core/config/database'; +import { auth0ManagementClient } from '../../../../core/auth/auth0-management.client'; +import fastifyPlugin from 'fastify-plugin'; +import { FastifyInstance } from 'fastify'; + +// Mock Auth0 Management client +jest.mock('../../../../core/auth/auth0-management.client'); +const mockAuth0Client = jest.mocked(auth0ManagementClient); + +// Mock auth plugin for protected routes +jest.mock('../../../../core/plugins/auth.plugin', () => { + return { + default: fastifyPlugin(async function (fastify) { + fastify.decorate('authenticate', async function (request, _reply) { + request.user = { sub: 'auth0|test-user-123' }; + }); + }, { name: 'auth-plugin' }), + }; +}); + +describe('Auth Integration Tests', () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + // Ensure user_profiles table exists (should be created by migrations) + // We don't need to run migration here as it should already exist + }); + + afterAll(async () => { + await app.close(); + await pool.end(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + // Clean up test data before each test + await pool.query('DELETE FROM user_profiles WHERE auth0_sub = $1', [ + 'auth0|test-user-123', + ]); + await pool.query('DELETE FROM user_profiles WHERE email = $1', [ + 'newuser@example.com', + ]); + }); + + describe('POST /api/auth/signup', () => { + it('should create a new user successfully', async () => { + const email = 'newuser@example.com'; + const password = 'Password123'; + const auth0UserId = 'auth0|new-user-456'; + + mockAuth0Client.createUser.mockResolvedValue(auth0UserId); + + const response = await request(app.server) + .post('/api/auth/signup') + .send({ email, password }) + .expect(201); + + expect(response.body).toMatchObject({ + userId: auth0UserId, + email, + message: expect.stringContaining('check your email'), + }); + + expect(mockAuth0Client.createUser).toHaveBeenCalledWith({ email, password }); + + // Verify user was created in local database + const userResult = await pool.query( + 'SELECT * FROM user_profiles WHERE auth0_sub = $1', + [auth0UserId] + ); + expect(userResult.rows).toHaveLength(1); + expect(userResult.rows[0].email).toBe(email); + expect(userResult.rows[0].email_verified).toBe(false); + }); + + it('should reject signup with invalid email', async () => { + const response = await request(app.server) + .post('/api/auth/signup') + .send({ email: 'invalid-email', password: 'Password123' }) + .expect(400); + + expect(response.body.message).toContain('validation'); + }); + + it('should reject signup with weak password', async () => { + const response = await request(app.server) + .post('/api/auth/signup') + .send({ email: 'test@example.com', password: 'weak' }) + .expect(400); + + expect(response.body.message).toContain('validation'); + }); + + it('should reject signup when email already exists', async () => { + const email = 'existing@example.com'; + const password = 'Password123'; + + mockAuth0Client.createUser.mockRejectedValue( + new Error('User already exists') + ); + + const response = await request(app.server) + .post('/api/auth/signup') + .send({ email, password }) + .expect(409); + + expect(response.body.error).toBe('Conflict'); + expect(response.body.message).toContain('already exists'); + }); + }); + + describe('GET /api/auth/verify-status', () => { + beforeEach(async () => { + // Create a test user profile + await pool.query( + 'INSERT INTO user_profiles (auth0_sub, email, email_verified) VALUES ($1, $2, $3)', + ['auth0|test-user-123', 'test@example.com', false] + ); + }); + + it('should return verification status', async () => { + const email = 'test@example.com'; + + mockAuth0Client.getUser.mockResolvedValue({ + userId: 'auth0|test-user-123', + email, + emailVerified: false, + }); + + const response = await request(app.server) + .get('/api/auth/verify-status') + .expect(200); + + expect(response.body).toMatchObject({ + emailVerified: false, + email, + }); + + expect(mockAuth0Client.getUser).toHaveBeenCalledWith('auth0|test-user-123'); + }); + + it('should update local database when verification status changes', async () => { + const email = 'test@example.com'; + + mockAuth0Client.getUser.mockResolvedValue({ + userId: 'auth0|test-user-123', + email, + emailVerified: true, + }); + + const response = await request(app.server) + .get('/api/auth/verify-status') + .expect(200); + + expect(response.body.emailVerified).toBe(true); + + // Verify local database was updated + const userResult = await pool.query( + 'SELECT email_verified FROM user_profiles WHERE auth0_sub = $1', + ['auth0|test-user-123'] + ); + expect(userResult.rows[0].email_verified).toBe(true); + }); + }); + + describe('POST /api/auth/resend-verification', () => { + beforeEach(async () => { + // Create a test user profile + await pool.query( + 'INSERT INTO user_profiles (auth0_sub, email, email_verified) VALUES ($1, $2, $3)', + ['auth0|test-user-123', 'test@example.com', false] + ); + }); + + it('should resend verification email when user is not verified', async () => { + mockAuth0Client.checkEmailVerified.mockResolvedValue(false); + mockAuth0Client.resendVerificationEmail.mockResolvedValue(undefined); + + const response = await request(app.server) + .post('/api/auth/resend-verification') + .expect(200); + + expect(response.body.message).toContain('Verification email sent'); + expect(mockAuth0Client.checkEmailVerified).toHaveBeenCalledWith( + 'auth0|test-user-123' + ); + expect(mockAuth0Client.resendVerificationEmail).toHaveBeenCalledWith( + 'auth0|test-user-123' + ); + }); + + it('should skip sending email when user is already verified', async () => { + mockAuth0Client.checkEmailVerified.mockResolvedValue(true); + + const response = await request(app.server) + .post('/api/auth/resend-verification') + .expect(200); + + expect(response.body.message).toContain('already verified'); + expect(mockAuth0Client.resendVerificationEmail).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/features/auth/tests/unit/auth.service.test.ts b/backend/src/features/auth/tests/unit/auth.service.test.ts new file mode 100644 index 0000000..c698f75 --- /dev/null +++ b/backend/src/features/auth/tests/unit/auth.service.test.ts @@ -0,0 +1,205 @@ +/** + * @ai-summary Unit tests for AuthService + * @ai-context Tests business logic with mocked dependencies + */ + +import { AuthService } from '../../domain/auth.service'; +import { UserProfileRepository } from '../../../user-profile/data/user-profile.repository'; +import { auth0ManagementClient } from '../../../../core/auth/auth0-management.client'; + +// Mock dependencies +jest.mock('../../../user-profile/data/user-profile.repository'); +jest.mock('../../../../core/auth/auth0-management.client'); + +const mockUserProfileRepository = jest.mocked(UserProfileRepository); +const mockAuth0Client = jest.mocked(auth0ManagementClient); + +describe('AuthService', () => { + let service: AuthService; + let repositoryInstance: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + repositoryInstance = { + create: jest.fn(), + getByAuth0Sub: jest.fn(), + updateEmailVerified: jest.fn(), + } as any; + + mockUserProfileRepository.mockImplementation(() => repositoryInstance); + service = new AuthService(repositoryInstance); + }); + + describe('signup', () => { + it('creates user in Auth0 and local database successfully', async () => { + const email = 'test@example.com'; + const password = 'Password123'; + const auth0UserId = 'auth0|123456'; + + mockAuth0Client.createUser.mockResolvedValue(auth0UserId); + repositoryInstance.create.mockResolvedValue({ + id: 'user-uuid', + auth0Sub: auth0UserId, + email, + subscriptionTier: 'free', + emailVerified: false, + onboardingCompletedAt: null, + deactivatedAt: null, + deactivatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.signup({ email, password }); + + expect(mockAuth0Client.createUser).toHaveBeenCalledWith({ email, password }); + expect(repositoryInstance.create).toHaveBeenCalledWith(auth0UserId, email, undefined); + expect(result).toEqual({ + userId: auth0UserId, + email, + message: 'Account created successfully. Please check your email to verify your account.', + }); + }); + + it('throws error when email already exists in Auth0', async () => { + const email = 'existing@example.com'; + const password = 'Password123'; + + mockAuth0Client.createUser.mockRejectedValue( + new Error('User already exists') + ); + + await expect(service.signup({ email, password })).rejects.toThrow('Email already exists'); + }); + + it('propagates other Auth0 errors', async () => { + const email = 'test@example.com'; + const password = 'Password123'; + + mockAuth0Client.createUser.mockRejectedValue(new Error('Auth0 API error')); + + await expect(service.signup({ email, password })).rejects.toThrow('Auth0 API error'); + }); + }); + + describe('getVerifyStatus', () => { + it('returns verification status from Auth0 and updates local database', async () => { + const auth0Sub = 'auth0|123456'; + const email = 'test@example.com'; + + mockAuth0Client.getUser.mockResolvedValue({ + userId: auth0Sub, + email, + emailVerified: true, + }); + + repositoryInstance.getByAuth0Sub.mockResolvedValue({ + id: 'user-uuid', + auth0Sub, + email, + subscriptionTier: 'free', + emailVerified: false, + onboardingCompletedAt: null, + deactivatedAt: null, + deactivatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + repositoryInstance.updateEmailVerified.mockResolvedValue({ + id: 'user-uuid', + auth0Sub, + email, + subscriptionTier: 'free', + emailVerified: true, + onboardingCompletedAt: null, + deactivatedAt: null, + deactivatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.getVerifyStatus(auth0Sub); + + expect(mockAuth0Client.getUser).toHaveBeenCalledWith(auth0Sub); + expect(repositoryInstance.updateEmailVerified).toHaveBeenCalledWith(auth0Sub, true); + expect(result).toEqual({ + emailVerified: true, + email, + }); + }); + + it('does not update local database if verification status unchanged', async () => { + const auth0Sub = 'auth0|123456'; + const email = 'test@example.com'; + + mockAuth0Client.getUser.mockResolvedValue({ + userId: auth0Sub, + email, + emailVerified: false, + }); + + repositoryInstance.getByAuth0Sub.mockResolvedValue({ + id: 'user-uuid', + auth0Sub, + email, + subscriptionTier: 'free', + emailVerified: false, + onboardingCompletedAt: null, + deactivatedAt: null, + deactivatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.getVerifyStatus(auth0Sub); + + expect(mockAuth0Client.getUser).toHaveBeenCalledWith(auth0Sub); + expect(repositoryInstance.updateEmailVerified).not.toHaveBeenCalled(); + expect(result).toEqual({ + emailVerified: false, + email, + }); + }); + }); + + describe('resendVerification', () => { + it('sends verification email when user is not verified', async () => { + const auth0Sub = 'auth0|123456'; + + mockAuth0Client.checkEmailVerified.mockResolvedValue(false); + mockAuth0Client.resendVerificationEmail.mockResolvedValue(undefined); + + const result = await service.resendVerification(auth0Sub); + + expect(mockAuth0Client.checkEmailVerified).toHaveBeenCalledWith(auth0Sub); + expect(mockAuth0Client.resendVerificationEmail).toHaveBeenCalledWith(auth0Sub); + expect(result).toEqual({ + message: 'Verification email sent. Please check your inbox.', + }); + }); + + it('skips sending email when user is already verified', async () => { + const auth0Sub = 'auth0|123456'; + + mockAuth0Client.checkEmailVerified.mockResolvedValue(true); + + const result = await service.resendVerification(auth0Sub); + + expect(mockAuth0Client.checkEmailVerified).toHaveBeenCalledWith(auth0Sub); + expect(mockAuth0Client.resendVerificationEmail).not.toHaveBeenCalled(); + expect(result).toEqual({ + message: 'Email is already verified', + }); + }); + + it('propagates Auth0 errors', async () => { + const auth0Sub = 'auth0|123456'; + + mockAuth0Client.checkEmailVerified.mockRejectedValue(new Error('Auth0 API error')); + + await expect(service.resendVerification(auth0Sub)).rejects.toThrow('Auth0 API error'); + }); + }); +}); diff --git a/backend/src/features/onboarding/README.md b/backend/src/features/onboarding/README.md new file mode 100644 index 0000000..c6f3b45 --- /dev/null +++ b/backend/src/features/onboarding/README.md @@ -0,0 +1,186 @@ +# Onboarding Feature Module + +## Overview +The onboarding feature manages the user signup workflow after email verification. It handles user preference setup (unit system, currency, timezone) and tracks onboarding completion status. + +## Purpose +After a user verifies their email with Auth0, they go through an onboarding flow to: +1. Set their preferences (unit system, currency, timezone) +2. Optionally add their first vehicle (handled by vehicles feature) +3. Mark onboarding as complete + +## API Endpoints + +All endpoints require JWT authentication via `fastify.authenticate` preHandler. + +### POST `/api/onboarding/preferences` +Save user preferences during onboarding. + +**Request Body:** +```json +{ + "unitSystem": "imperial", + "currencyCode": "USD", + "timeZone": "America/New_York" +} +``` + +**Validation:** +- `unitSystem`: Must be either "imperial" or "metric" +- `currencyCode`: Must be exactly 3 uppercase letters (ISO 4217) +- `timeZone`: 1-100 characters (IANA timezone identifier) + +**Response (200):** +```json +{ + "success": true, + "preferences": { + "unitSystem": "imperial", + "currencyCode": "USD", + "timeZone": "America/New_York" + } +} +``` + +**Error Responses:** +- `404 Not Found` - User profile not found +- `500 Internal Server Error` - Failed to save preferences + +### POST `/api/onboarding/complete` +Mark onboarding as complete for the authenticated user. + +**Request Body:** None + +**Response (200):** +```json +{ + "success": true, + "completedAt": "2025-12-22T15:30:00.000Z" +} +``` + +**Error Responses:** +- `404 Not Found` - User profile not found +- `500 Internal Server Error` - Failed to complete onboarding + +### GET `/api/onboarding/status` +Get current onboarding status for the authenticated user. + +**Response (200):** +```json +{ + "preferencesSet": true, + "onboardingCompleted": true, + "onboardingCompletedAt": "2025-12-22T15:30:00.000Z" +} +``` + +**Response (200) - Incomplete:** +```json +{ + "preferencesSet": false, + "onboardingCompleted": false, + "onboardingCompletedAt": null +} +``` + +**Error Responses:** +- `404 Not Found` - User profile not found +- `500 Internal Server Error` - Failed to get status + +## Architecture + +### Directory Structure +``` +backend/src/features/onboarding/ +├── README.md # This file +├── index.ts # Public API exports +├── api/ # HTTP layer +│ ├── onboarding.controller.ts # Request handlers +│ ├── onboarding.routes.ts # Route definitions +│ └── onboarding.validation.ts # Zod schemas +├── domain/ # Business logic +│ ├── onboarding.service.ts # Core logic +│ └── onboarding.types.ts # Type definitions +└── tests/ # Tests (to be added) + ├── unit/ + └── integration/ +``` + +## Integration Points + +### Dependencies +- **UserProfileRepository** (`features/user-profile`) - For updating `onboarding_completed_at` +- **UserPreferencesRepository** (`core/user-preferences`) - For saving unit system, currency, timezone +- **Database Pool** (`core/config/database`) - PostgreSQL connection +- **Logger** (`core/logging/logger`) - Structured logging + +### Database Tables +- `user_profiles` - Stores `onboarding_completed_at` timestamp +- `user_preferences` - Stores `unit_system`, `currency_code`, `time_zone` + +## Business Logic + +### Save Preferences Flow +1. Extract Auth0 user ID from JWT +2. Fetch user profile to get internal user ID +3. Check if user_preferences record exists +4. If exists, update preferences; otherwise create new record +5. Return saved preferences + +### Complete Onboarding Flow +1. Extract Auth0 user ID from JWT +2. Call `UserProfileRepository.markOnboardingComplete(auth0Sub)` +3. Updates `onboarding_completed_at` to NOW() if not already set +4. Return completion timestamp + +### Get Status Flow +1. Extract Auth0 user ID from JWT +2. Fetch user profile +3. Check if user_preferences record exists +4. Return status object with: + - `preferencesSet`: boolean (preferences exist) + - `onboardingCompleted`: boolean (onboarding_completed_at is set) + - `onboardingCompletedAt`: timestamp or null + +## Key Behaviors + +### User Ownership +All operations are scoped to the authenticated user via JWT. No cross-user access is possible. + +### Idempotency +- `savePreferences` - Can be called multiple times; updates existing record +- `completeOnboarding` - Can be called multiple times; returns existing completion timestamp if already completed + +### Email Verification +These routes are in `VERIFICATION_EXEMPT_ROUTES` in `auth.plugin.ts` because users complete onboarding immediately after email verification. + +## Error Handling + +All errors are logged with structured logging: +```typescript +logger.error('Error saving onboarding preferences', { + error, + userId: auth0Sub.substring(0, 8) + '...', +}); +``` + +Controller methods return appropriate HTTP status codes: +- `200 OK` - Success +- `404 Not Found` - User profile not found +- `500 Internal Server Error` - Unexpected errors + +## Testing + +Tests to be added: +- Unit tests for `OnboardingService` with mocked repositories +- Integration tests for API endpoints with test database + +## Future Enhancements + +Potential improvements: +- Add validation for IANA timezone identifiers +- Add validation for ISO 4217 currency codes against known list +- Track onboarding step completion for analytics +- Add onboarding progress percentage +- Support for skipping onboarding (mark as complete without preferences) diff --git a/backend/src/features/onboarding/api/onboarding.controller.ts b/backend/src/features/onboarding/api/onboarding.controller.ts new file mode 100644 index 0000000..ce35c50 --- /dev/null +++ b/backend/src/features/onboarding/api/onboarding.controller.ts @@ -0,0 +1,143 @@ +/** + * @ai-summary Fastify route handlers for onboarding API + * @ai-context HTTP request/response handling for onboarding workflow + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { OnboardingService } from '../domain/onboarding.service'; +import { UserPreferencesRepository } from '../../../core/user-preferences/data/user-preferences.repository'; +import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; +import { pool } from '../../../core/config/database'; +import { logger } from '../../../core/logging/logger'; +import { SavePreferencesInput } from './onboarding.validation'; + +interface AuthenticatedRequest extends FastifyRequest { + user: { + sub: string; + [key: string]: unknown; + }; +} + +export class OnboardingController { + private onboardingService: OnboardingService; + + constructor() { + const userPreferencesRepo = new UserPreferencesRepository(pool); + const userProfileRepo = new UserProfileRepository(pool); + this.onboardingService = new OnboardingService(userPreferencesRepo, userProfileRepo); + } + + /** + * POST /api/onboarding/preferences + * Save user preferences during onboarding + */ + async savePreferences( + request: FastifyRequest<{ Body: SavePreferencesInput }>, + reply: FastifyReply + ) { + try { + const auth0Sub = (request as AuthenticatedRequest).user.sub; + + const preferences = await this.onboardingService.savePreferences( + auth0Sub, + request.body + ); + + return reply.code(200).send({ + success: true, + preferences, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error in savePreferences controller', { + error, + userId: (request as AuthenticatedRequest).user?.sub, + }); + + if (errorMessage === 'User profile not found') { + return reply.code(404).send({ + error: 'Not Found', + message: 'User profile not found', + }); + } + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to save preferences', + }); + } + } + + /** + * POST /api/onboarding/complete + * Mark onboarding as complete + */ + async completeOnboarding(request: FastifyRequest, reply: FastifyReply) { + try { + const auth0Sub = (request as AuthenticatedRequest).user.sub; + + const completedAt = await this.onboardingService.completeOnboarding(auth0Sub); + + return reply.code(200).send({ + success: true, + completedAt: completedAt.toISOString(), + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error in completeOnboarding controller', { + error, + userId: (request as AuthenticatedRequest).user?.sub, + }); + + if (errorMessage === 'User profile not found') { + return reply.code(404).send({ + error: 'Not Found', + message: 'User profile not found', + }); + } + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to complete onboarding', + }); + } + } + + /** + * GET /api/onboarding/status + * Get current onboarding status + */ + async getStatus(request: FastifyRequest, reply: FastifyReply) { + try { + const auth0Sub = (request as AuthenticatedRequest).user.sub; + + const status = await this.onboardingService.getOnboardingStatus(auth0Sub); + + return reply.code(200).send({ + preferencesSet: status.preferencesSet, + onboardingCompleted: status.onboardingCompleted, + onboardingCompletedAt: status.onboardingCompletedAt + ? status.onboardingCompletedAt.toISOString() + : null, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error in getStatus controller', { + error, + userId: (request as AuthenticatedRequest).user?.sub, + }); + + if (errorMessage === 'User profile not found') { + return reply.code(404).send({ + error: 'Not Found', + message: 'User profile not found', + }); + } + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to get onboarding status', + }); + } + } +} diff --git a/backend/src/features/onboarding/api/onboarding.routes.ts b/backend/src/features/onboarding/api/onboarding.routes.ts new file mode 100644 index 0000000..a863e60 --- /dev/null +++ b/backend/src/features/onboarding/api/onboarding.routes.ts @@ -0,0 +1,33 @@ +/** + * @ai-summary Fastify routes for onboarding API + * @ai-context Route definitions for user onboarding workflow + */ + +import { FastifyInstance, FastifyPluginOptions, FastifyPluginAsync } from 'fastify'; +import { OnboardingController } from './onboarding.controller'; +import { SavePreferencesInput } from './onboarding.validation'; + +export const onboardingRoutes: FastifyPluginAsync = async ( + fastify: FastifyInstance, + _opts: FastifyPluginOptions +) => { + const onboardingController = new OnboardingController(); + + // POST /api/onboarding/preferences - Save user preferences + fastify.post<{ Body: SavePreferencesInput }>('/onboarding/preferences', { + preHandler: [fastify.authenticate], + handler: onboardingController.savePreferences.bind(onboardingController), + }); + + // POST /api/onboarding/complete - Mark onboarding as complete + fastify.post('/onboarding/complete', { + preHandler: [fastify.authenticate], + handler: onboardingController.completeOnboarding.bind(onboardingController), + }); + + // GET /api/onboarding/status - Get onboarding status + fastify.get('/onboarding/status', { + preHandler: [fastify.authenticate], + handler: onboardingController.getStatus.bind(onboardingController), + }); +}; diff --git a/backend/src/features/onboarding/api/onboarding.validation.ts b/backend/src/features/onboarding/api/onboarding.validation.ts new file mode 100644 index 0000000..51b35a3 --- /dev/null +++ b/backend/src/features/onboarding/api/onboarding.validation.ts @@ -0,0 +1,21 @@ +/** + * @ai-summary Request validation schemas for onboarding API + * @ai-context Uses Zod for runtime validation and type safety + */ + +import { z } from 'zod'; + +export const savePreferencesSchema = z.object({ + unitSystem: z.enum(['imperial', 'metric'], { + errorMap: () => ({ message: 'Unit system must be either "imperial" or "metric"' }) + }), + currencyCode: z.string() + .length(3, 'Currency code must be exactly 3 characters (ISO 4217)') + .toUpperCase() + .regex(/^[A-Z]{3}$/, 'Currency code must be 3 uppercase letters'), + timeZone: z.string() + .min(1, 'Time zone is required') + .max(100, 'Time zone must not exceed 100 characters') +}).strict(); + +export type SavePreferencesInput = z.infer; diff --git a/backend/src/features/onboarding/domain/onboarding.service.ts b/backend/src/features/onboarding/domain/onboarding.service.ts new file mode 100644 index 0000000..8d96b77 --- /dev/null +++ b/backend/src/features/onboarding/domain/onboarding.service.ts @@ -0,0 +1,155 @@ +/** + * @ai-summary Business logic for user onboarding workflow + * @ai-context Coordinates user preferences and profile updates during onboarding + */ + +import { UserPreferencesRepository } from '../../../core/user-preferences/data/user-preferences.repository'; +import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; +import { logger } from '../../../core/logging/logger'; +import { + OnboardingPreferences, + OnboardingStatus, + SavePreferencesRequest, +} from './onboarding.types'; + +export class OnboardingService { + constructor( + private userPreferencesRepo: UserPreferencesRepository, + private userProfileRepo: UserProfileRepository + ) {} + + /** + * Save user preferences during onboarding + */ + async savePreferences( + auth0Sub: string, + request: SavePreferencesRequest + ): Promise { + try { + // Validate required fields (should be guaranteed by Zod validation) + if (!request.unitSystem || !request.currencyCode || !request.timeZone) { + throw new Error('Missing required fields: unitSystem, currencyCode, timeZone'); + } + + logger.info('Saving onboarding preferences', { + userId: auth0Sub.substring(0, 8) + '...', + unitSystem: request.unitSystem, + currencyCode: request.currencyCode, + timeZone: request.timeZone, + }); + + // Get user profile to get internal user ID + const profile = await this.userProfileRepo.getByAuth0Sub(auth0Sub); + if (!profile) { + throw new Error('User profile not found'); + } + + // Check if preferences already exist + const existingPrefs = await this.userPreferencesRepo.findByUserId(profile.id); + + let savedPrefs; + if (existingPrefs) { + // Update existing preferences + savedPrefs = await this.userPreferencesRepo.update(profile.id, { + unitSystem: request.unitSystem, + currencyCode: request.currencyCode, + timeZone: request.timeZone, + }); + } else { + // Create new preferences + savedPrefs = await this.userPreferencesRepo.create({ + userId: profile.id, + unitSystem: request.unitSystem, + currencyCode: request.currencyCode, + timeZone: request.timeZone, + }); + } + + if (!savedPrefs) { + throw new Error('Failed to save user preferences'); + } + + logger.info('Onboarding preferences saved successfully', { + userId: auth0Sub.substring(0, 8) + '...', + preferencesId: savedPrefs.id, + }); + + return { + unitSystem: savedPrefs.unitSystem, + currencyCode: savedPrefs.currencyCode, + timeZone: savedPrefs.timeZone, + }; + } catch (error) { + logger.error('Error saving onboarding preferences', { + error, + userId: auth0Sub.substring(0, 8) + '...', + }); + throw error; + } + } + + /** + * Mark onboarding as complete + */ + async completeOnboarding(auth0Sub: string): Promise { + try { + logger.info('Completing onboarding', { + userId: auth0Sub.substring(0, 8) + '...', + }); + + const profile = await this.userProfileRepo.markOnboardingComplete(auth0Sub); + + logger.info('Onboarding completed successfully', { + userId: auth0Sub.substring(0, 8) + '...', + completedAt: profile.onboardingCompletedAt, + }); + + return profile.onboardingCompletedAt!; + } catch (error) { + logger.error('Error completing onboarding', { + error, + userId: auth0Sub.substring(0, 8) + '...', + }); + throw error; + } + } + + /** + * Get current onboarding status + */ + async getOnboardingStatus(auth0Sub: string): Promise { + try { + logger.info('Getting onboarding status', { + userId: auth0Sub.substring(0, 8) + '...', + }); + + // Get user profile + const profile = await this.userProfileRepo.getByAuth0Sub(auth0Sub); + if (!profile) { + throw new Error('User profile not found'); + } + + // Check if preferences exist + const preferences = await this.userPreferencesRepo.findByUserId(profile.id); + + const status: OnboardingStatus = { + preferencesSet: !!preferences, + onboardingCompleted: !!profile.onboardingCompletedAt, + onboardingCompletedAt: profile.onboardingCompletedAt, + }; + + logger.info('Retrieved onboarding status', { + userId: auth0Sub.substring(0, 8) + '...', + status, + }); + + return status; + } catch (error) { + logger.error('Error getting onboarding status', { + error, + userId: auth0Sub.substring(0, 8) + '...', + }); + throw error; + } + } +} diff --git a/backend/src/features/onboarding/domain/onboarding.types.ts b/backend/src/features/onboarding/domain/onboarding.types.ts new file mode 100644 index 0000000..05dc812 --- /dev/null +++ b/backend/src/features/onboarding/domain/onboarding.types.ts @@ -0,0 +1,40 @@ +/** + * @ai-summary Type definitions for onboarding feature + * @ai-context Manages user onboarding flow, preferences, and completion status + */ + +export type UnitSystem = 'imperial' | 'metric'; + +export interface OnboardingPreferences { + unitSystem: UnitSystem; + currencyCode: string; + timeZone: string; +} + +export interface OnboardingStatus { + preferencesSet: boolean; + onboardingCompleted: boolean; + onboardingCompletedAt: Date | null; +} + +export interface SavePreferencesRequest { + unitSystem?: UnitSystem; + currencyCode?: string; + timeZone?: string; +} + +export interface SavePreferencesResponse { + success: boolean; + preferences: OnboardingPreferences; +} + +export interface CompleteOnboardingResponse { + success: boolean; + completedAt: Date; +} + +export interface OnboardingStatusResponse { + preferencesSet: boolean; + onboardingCompleted: boolean; + onboardingCompletedAt: string | null; +} diff --git a/backend/src/features/onboarding/index.ts b/backend/src/features/onboarding/index.ts new file mode 100644 index 0000000..4766c6c --- /dev/null +++ b/backend/src/features/onboarding/index.ts @@ -0,0 +1,20 @@ +/** + * @ai-summary Public API for onboarding feature capsule + * @ai-context This is the ONLY file other features should import from + */ + +// Export service for use by other features +export { OnboardingService } from './domain/onboarding.service'; + +// Export types needed by other features +export type { + OnboardingPreferences, + OnboardingStatus, + SavePreferencesRequest, + SavePreferencesResponse, + CompleteOnboardingResponse, + OnboardingStatusResponse, +} from './domain/onboarding.types'; + +// Internal: Register routes with Fastify app +export { onboardingRoutes } from './api/onboarding.routes'; diff --git a/backend/src/features/user-profile/api/user-profile.controller.ts b/backend/src/features/user-profile/api/user-profile.controller.ts index 0881498..4bb89b3 100644 --- a/backend/src/features/user-profile/api/user-profile.controller.ts +++ b/backend/src/features/user-profile/api/user-profile.controller.ts @@ -6,16 +6,24 @@ import { FastifyRequest, FastifyReply } from 'fastify'; import { UserProfileService } from '../domain/user-profile.service'; import { UserProfileRepository } from '../data/user-profile.repository'; +import { AdminRepository } from '../../admin/data/admin.repository'; import { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; -import { UpdateProfileInput, updateProfileSchema } from './user-profile.validation'; +import { + UpdateProfileInput, + updateProfileSchema, + RequestDeletionInput, + requestDeletionSchema, +} from './user-profile.validation'; export class UserProfileController { private userProfileService: UserProfileService; constructor() { const repository = new UserProfileRepository(pool); + const adminRepository = new AdminRepository(pool); this.userProfileService = new UserProfileService(repository); + this.userProfileService.setAdminRepository(adminRepository); } /** @@ -121,4 +129,178 @@ export class UserProfileController { }); } } + + /** + * POST /api/user/delete - Request account deletion + */ + async requestDeletion( + request: FastifyRequest<{ Body: RequestDeletionInput }>, + reply: FastifyReply + ) { + try { + const auth0Sub = request.userContext?.userId; + + if (!auth0Sub) { + return reply.code(401).send({ + error: 'Unauthorized', + message: 'User context missing', + }); + } + + // Validate request body + const validation = requestDeletionSchema.safeParse(request.body); + if (!validation.success) { + return reply.code(400).send({ + error: 'Bad Request', + message: 'Invalid request body', + details: validation.error.errors, + }); + } + + const { password, confirmationText } = validation.data; + + // Request deletion + const profile = await this.userProfileService.requestDeletion( + auth0Sub, + password, + confirmationText + ); + + const deletionStatus = this.userProfileService.getDeletionStatus(profile); + + return reply.code(200).send({ + message: 'Account deletion requested successfully', + deletionStatus, + }); + } catch (error: any) { + logger.error('Error requesting account deletion', { + error: error.message, + userId: request.userContext?.userId, + }); + + if (error.message.includes('Invalid password')) { + return reply.code(401).send({ + error: 'Unauthorized', + message: 'Invalid password', + }); + } + + if (error.message.includes('Invalid confirmation')) { + return reply.code(400).send({ + error: 'Bad Request', + message: 'Confirmation text must be exactly "DELETE"', + }); + } + + if (error.message.includes('already requested')) { + return reply.code(400).send({ + error: 'Bad Request', + message: 'Account deletion already requested', + }); + } + + if (error.message.includes('not found')) { + return reply.code(404).send({ + error: 'Not Found', + message: 'User profile not found', + }); + } + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to request account deletion', + }); + } + } + + /** + * POST /api/user/cancel-deletion - Cancel account deletion + */ + async cancelDeletion(request: FastifyRequest, reply: FastifyReply) { + try { + const auth0Sub = request.userContext?.userId; + + if (!auth0Sub) { + return reply.code(401).send({ + error: 'Unauthorized', + message: 'User context missing', + }); + } + + // Cancel deletion + const profile = await this.userProfileService.cancelDeletion(auth0Sub); + + return reply.code(200).send({ + message: 'Account deletion canceled successfully', + profile, + }); + } catch (error: any) { + logger.error('Error canceling account deletion', { + error: error.message, + userId: request.userContext?.userId, + }); + + if (error.message.includes('no deletion request')) { + return reply.code(400).send({ + error: 'Bad Request', + message: 'No deletion request pending', + }); + } + + if (error.message.includes('not found')) { + return reply.code(404).send({ + error: 'Not Found', + message: 'User profile not found', + }); + } + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to cancel account deletion', + }); + } + } + + /** + * GET /api/user/deletion-status - Get deletion status + */ + async getDeletionStatus(request: FastifyRequest, reply: FastifyReply) { + try { + const auth0Sub = request.userContext?.userId; + + if (!auth0Sub) { + return reply.code(401).send({ + error: 'Unauthorized', + message: 'User context missing', + }); + } + + // Get user data from Auth0 token + const auth0User = { + sub: auth0Sub, + email: (request as any).user?.email || request.userContext?.email || '', + name: (request as any).user?.name, + }; + + // Get or create profile + const profile = await this.userProfileService.getOrCreateProfile( + auth0Sub, + auth0User + ); + + const deletionStatus = this.userProfileService.getDeletionStatus(profile); + + return reply.code(200).send(deletionStatus); + } catch (error: any) { + logger.error('Error getting deletion status', { + error: error.message, + userId: request.userContext?.userId, + }); + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to get deletion status', + }); + } + } } diff --git a/backend/src/features/user-profile/api/user-profile.routes.ts b/backend/src/features/user-profile/api/user-profile.routes.ts index e091a27..4656c67 100644 --- a/backend/src/features/user-profile/api/user-profile.routes.ts +++ b/backend/src/features/user-profile/api/user-profile.routes.ts @@ -5,7 +5,7 @@ import { FastifyPluginAsync } from 'fastify'; import { UserProfileController } from './user-profile.controller'; -import { UpdateProfileInput } from './user-profile.validation'; +import { UpdateProfileInput, RequestDeletionInput } from './user-profile.validation'; export const userProfileRoutes: FastifyPluginAsync = async (fastify) => { const userProfileController = new UserProfileController(); @@ -21,4 +21,22 @@ export const userProfileRoutes: FastifyPluginAsync = async (fastify) => { preHandler: [fastify.authenticate], handler: userProfileController.updateProfile.bind(userProfileController), }); + + // POST /api/user/delete - Request account deletion + fastify.post<{ Body: RequestDeletionInput }>('/user/delete', { + preHandler: [fastify.authenticate], + handler: userProfileController.requestDeletion.bind(userProfileController), + }); + + // POST /api/user/cancel-deletion - Cancel account deletion + fastify.post('/user/cancel-deletion', { + preHandler: [fastify.authenticate], + handler: userProfileController.cancelDeletion.bind(userProfileController), + }); + + // GET /api/user/deletion-status - Get deletion status + fastify.get('/user/deletion-status', { + preHandler: [fastify.authenticate], + handler: userProfileController.getDeletionStatus.bind(userProfileController), + }); }; diff --git a/backend/src/features/user-profile/api/user-profile.validation.ts b/backend/src/features/user-profile/api/user-profile.validation.ts index 4fb06d8..bcf78e2 100644 --- a/backend/src/features/user-profile/api/user-profile.validation.ts +++ b/backend/src/features/user-profile/api/user-profile.validation.ts @@ -16,3 +16,12 @@ export const updateProfileSchema = z.object({ ); export type UpdateProfileInput = z.infer; + +export const requestDeletionSchema = z.object({ + password: z.string().min(1, 'Password is required'), + confirmationText: z.string().refine((val) => val === 'DELETE', { + message: 'Confirmation text must be exactly "DELETE"', + }), +}); + +export type RequestDeletionInput = z.infer; diff --git a/backend/src/features/user-profile/data/user-profile.repository.ts b/backend/src/features/user-profile/data/user-profile.repository.ts index a1a88be..dbc1d58 100644 --- a/backend/src/features/user-profile/data/user-profile.repository.ts +++ b/backend/src/features/user-profile/data/user-profile.repository.ts @@ -16,7 +16,8 @@ import { logger } from '../../../core/logging/logger'; // Base columns for user profile queries const USER_PROFILE_COLUMNS = ` id, auth0_sub, email, display_name, notification_email, - subscription_tier, deactivated_at, deactivated_by, + subscription_tier, email_verified, onboarding_completed_at, + deactivated_at, deactivated_by, deletion_requested_at, deletion_scheduled_for, created_at, updated_at `; @@ -139,8 +140,12 @@ export class UserProfileRepository { displayName: row.display_name, notificationEmail: row.notification_email, subscriptionTier: row.subscription_tier || 'free', + emailVerified: row.email_verified ?? false, + onboardingCompletedAt: row.onboarding_completed_at ? new Date(row.onboarding_completed_at) : null, deactivatedAt: row.deactivated_at ? new Date(row.deactivated_at) : null, deactivatedBy: row.deactivated_by || null, + deletionRequestedAt: row.deletion_requested_at ? new Date(row.deletion_requested_at) : null, + deletionScheduledFor: row.deletion_scheduled_for ? new Date(row.deletion_scheduled_for) : null, createdAt: new Date(row.created_at), updatedAt: new Date(row.updated_at), }; @@ -213,8 +218,8 @@ export class UserProfileRepository { const dataQuery = ` SELECT up.id, up.auth0_sub, up.email, up.display_name, up.notification_email, - up.subscription_tier, up.deactivated_at, up.deactivated_by, - up.created_at, up.updated_at, + up.subscription_tier, up.email_verified, up.onboarding_completed_at, + up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at, au.auth0_sub as admin_auth0_sub, au.role as admin_role FROM user_profiles up @@ -247,8 +252,8 @@ export class UserProfileRepository { const query = ` SELECT up.id, up.auth0_sub, up.email, up.display_name, up.notification_email, - up.subscription_tier, up.deactivated_at, up.deactivated_by, - up.created_at, up.updated_at, + up.subscription_tier, up.email_verified, up.onboarding_completed_at, + up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at, au.auth0_sub as admin_auth0_sub, au.role as admin_role FROM user_profiles up @@ -388,4 +393,221 @@ export class UserProfileRepository { throw error; } } + + /** + * Update email verification status + */ + async updateEmailVerified(auth0Sub: string, emailVerified: boolean): Promise { + const query = ` + UPDATE user_profiles + SET email_verified = $1, updated_at = NOW() + WHERE auth0_sub = $2 + RETURNING ${USER_PROFILE_COLUMNS} + `; + + try { + const result = await this.pool.query(query, [emailVerified, auth0Sub]); + if (result.rows.length === 0) { + throw new Error('User profile not found'); + } + return this.mapRowToUserProfile(result.rows[0]); + } catch (error) { + logger.error('Error updating email verified status', { error, auth0Sub, emailVerified }); + throw error; + } + } + + /** + * Mark onboarding as complete + */ + async markOnboardingComplete(auth0Sub: string): Promise { + const query = ` + UPDATE user_profiles + SET onboarding_completed_at = NOW(), updated_at = NOW() + WHERE auth0_sub = $1 AND onboarding_completed_at IS NULL + RETURNING ${USER_PROFILE_COLUMNS} + `; + + try { + const result = await this.pool.query(query, [auth0Sub]); + if (result.rows.length === 0) { + // Check if already completed or profile not found + const existing = await this.getByAuth0Sub(auth0Sub); + if (existing && existing.onboardingCompletedAt) { + return existing; // Already completed, return as-is + } + throw new Error('User profile not found'); + } + return this.mapRowToUserProfile(result.rows[0]); + } catch (error) { + logger.error('Error marking onboarding complete', { error, auth0Sub }); + throw error; + } + } + + /** + * Update user email (used when fetching correct email from Auth0) + */ + async updateEmail(auth0Sub: string, email: string): Promise { + const query = ` + UPDATE user_profiles + SET email = $1, updated_at = NOW() + WHERE auth0_sub = $2 + RETURNING ${USER_PROFILE_COLUMNS} + `; + + try { + const result = await this.pool.query(query, [email, auth0Sub]); + if (result.rows.length === 0) { + throw new Error('User profile not found'); + } + return this.mapRowToUserProfile(result.rows[0]); + } catch (error) { + logger.error('Error updating user email', { error, auth0Sub }); + throw error; + } + } + + /** + * Request account deletion (sets deletion timestamps and deactivates account) + * 30-day grace period before permanent deletion + */ + async requestDeletion(auth0Sub: string): Promise { + const query = ` + UPDATE user_profiles + SET + deletion_requested_at = NOW(), + deletion_scheduled_for = NOW() + INTERVAL '30 days', + deactivated_at = NOW(), + updated_at = NOW() + WHERE auth0_sub = $1 AND deletion_requested_at IS NULL + RETURNING ${USER_PROFILE_COLUMNS} + `; + + try { + const result = await this.pool.query(query, [auth0Sub]); + if (result.rows.length === 0) { + throw new Error('User profile not found or deletion already requested'); + } + return this.mapRowToUserProfile(result.rows[0]); + } catch (error) { + logger.error('Error requesting account deletion', { error, auth0Sub }); + throw error; + } + } + + /** + * Cancel deletion request (clears deletion timestamps and reactivates account) + */ + async cancelDeletion(auth0Sub: string): Promise { + const query = ` + UPDATE user_profiles + SET + deletion_requested_at = NULL, + deletion_scheduled_for = NULL, + deactivated_at = NULL, + deactivated_by = NULL, + updated_at = NOW() + WHERE auth0_sub = $1 AND deletion_requested_at IS NOT NULL + RETURNING ${USER_PROFILE_COLUMNS} + `; + + try { + const result = await this.pool.query(query, [auth0Sub]); + if (result.rows.length === 0) { + throw new Error('User profile not found or no deletion request pending'); + } + return this.mapRowToUserProfile(result.rows[0]); + } catch (error) { + logger.error('Error canceling account deletion', { error, auth0Sub }); + throw error; + } + } + + /** + * Get users whose deletion grace period has expired + */ + async getUsersPastGracePeriod(): Promise { + const query = ` + SELECT ${USER_PROFILE_COLUMNS} + FROM user_profiles + WHERE deletion_scheduled_for IS NOT NULL + AND deletion_scheduled_for <= NOW() + ORDER BY deletion_scheduled_for ASC + `; + + try { + const result = await this.pool.query(query); + return result.rows.map(row => this.mapRowToUserProfile(row)); + } catch (error) { + logger.error('Error fetching users past grace period', { error }); + throw error; + } + } + + /** + * Hard delete user and all associated data + * This is a permanent operation - use with caution + */ + async hardDeleteUser(auth0Sub: string): Promise { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + + // 1. Anonymize community station submissions (keep data but remove user reference) + await client.query( + `UPDATE community_stations + SET submitted_by = 'deleted-user' + WHERE submitted_by = $1`, + [auth0Sub] + ); + + // 2. Delete notification logs + await client.query( + 'DELETE FROM notification_logs WHERE user_id = $1', + [auth0Sub] + ); + + // 3. Delete user notifications + await client.query( + 'DELETE FROM user_notifications WHERE user_id = $1', + [auth0Sub] + ); + + // 4. Delete saved stations + await client.query( + 'DELETE FROM saved_stations WHERE user_id = $1', + [auth0Sub] + ); + + // 5. Delete vehicles (cascades to fuel_logs, maintenance_logs, maintenance_schedules, documents) + await client.query( + 'DELETE FROM vehicles WHERE user_id = $1', + [auth0Sub] + ); + + // 6. Delete user preferences + await client.query( + 'DELETE FROM user_preferences WHERE user_id = $1', + [auth0Sub] + ); + + // 7. Delete user profile (final step) + await client.query( + 'DELETE FROM user_profiles WHERE auth0_sub = $1', + [auth0Sub] + ); + + await client.query('COMMIT'); + + logger.info('User hard deleted successfully', { auth0Sub }); + } catch (error) { + await client.query('ROLLBACK'); + logger.error('Error hard deleting user', { error, auth0Sub }); + throw error; + } finally { + client.release(); + } + } } diff --git a/backend/src/features/user-profile/domain/user-profile.service.ts b/backend/src/features/user-profile/domain/user-profile.service.ts index f261b10..9681e5a 100644 --- a/backend/src/features/user-profile/domain/user-profile.service.ts +++ b/backend/src/features/user-profile/domain/user-profile.service.ts @@ -12,9 +12,11 @@ import { ListUsersQuery, ListUsersResponse, SubscriptionTier, + DeletionStatus, } from './user-profile.types'; import { AdminRepository } from '../../admin/data/admin.repository'; import { logger } from '../../../core/logging/logger'; +import { auth0ManagementClient } from '../../../core/auth/auth0-management.client'; export class UserProfileService { private adminRepository: AdminRepository | null = null; @@ -320,4 +322,178 @@ export class UserProfileService { throw error; } } + + // ============================================ + // Account deletion methods (GDPR compliance) + // ============================================ + + /** + * Request account deletion with password verification + * Sets 30-day grace period before permanent deletion + */ + async requestDeletion( + auth0Sub: string, + password: string, + confirmationText: string + ): Promise { + try { + // Validate confirmation text + if (confirmationText !== 'DELETE') { + throw new Error('Invalid confirmation text'); + } + + // Get user profile + const profile = await this.repository.getByAuth0Sub(auth0Sub); + if (!profile) { + throw new Error('User not found'); + } + + // Check if deletion already requested + if (profile.deletionRequestedAt) { + throw new Error('Deletion already requested'); + } + + // Verify password with Auth0 + const passwordValid = await auth0ManagementClient.verifyPassword(profile.email, password); + if (!passwordValid) { + throw new Error('Invalid password'); + } + + // Request deletion + const updatedProfile = await this.repository.requestDeletion(auth0Sub); + + // Log to audit trail + if (this.adminRepository) { + await this.adminRepository.logAuditAction( + auth0Sub, + 'REQUEST_DELETION', + auth0Sub, + 'user_profile', + updatedProfile.id, + { + deletionScheduledFor: updatedProfile.deletionScheduledFor, + } + ); + } + + logger.info('Account deletion requested', { + auth0Sub, + deletionScheduledFor: updatedProfile.deletionScheduledFor, + }); + + return updatedProfile; + } catch (error) { + logger.error('Error requesting account deletion', { error, auth0Sub }); + throw error; + } + } + + /** + * Cancel pending deletion request + */ + async cancelDeletion(auth0Sub: string): Promise { + try { + // Cancel deletion + const updatedProfile = await this.repository.cancelDeletion(auth0Sub); + + // Log to audit trail + if (this.adminRepository) { + await this.adminRepository.logAuditAction( + auth0Sub, + 'CANCEL_DELETION', + auth0Sub, + 'user_profile', + updatedProfile.id, + {} + ); + } + + logger.info('Account deletion canceled', { auth0Sub }); + + return updatedProfile; + } catch (error) { + logger.error('Error canceling account deletion', { error, auth0Sub }); + throw error; + } + } + + /** + * Get deletion status for a user profile + */ + getDeletionStatus(profile: UserProfile): DeletionStatus { + if (!profile.deletionRequestedAt || !profile.deletionScheduledFor) { + return { + isPendingDeletion: false, + deletionRequestedAt: null, + deletionScheduledFor: null, + daysRemaining: null, + }; + } + + const now = new Date(); + const scheduledDate = new Date(profile.deletionScheduledFor); + const millisecondsRemaining = scheduledDate.getTime() - now.getTime(); + const daysRemaining = Math.ceil(millisecondsRemaining / (1000 * 60 * 60 * 24)); + + return { + isPendingDeletion: true, + deletionRequestedAt: profile.deletionRequestedAt, + deletionScheduledFor: profile.deletionScheduledFor, + daysRemaining: Math.max(0, daysRemaining), + }; + } + + /** + * Admin hard delete user (permanent deletion) + * Prevents self-delete + */ + async adminHardDeleteUser( + auth0Sub: string, + actorAuth0Sub: string, + reason?: string + ): Promise { + try { + // Prevent self-delete + if (auth0Sub === actorAuth0Sub) { + throw new Error('Cannot delete your own account'); + } + + // Get user profile before deletion for audit log + const profile = await this.repository.getByAuth0Sub(auth0Sub); + if (!profile) { + throw new Error('User not found'); + } + + // Log to audit trail before deletion + if (this.adminRepository) { + await this.adminRepository.logAuditAction( + actorAuth0Sub, + 'HARD_DELETE_USER', + auth0Sub, + 'user_profile', + profile.id, + { + reason: reason || 'No reason provided', + email: profile.email, + displayName: profile.displayName, + } + ); + } + + // Hard delete from database + await this.repository.hardDeleteUser(auth0Sub); + + // Delete from Auth0 + await auth0ManagementClient.deleteUser(auth0Sub); + + logger.info('User hard deleted by admin', { + auth0Sub, + actorAuth0Sub, + reason, + }); + } catch (error) { + logger.error('Error hard deleting user', { error, auth0Sub, actorAuth0Sub }); + throw error; + } + } } diff --git a/backend/src/features/user-profile/domain/user-profile.types.ts b/backend/src/features/user-profile/domain/user-profile.types.ts index 1ea146e..5c19c39 100644 --- a/backend/src/features/user-profile/domain/user-profile.types.ts +++ b/backend/src/features/user-profile/domain/user-profile.types.ts @@ -13,8 +13,12 @@ export interface UserProfile { displayName?: string; notificationEmail?: string; subscriptionTier: SubscriptionTier; + emailVerified: boolean; + onboardingCompletedAt: Date | null; deactivatedAt: Date | null; deactivatedBy: string | null; + deletionRequestedAt: Date | null; + deletionScheduledFor: Date | null; createdAt: Date; updatedAt: Date; } @@ -64,3 +68,17 @@ export interface Auth0UserData { email: string; name?: string; } + +// Request to delete user account +export interface RequestDeletionRequest { + password: string; + confirmationText: string; +} + +// Deletion status for user account +export interface DeletionStatus { + isPendingDeletion: boolean; + deletionRequestedAt: Date | null; + deletionScheduledFor: Date | null; + daysRemaining: number | null; +} diff --git a/backend/src/features/user-profile/jobs/account-purge.job.ts b/backend/src/features/user-profile/jobs/account-purge.job.ts new file mode 100644 index 0000000..1c2c023 --- /dev/null +++ b/backend/src/features/user-profile/jobs/account-purge.job.ts @@ -0,0 +1,87 @@ +/** + * @ai-summary Account purge job for GDPR-compliant deletion + * @ai-context Processes user accounts past their 30-day grace period + */ + +import { UserProfileRepository } from '../data/user-profile.repository'; +import { pool } from '../../../core/config/database'; +import { logger } from '../../../core/logging/logger'; +import { auth0ManagementClient } from '../../../core/auth/auth0-management.client'; + +interface PurgeResult { + processed: number; + deleted: number; + errors: Array<{ auth0Sub: string; error: string }>; +} + +/** + * Process account purges for users past the 30-day grace period + * This job runs daily at 2 AM + */ +export async function processAccountPurges(): Promise { + const repository = new UserProfileRepository(pool); + const result: PurgeResult = { + processed: 0, + deleted: 0, + errors: [], + }; + + try { + logger.info('Starting account purge job'); + + // Get users past grace period + const usersToPurge = await repository.getUsersPastGracePeriod(); + + logger.info('Found users to purge', { count: usersToPurge.length }); + + result.processed = usersToPurge.length; + + // Process each user + for (const user of usersToPurge) { + try { + logger.info('Purging user account', { + auth0Sub: user.auth0Sub, + email: user.email, + deletionScheduledFor: user.deletionScheduledFor, + }); + + // Hard delete from database + await repository.hardDeleteUser(user.auth0Sub); + + // Delete from Auth0 + await auth0ManagementClient.deleteUser(user.auth0Sub); + + result.deleted++; + + logger.info('User account purged successfully', { + auth0Sub: user.auth0Sub, + email: user.email, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + logger.error('Failed to purge user account', { + auth0Sub: user.auth0Sub, + email: user.email, + error: errorMessage, + }); + + result.errors.push({ + auth0Sub: user.auth0Sub, + error: errorMessage, + }); + } + } + + logger.info('Account purge job completed', { + processed: result.processed, + deleted: result.deleted, + errors: result.errors.length, + }); + + return result; + } catch (error) { + logger.error('Account purge job failed', { error }); + throw error; + } +} diff --git a/backend/src/features/user-profile/migrations/003_add_email_verified_and_onboarding.sql b/backend/src/features/user-profile/migrations/003_add_email_verified_and_onboarding.sql new file mode 100644 index 0000000..b7c1d0d --- /dev/null +++ b/backend/src/features/user-profile/migrations/003_add_email_verified_and_onboarding.sql @@ -0,0 +1,23 @@ +-- Migration: 003_add_email_verified_and_onboarding +-- Description: Add email verification status and onboarding tracking columns +-- Date: 2025-12-22 + +-- Add email verification status column +ALTER TABLE user_profiles + ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false; + +-- Add onboarding completion timestamp column +ALTER TABLE user_profiles + ADD COLUMN IF NOT EXISTS onboarding_completed_at TIMESTAMP WITH TIME ZONE DEFAULT NULL; + +-- Create index for efficient email verification status queries +CREATE INDEX IF NOT EXISTS idx_user_profiles_email_verified + ON user_profiles(email_verified); + +-- Create index for onboarding status queries +CREATE INDEX IF NOT EXISTS idx_user_profiles_onboarding_completed + ON user_profiles(onboarding_completed_at); + +-- Add comment for documentation +COMMENT ON COLUMN user_profiles.email_verified IS 'Whether the user has verified their email address via Auth0'; +COMMENT ON COLUMN user_profiles.onboarding_completed_at IS 'Timestamp when user completed the onboarding flow, NULL if not completed'; diff --git a/backend/src/features/user-profile/migrations/004_add_deletion_request.sql b/backend/src/features/user-profile/migrations/004_add_deletion_request.sql new file mode 100644 index 0000000..5616935 --- /dev/null +++ b/backend/src/features/user-profile/migrations/004_add_deletion_request.sql @@ -0,0 +1,18 @@ +-- Migration: Add user account deletion request tracking +-- Description: Adds columns to track GDPR-compliant user account deletion requests with 30-day grace period + +-- Add deletion tracking columns to user_profiles table +ALTER TABLE user_profiles +ADD COLUMN deletion_requested_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, +ADD COLUMN deletion_scheduled_for TIMESTAMP WITH TIME ZONE DEFAULT NULL; + +-- Create indexes for efficient purge job queries +CREATE INDEX idx_user_profiles_deletion_scheduled ON user_profiles(deletion_scheduled_for) +WHERE deletion_scheduled_for IS NOT NULL; + +CREATE INDEX idx_user_profiles_deletion_requested ON user_profiles(deletion_requested_at) +WHERE deletion_requested_at IS NOT NULL; + +-- Comment on columns for documentation +COMMENT ON COLUMN user_profiles.deletion_requested_at IS 'Timestamp when user requested account deletion (GDPR compliance)'; +COMMENT ON COLUMN user_profiles.deletion_scheduled_for IS 'Timestamp when account will be permanently deleted (30 days after request)'; diff --git a/docker-compose.yml b/docker-compose.yml index efad84d..22f05c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -110,6 +110,8 @@ services: - ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro - ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro - ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro + - ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro + - ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro # Filesystem storage for documents - ./data/documents:/app/data/documents networks: diff --git a/docs/PROMPTS.md b/docs/PROMPTS.md index 1fc2af8..cefc704 100644 --- a/docs/PROMPTS.md +++ b/docs/PROMPTS.md @@ -19,7 +19,7 @@ comprehensive spec.md - containing requirements, architecture decisions, data mo You are a senior software engineer specializsing in NodeJS, Typescript, front end and back end development. You will be delegating tasks to the platform-agent, feature-agent, first-frontend-agent and quality-agent when appropriate. *** ACTION *** -- You will be enhancing the maintenance record feature. +- You will be implementing improvements to the User Management. - Make no assumptions. - Ask clarifying questions. - Ultrathink @@ -27,8 +27,11 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en *** CONTEXT *** - This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s. - Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change. -- There is a basic maintenance record system implemented. -- We need to implement schedule maintenance records with notifications tied into the existing notification system. +- There is no delete option for users +- GPDR requires that users are able to fully delete their information +- There is a Delete button in the user settings. This needs to be implemented +- The same functionality should be enabled admin settings for user management. + *** CHANGES TO IMPLEMENT *** - Research this code base and ask iterative questions to compile a complete plan. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f493d3e..bf6c6a0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,17 @@ const AdminEmailTemplatesMobileScreen = lazy(() => import('./features/admin/mobi // Admin Community Stations (lazy-loaded) const AdminCommunityStationsPage = lazy(() => import('./features/admin/pages/AdminCommunityStationsPage').then(m => ({ default: m.AdminCommunityStationsPage }))); const AdminCommunityStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminCommunityStationsMobileScreen').then(m => ({ default: m.AdminCommunityStationsMobileScreen }))); + +// Auth pages (lazy-loaded) +const SignupPage = lazy(() => import('./features/auth/pages/SignupPage').then(m => ({ default: m.SignupPage }))); +const VerifyEmailPage = lazy(() => import('./features/auth/pages/VerifyEmailPage').then(m => ({ default: m.VerifyEmailPage }))); +const SignupMobileScreen = lazy(() => import('./features/auth/mobile/SignupMobileScreen').then(m => ({ default: m.SignupMobileScreen }))); +const VerifyEmailMobileScreen = lazy(() => import('./features/auth/mobile/VerifyEmailMobileScreen').then(m => ({ default: m.VerifyEmailMobileScreen }))); + +// Onboarding pages (lazy-loaded) +const OnboardingPage = lazy(() => import('./features/onboarding/pages/OnboardingPage').then(m => ({ default: m.OnboardingPage }))); +const OnboardingMobileScreen = lazy(() => import('./features/onboarding/mobile/OnboardingMobileScreen').then(m => ({ default: m.OnboardingMobileScreen }))); + import { HomePage } from './pages/HomePage'; import { BottomNavigation } from './shared-minimal/components/mobile/BottomNavigation'; import { QuickAction } from './shared-minimal/components/mobile/quickActions'; @@ -399,7 +410,11 @@ function App() { const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/'); const isCallbackRoute = location.pathname === '/callback'; - const shouldShowHomePage = !isGarageRoute && !isCallbackRoute; + const isSignupRoute = location.pathname === '/signup'; + const isVerifyEmailRoute = location.pathname === '/verify-email'; + const isOnboardingRoute = location.pathname === '/onboarding'; + const isAuthRoute = isSignupRoute || isVerifyEmailRoute || isOnboardingRoute; + const shouldShowHomePage = !isGarageRoute && !isCallbackRoute && !isAuthRoute; // Enhanced navigation handlers for mobile const handleVehicleSelect = useCallback((vehicle: Vehicle) => { @@ -475,10 +490,60 @@ function App() { ); } + // Signup route is public - no authentication required + if (isSignupRoute) { + return ( + + + +
Loading...
+ + }> + {mobileMode ? : } +
+ +
+ ); + } + if (!isAuthenticated) { return ; } + // Verify email and onboarding routes require authentication but not full initialization + if (isVerifyEmailRoute) { + return ( + + + +
Loading...
+ + }> + {mobileMode ? : } +
+ +
+ ); + } + + if (isOnboardingRoute) { + return ( + + + +
Loading...
+ + }> + {mobileMode ? : } +
+ +
+ ); + } + // Wait for auth gate to be ready before rendering protected routes // This prevents a race condition where the page renders before the auth token is ready if (!isAuthGateReady) { diff --git a/frontend/src/features/admin/api/admin.api.ts b/frontend/src/features/admin/api/admin.api.ts index a28aa3c..1aacf2d 100644 --- a/frontend/src/features/admin/api/admin.api.ts +++ b/frontend/src/features/admin/api/admin.api.ts @@ -359,5 +359,13 @@ export const adminApi = { ); return response.data; }, + + hardDelete: async (auth0Sub: string, reason?: string): Promise<{ message: string }> => { + const response = await apiClient.delete<{ message: string }>( + `/admin/users/${encodeURIComponent(auth0Sub)}`, + { params: reason ? { reason } : undefined } + ); + return response.data; + }, }, }; diff --git a/frontend/src/features/admin/hooks/useUsers.ts b/frontend/src/features/admin/hooks/useUsers.ts index 7ba681e..03b5979 100644 --- a/frontend/src/features/admin/hooks/useUsers.ts +++ b/frontend/src/features/admin/hooks/useUsers.ts @@ -178,3 +178,26 @@ export const usePromoteToAdmin = () => { }, }); }; + +/** + * Hook to hard delete a user (GDPR permanent deletion) + */ +export const useHardDeleteUser = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ auth0Sub, reason }: { auth0Sub: string; reason?: string }) => + adminApi.users.hardDelete(auth0Sub, reason), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); + toast.success('User permanently deleted'); + }, + onError: (error: ApiError) => { + toast.error( + error.response?.data?.message || + error.response?.data?.error || + 'Failed to delete user' + ); + }, + }); +}; diff --git a/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx b/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx index 4151afa..1a65a88 100644 --- a/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx +++ b/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx @@ -15,6 +15,7 @@ import { useReactivateUser, useUpdateUserProfile, usePromoteToAdmin, + useHardDeleteUser, } from '../hooks/useUsers'; import { ManagedUser, @@ -103,6 +104,7 @@ export const AdminUsersMobileScreen: React.FC = () => { const reactivateMutation = useReactivateUser(); const updateProfileMutation = useUpdateUserProfile(); const promoteToAdminMutation = usePromoteToAdmin(); + const hardDeleteMutation = useHardDeleteUser(); // Selected user for actions const [selectedUser, setSelectedUser] = useState(null); @@ -115,6 +117,9 @@ export const AdminUsersMobileScreen: React.FC = () => { const [editDisplayName, setEditDisplayName] = useState(''); const [showPromoteModal, setShowPromoteModal] = useState(false); const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin'); + const [showHardDeleteModal, setShowHardDeleteModal] = useState(false); + const [hardDeleteReason, setHardDeleteReason] = useState(''); + const [hardDeleteConfirmText, setHardDeleteConfirmText] = useState(''); // Handlers const handleSearch = useCallback(() => { @@ -256,6 +261,34 @@ export const AdminUsersMobileScreen: React.FC = () => { setSelectedUser(null); }, []); + const handleHardDeleteClick = useCallback(() => { + setShowUserActions(false); + setShowHardDeleteModal(true); + }, []); + + const handleHardDeleteConfirm = useCallback(() => { + if (selectedUser && hardDeleteConfirmText === 'DELETE') { + hardDeleteMutation.mutate( + { auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined }, + { + onSuccess: () => { + setShowHardDeleteModal(false); + setHardDeleteReason(''); + setHardDeleteConfirmText(''); + setSelectedUser(null); + }, + } + ); + } + }, [selectedUser, hardDeleteReason, hardDeleteConfirmText, hardDeleteMutation]); + + const handleHardDeleteCancel = useCallback(() => { + setShowHardDeleteModal(false); + setHardDeleteReason(''); + setHardDeleteConfirmText(''); + setSelectedUser(null); + }, []); + const handleLoadMore = useCallback(() => { setParams(prev => ({ ...prev, page: (prev.page || 1) + 1 })); }, []); @@ -527,6 +560,15 @@ export const AdminUsersMobileScreen: React.FC = () => { Deactivate User )} + + {!selectedUser.isAdmin && ( + + )} )} @@ -716,6 +758,82 @@ export const AdminUsersMobileScreen: React.FC = () => {

+ + {/* Hard Delete Confirmation Modal */} + !hardDeleteMutation.isPending && handleHardDeleteCancel()} + title="Permanently Delete User" + actions={ + <> + + + + } + > +
+
+

+ Warning: This action cannot be undone! +

+

+ All user data will be permanently deleted, including vehicles, fuel logs, + maintenance records, and documents. +

+
+ +

+ Are you sure you want to permanently delete{' '} + {selectedUser?.email}? +

+ +
+ +