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}?
+
+
+
+
+
+
+
+
+
setHardDeleteConfirmText(e.target.value.toUpperCase())}
+ placeholder="Type DELETE"
+ className={`w-full px-3 py-2 rounded-lg border min-h-[44px] ${
+ hardDeleteConfirmText && hardDeleteConfirmText !== 'DELETE'
+ ? 'border-red-500'
+ : 'border-slate-200'
+ }`}
+ style={{ fontSize: '16px' }}
+ />
+ {hardDeleteConfirmText && hardDeleteConfirmText !== 'DELETE' && (
+
Please type DELETE exactly
+ )}
+
+
+
);
};
diff --git a/frontend/src/features/auth/api/auth.api.ts b/frontend/src/features/auth/api/auth.api.ts
new file mode 100644
index 0000000..5eeee26
--- /dev/null
+++ b/frontend/src/features/auth/api/auth.api.ts
@@ -0,0 +1,40 @@
+/**
+ * @ai-summary API client for auth feature (signup, verification)
+ */
+
+import axios from 'axios';
+import { apiClient } from '../../../core/api/client';
+import {
+ SignupRequest,
+ SignupResponse,
+ VerifyStatusResponse,
+ ResendVerificationResponse,
+} from '../types/auth.types';
+
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
+
+// Create unauthenticated client for public signup endpoint
+const unauthenticatedClient = axios.create({
+ baseURL: API_BASE_URL,
+ timeout: 10000,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+export const authApi = {
+ signup: async (data: SignupRequest): Promise => {
+ const response = await unauthenticatedClient.post('/auth/signup', data);
+ return response.data;
+ },
+
+ getVerifyStatus: async (): Promise => {
+ const response = await apiClient.get('/auth/verify-status');
+ return response.data;
+ },
+
+ resendVerification: async (): Promise => {
+ const response = await apiClient.post('/auth/resend-verification');
+ return response.data;
+ },
+};
diff --git a/frontend/src/features/auth/components/SignupForm.tsx b/frontend/src/features/auth/components/SignupForm.tsx
new file mode 100644
index 0000000..86b118d
--- /dev/null
+++ b/frontend/src/features/auth/components/SignupForm.tsx
@@ -0,0 +1,148 @@
+/**
+ * @ai-summary Signup form component with password validation and show/hide toggle
+ */
+
+import React, { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import { Button } from '../../../shared-minimal/components/Button';
+import { SignupRequest } from '../types/auth.types';
+
+const signupSchema = z
+ .object({
+ email: z.string().email('Please enter a valid email address'),
+ password: z
+ .string()
+ .min(8, 'Password must be at least 8 characters')
+ .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
+ .regex(/[0-9]/, 'Password must contain at least one number'),
+ confirmPassword: z.string(),
+ })
+ .refine((data) => data.password === data.confirmPassword, {
+ message: 'Passwords do not match',
+ path: ['confirmPassword'],
+ });
+
+interface SignupFormProps {
+ onSubmit: (data: SignupRequest) => void;
+ loading?: boolean;
+}
+
+export const SignupForm: React.FC = ({ onSubmit, loading }) => {
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(signupSchema),
+ });
+
+ const handleFormSubmit = (data: SignupRequest & { confirmPassword: string }) => {
+ const { email, password } = data;
+ onSubmit({ email, password });
+ };
+
+ return (
+
+ );
+};
diff --git a/frontend/src/features/auth/hooks/useSignup.ts b/frontend/src/features/auth/hooks/useSignup.ts
new file mode 100644
index 0000000..2d6f77d
--- /dev/null
+++ b/frontend/src/features/auth/hooks/useSignup.ts
@@ -0,0 +1,32 @@
+/**
+ * @ai-summary React Query hook for user signup
+ */
+
+import { useMutation } from '@tanstack/react-query';
+import toast from 'react-hot-toast';
+import { authApi } from '../api/auth.api';
+import { SignupRequest } from '../types/auth.types';
+
+interface ApiError {
+ response?: {
+ data?: {
+ error?: string;
+ message?: string;
+ };
+ status?: number;
+ };
+ message?: string;
+}
+
+export const useSignup = () => {
+ return useMutation({
+ mutationFn: (data: SignupRequest) => authApi.signup(data),
+ onSuccess: () => {
+ toast.success('Account created! Please check your email to verify your account.');
+ },
+ onError: (error: ApiError) => {
+ const errorMessage = error.response?.data?.error || error.response?.data?.message || error.message || 'Failed to create account';
+ toast.error(errorMessage);
+ },
+ });
+};
diff --git a/frontend/src/features/auth/hooks/useVerifyStatus.ts b/frontend/src/features/auth/hooks/useVerifyStatus.ts
new file mode 100644
index 0000000..925460f
--- /dev/null
+++ b/frontend/src/features/auth/hooks/useVerifyStatus.ts
@@ -0,0 +1,54 @@
+/**
+ * @ai-summary React Query hook for email verification status with polling
+ */
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useAuth0 } from '@auth0/auth0-react';
+import toast from 'react-hot-toast';
+import { authApi } from '../api/auth.api';
+
+interface ApiError {
+ response?: {
+ data?: {
+ error?: string;
+ message?: string;
+ };
+ };
+ message?: string;
+}
+
+export const useVerifyStatus = (options?: { enablePolling?: boolean; onVerified?: () => void }) => {
+ const { isAuthenticated, isLoading } = useAuth0();
+
+ const query = useQuery({
+ queryKey: ['verifyStatus'],
+ queryFn: authApi.getVerifyStatus,
+ enabled: isAuthenticated && !isLoading,
+ refetchInterval: options?.enablePolling ? 5000 : false, // Poll every 5 seconds if enabled
+ refetchIntervalInBackground: false,
+ retry: 2,
+ });
+
+ // Call onVerified callback when verification completes
+ if (query.data?.emailVerified && options?.onVerified) {
+ options.onVerified();
+ }
+
+ return query;
+};
+
+export const useResendVerification = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => authApi.resendVerification(),
+ onSuccess: (data) => {
+ toast.success(data.message || 'Verification email sent. Please check your inbox.');
+ queryClient.invalidateQueries({ queryKey: ['verifyStatus'] });
+ },
+ onError: (error: ApiError) => {
+ const errorMessage = error.response?.data?.error || error.response?.data?.message || error.message || 'Failed to resend verification email';
+ toast.error(errorMessage);
+ },
+ });
+};
diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts
new file mode 100644
index 0000000..8fbf1f3
--- /dev/null
+++ b/frontend/src/features/auth/index.ts
@@ -0,0 +1,24 @@
+/**
+ * @ai-summary Auth feature module exports
+ */
+
+// Types
+export * from './types/auth.types';
+
+// API
+export { authApi } from './api/auth.api';
+
+// Hooks
+export { useSignup } from './hooks/useSignup';
+export { useVerifyStatus, useResendVerification } from './hooks/useVerifyStatus';
+
+// Components
+export { SignupForm } from './components/SignupForm';
+
+// Pages
+export { SignupPage } from './pages/SignupPage';
+export { VerifyEmailPage } from './pages/VerifyEmailPage';
+
+// Mobile Screens
+export { SignupMobileScreen } from './mobile/SignupMobileScreen';
+export { VerifyEmailMobileScreen } from './mobile/VerifyEmailMobileScreen';
diff --git a/frontend/src/features/auth/mobile/SignupMobileScreen.tsx b/frontend/src/features/auth/mobile/SignupMobileScreen.tsx
new file mode 100644
index 0000000..1cd8816
--- /dev/null
+++ b/frontend/src/features/auth/mobile/SignupMobileScreen.tsx
@@ -0,0 +1,54 @@
+/**
+ * @ai-summary Mobile signup screen with glass card styling
+ */
+
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
+import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
+import { SignupForm } from '../components/SignupForm';
+import { useSignup } from '../hooks/useSignup';
+import { SignupRequest } from '../types/auth.types';
+
+export const SignupMobileScreen: React.FC = () => {
+ const navigate = useNavigate();
+ const { mutate: signup, isPending } = useSignup();
+
+ const handleSubmit = (data: SignupRequest) => {
+ signup(data, {
+ onSuccess: () => {
+ navigate('/verify-email');
+ },
+ });
+ };
+
+ return (
+
+
+
+
MotoVaultPro
+
Create Your Account
+
+ Start tracking your vehicle maintenance and fuel logs
+
+
+
+
+
+
+
+
+ Already have an account?{' '}
+
+
+
+
+ );
+};
+
+export default SignupMobileScreen;
diff --git a/frontend/src/features/auth/mobile/VerifyEmailMobileScreen.tsx b/frontend/src/features/auth/mobile/VerifyEmailMobileScreen.tsx
new file mode 100644
index 0000000..9ecbab3
--- /dev/null
+++ b/frontend/src/features/auth/mobile/VerifyEmailMobileScreen.tsx
@@ -0,0 +1,96 @@
+/**
+ * @ai-summary Mobile email verification screen with polling and resend
+ */
+
+import React, { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
+import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
+import { Button } from '../../../shared-minimal/components/Button';
+import { useVerifyStatus, useResendVerification } from '../hooks/useVerifyStatus';
+
+export const VerifyEmailMobileScreen: React.FC = () => {
+ const navigate = useNavigate();
+ const { data: verifyStatus, isLoading } = useVerifyStatus({
+ enablePolling: true,
+ });
+ const { mutate: resendVerification, isPending: isResending } = useResendVerification();
+
+ useEffect(() => {
+ if (verifyStatus?.emailVerified) {
+ navigate('/onboarding');
+ }
+ }, [verifyStatus, navigate]);
+
+ const handleResend = () => {
+ resendVerification();
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
Check Your Email
+
+ We've sent a verification link to
+
+
+ {verifyStatus?.email}
+
+
+
+
+
+
+
Click the link in the email to verify your account.
+
Once verified, you'll be automatically redirected to complete your profile.
+
+
+
+
Didn't receive the email?
+
+
+
+
+
+
+
Check your spam folder if you don't see the email in your inbox.
+
+
+
+ );
+};
+
+export default VerifyEmailMobileScreen;
diff --git a/frontend/src/features/auth/pages/SignupPage.tsx b/frontend/src/features/auth/pages/SignupPage.tsx
new file mode 100644
index 0000000..4e7b6d5
--- /dev/null
+++ b/frontend/src/features/auth/pages/SignupPage.tsx
@@ -0,0 +1,50 @@
+/**
+ * @ai-summary Desktop signup page with centered card layout
+ */
+
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { SignupForm } from '../components/SignupForm';
+import { useSignup } from '../hooks/useSignup';
+import { SignupRequest } from '../types/auth.types';
+
+export const SignupPage: React.FC = () => {
+ const navigate = useNavigate();
+ const { mutate: signup, isPending } = useSignup();
+
+ const handleSubmit = (data: SignupRequest) => {
+ signup(data, {
+ onSuccess: () => {
+ navigate('/verify-email');
+ },
+ });
+ };
+
+ return (
+
+
+
+
+
MotoVaultPro
+
Create Your Account
+
+ Start tracking your vehicle maintenance and fuel logs
+
+
+
+
+
+
+ Already have an account?{' '}
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/features/auth/pages/VerifyEmailPage.tsx b/frontend/src/features/auth/pages/VerifyEmailPage.tsx
new file mode 100644
index 0000000..c07126f
--- /dev/null
+++ b/frontend/src/features/auth/pages/VerifyEmailPage.tsx
@@ -0,0 +1,90 @@
+/**
+ * @ai-summary Desktop email verification page with polling and resend functionality
+ */
+
+import React, { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useVerifyStatus, useResendVerification } from '../hooks/useVerifyStatus';
+import { Button } from '../../../shared-minimal/components/Button';
+
+export const VerifyEmailPage: React.FC = () => {
+ const navigate = useNavigate();
+ const { data: verifyStatus, isLoading } = useVerifyStatus({
+ enablePolling: true,
+ });
+ const { mutate: resendVerification, isPending: isResending } = useResendVerification();
+
+ useEffect(() => {
+ if (verifyStatus?.emailVerified) {
+ navigate('/onboarding');
+ }
+ }, [verifyStatus, navigate]);
+
+ const handleResend = () => {
+ resendVerification();
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
Check Your Email
+
+ We've sent a verification link to
+
+
+ {verifyStatus?.email}
+
+
+
+
+
+
Click the link in the email to verify your account.
+
Once verified, you'll be automatically redirected to complete your profile.
+
+
+
+
Didn't receive the email?
+
+
+
+
+
+
Check your spam folder if you don't see the email in your inbox.
+
+
+
+
+ );
+};
diff --git a/frontend/src/features/auth/types/auth.types.ts b/frontend/src/features/auth/types/auth.types.ts
new file mode 100644
index 0000000..214b1c6
--- /dev/null
+++ b/frontend/src/features/auth/types/auth.types.ts
@@ -0,0 +1,23 @@
+/**
+ * @ai-summary TypeScript types for auth feature
+ */
+
+export interface SignupRequest {
+ email: string;
+ password: string;
+}
+
+export interface SignupResponse {
+ userId: string;
+ email: string;
+ message: string;
+}
+
+export interface VerifyStatusResponse {
+ emailVerified: boolean;
+ email: string;
+}
+
+export interface ResendVerificationResponse {
+ message: string;
+}
diff --git a/frontend/src/features/onboarding/api/onboarding.api.ts b/frontend/src/features/onboarding/api/onboarding.api.ts
new file mode 100644
index 0000000..5e8b9c3
--- /dev/null
+++ b/frontend/src/features/onboarding/api/onboarding.api.ts
@@ -0,0 +1,23 @@
+/**
+ * @ai-summary API client for onboarding endpoints
+ */
+
+import { apiClient } from '../../../core/api/client';
+import { OnboardingPreferences, OnboardingStatus } from '../types/onboarding.types';
+
+export const onboardingApi = {
+ savePreferences: async (data: OnboardingPreferences) => {
+ const response = await apiClient.post('/onboarding/preferences', data);
+ return response.data;
+ },
+
+ completeOnboarding: async () => {
+ const response = await apiClient.post('/onboarding/complete');
+ return response.data;
+ },
+
+ getStatus: async () => {
+ const response = await apiClient.get('/onboarding/status');
+ return response.data;
+ },
+};
diff --git a/frontend/src/features/onboarding/components/AddVehicleStep.tsx b/frontend/src/features/onboarding/components/AddVehicleStep.tsx
new file mode 100644
index 0000000..6581ab2
--- /dev/null
+++ b/frontend/src/features/onboarding/components/AddVehicleStep.tsx
@@ -0,0 +1,114 @@
+/**
+ * @ai-summary Step 2 of onboarding - Optionally add first vehicle
+ */
+
+import React, { useState } from 'react';
+import { Button } from '../../../shared-minimal/components/Button';
+import { VehicleForm } from '../../vehicles/components/VehicleForm';
+import { CreateVehicleRequest } from '../../vehicles/types/vehicles.types';
+
+interface AddVehicleStepProps {
+ onNext: () => void;
+ onAddVehicle: (data: CreateVehicleRequest) => void;
+ onBack: () => void;
+ loading?: boolean;
+}
+
+export const AddVehicleStep: React.FC = ({
+ onNext,
+ onAddVehicle,
+ onBack,
+ loading,
+}) => {
+ const [showForm, setShowForm] = useState(false);
+
+ const handleSkip = () => {
+ onNext();
+ };
+
+ const handleAddVehicle = (data: CreateVehicleRequest) => {
+ onAddVehicle(data);
+ };
+
+ if (!showForm) {
+ return (
+
+
+
+
Add Your First Vehicle
+
+ Add a vehicle now or skip this step and add it later from your garage.
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
Add Your First Vehicle
+
+ Fill in the details below. You can always edit this later.
+
+
+
+
setShowForm(false)}
+ loading={loading}
+ />
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/features/onboarding/components/CompleteStep.tsx b/frontend/src/features/onboarding/components/CompleteStep.tsx
new file mode 100644
index 0000000..49e7747
--- /dev/null
+++ b/frontend/src/features/onboarding/components/CompleteStep.tsx
@@ -0,0 +1,70 @@
+/**
+ * @ai-summary Step 3 of onboarding - Success screen
+ */
+
+import React from 'react';
+import { Button } from '../../../shared-minimal/components/Button';
+
+interface CompleteStepProps {
+ onComplete: () => void;
+ loading?: boolean;
+}
+
+export const CompleteStep: React.FC = ({ onComplete, loading }) => {
+ return (
+
+
+
+
+
You're All Set!
+
+ Welcome to MotoVault Pro. Your account is ready and you can now start tracking your vehicles.
+
+
+
+
+
What's Next?
+
+ -
+
+ Add or manage your vehicles in the garage
+
+ -
+
+ Track fuel logs and maintenance records
+
+ -
+
+ Upload important vehicle documents
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/features/onboarding/components/PreferencesStep.tsx b/frontend/src/features/onboarding/components/PreferencesStep.tsx
new file mode 100644
index 0000000..6495729
--- /dev/null
+++ b/frontend/src/features/onboarding/components/PreferencesStep.tsx
@@ -0,0 +1,141 @@
+/**
+ * @ai-summary Step 1 of onboarding - Set user preferences
+ */
+
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import { Button } from '../../../shared-minimal/components/Button';
+import { OnboardingPreferences } from '../types/onboarding.types';
+
+const preferencesSchema = z.object({
+ unitSystem: z.enum(['imperial', 'metric']),
+ currencyCode: z.string().length(3),
+ timeZone: z.string().min(1).max(100),
+});
+
+interface PreferencesStepProps {
+ onNext: (data: OnboardingPreferences) => void;
+ loading?: boolean;
+}
+
+export const PreferencesStep: React.FC = ({ onNext, loading }) => {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ watch,
+ setValue,
+ } = useForm({
+ resolver: zodResolver(preferencesSchema),
+ defaultValues: {
+ unitSystem: 'imperial',
+ currencyCode: 'USD',
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ },
+ });
+
+ const unitSystem = watch('unitSystem');
+
+ return (
+
+ );
+};
diff --git a/frontend/src/features/onboarding/hooks/useOnboarding.ts b/frontend/src/features/onboarding/hooks/useOnboarding.ts
new file mode 100644
index 0000000..2d9c7eb
--- /dev/null
+++ b/frontend/src/features/onboarding/hooks/useOnboarding.ts
@@ -0,0 +1,63 @@
+/**
+ * @ai-summary React Query hooks for onboarding flow
+ */
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useAuth0 } from '@auth0/auth0-react';
+import toast from 'react-hot-toast';
+import { onboardingApi } from '../api/onboarding.api';
+import { OnboardingPreferences } from '../types/onboarding.types';
+
+interface ApiError {
+ response?: {
+ data?: {
+ error?: string;
+ message?: string;
+ };
+ status?: number;
+ };
+ message?: string;
+}
+
+export const useOnboardingStatus = () => {
+ const { isAuthenticated, isLoading } = useAuth0();
+
+ return useQuery({
+ queryKey: ['onboarding-status'],
+ queryFn: onboardingApi.getStatus,
+ enabled: isAuthenticated && !isLoading,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ retry: false,
+ });
+};
+
+export const useSavePreferences = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (data: OnboardingPreferences) => onboardingApi.savePreferences(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
+ toast.success('Preferences saved successfully');
+ },
+ onError: (error: ApiError) => {
+ const errorMessage = error.response?.data?.error || error.message || 'Failed to save preferences';
+ toast.error(errorMessage);
+ },
+ });
+};
+
+export const useCompleteOnboarding = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: onboardingApi.completeOnboarding,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
+ },
+ onError: (error: ApiError) => {
+ const errorMessage = error.response?.data?.error || error.message || 'Failed to complete onboarding';
+ toast.error(errorMessage);
+ },
+ });
+};
diff --git a/frontend/src/features/onboarding/index.ts b/frontend/src/features/onboarding/index.ts
new file mode 100644
index 0000000..0857133
--- /dev/null
+++ b/frontend/src/features/onboarding/index.ts
@@ -0,0 +1,28 @@
+/**
+ * @ai-summary Public API exports for onboarding feature
+ */
+
+// Pages
+export { OnboardingPage } from './pages/OnboardingPage';
+
+// Mobile
+export { OnboardingMobileScreen } from './mobile/OnboardingMobileScreen';
+
+// Components
+export { PreferencesStep } from './components/PreferencesStep';
+export { AddVehicleStep } from './components/AddVehicleStep';
+export { CompleteStep } from './components/CompleteStep';
+
+// Hooks
+export {
+ useOnboardingStatus,
+ useSavePreferences,
+ useCompleteOnboarding,
+} from './hooks/useOnboarding';
+
+// Types
+export type {
+ OnboardingPreferences,
+ OnboardingStatus,
+ OnboardingStep,
+} from './types/onboarding.types';
diff --git a/frontend/src/features/onboarding/mobile/OnboardingMobileScreen.tsx b/frontend/src/features/onboarding/mobile/OnboardingMobileScreen.tsx
new file mode 100644
index 0000000..fe237e2
--- /dev/null
+++ b/frontend/src/features/onboarding/mobile/OnboardingMobileScreen.tsx
@@ -0,0 +1,150 @@
+/**
+ * @ai-summary Mobile onboarding screen with multi-step wizard
+ */
+
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
+import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
+import { useSavePreferences, useCompleteOnboarding } from '../hooks/useOnboarding';
+import { PreferencesStep } from '../components/PreferencesStep';
+import { AddVehicleStep } from '../components/AddVehicleStep';
+import { CompleteStep } from '../components/CompleteStep';
+import { OnboardingStep, OnboardingPreferences } from '../types/onboarding.types';
+import { CreateVehicleRequest } from '../../vehicles/types/vehicles.types';
+import { vehiclesApi } from '../../vehicles/api/vehicles.api';
+import toast from 'react-hot-toast';
+
+export const OnboardingMobileScreen: React.FC = () => {
+ const navigate = useNavigate();
+ const [currentStep, setCurrentStep] = useState('preferences');
+ const savePreferences = useSavePreferences();
+ const completeOnboarding = useCompleteOnboarding();
+ const [isAddingVehicle, setIsAddingVehicle] = useState(false);
+
+ const stepNumbers: Record = {
+ preferences: 1,
+ vehicle: 2,
+ complete: 3,
+ };
+
+ const handleSavePreferences = async (data: OnboardingPreferences) => {
+ try {
+ await savePreferences.mutateAsync(data);
+ setCurrentStep('vehicle');
+ } catch (error) {
+ // Error is handled by the mutation hook
+ }
+ };
+
+ const handleAddVehicle = async (data: CreateVehicleRequest) => {
+ setIsAddingVehicle(true);
+ try {
+ await vehiclesApi.create(data);
+ toast.success('Vehicle added successfully');
+ setCurrentStep('complete');
+ } catch (error: any) {
+ toast.error(error.response?.data?.error || 'Failed to add vehicle');
+ } finally {
+ setIsAddingVehicle(false);
+ }
+ };
+
+ const handleSkipVehicle = () => {
+ setCurrentStep('complete');
+ };
+
+ const handleComplete = async () => {
+ try {
+ await completeOnboarding.mutateAsync();
+ navigate('/vehicles');
+ } catch (error) {
+ // Error is handled by the mutation hook
+ }
+ };
+
+ const handleBack = () => {
+ if (currentStep === 'vehicle') {
+ setCurrentStep('preferences');
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
Welcome to MotoVault Pro
+
Let's set up your account
+
+
+ {/* Progress Indicator */}
+
+ {(['preferences', 'vehicle', 'complete'] as OnboardingStep[]).map((step, index) => (
+
+
+
= stepNumbers[step]
+ ? 'bg-primary-600 text-white'
+ : 'bg-gray-200 text-gray-500'
+ }`}
+ >
+ {stepNumbers[step]}
+
+
= stepNumbers[step]
+ ? 'text-primary-600'
+ : 'text-gray-500'
+ }`}
+ >
+ {step === 'preferences' && 'Setup'}
+ {step === 'vehicle' && 'Vehicle'}
+ {step === 'complete' && 'Done'}
+
+
+ {index < 2 && (
+ stepNumbers[step]
+ ? 'bg-primary-600'
+ : 'bg-gray-200'
+ }`}
+ />
+ )}
+
+ ))}
+
+
+ {/* Step Content */}
+
+ {currentStep === 'preferences' && (
+
+ )}
+
+ {currentStep === 'vehicle' && (
+
+ )}
+
+ {currentStep === 'complete' && (
+
+ )}
+
+
+
+ );
+};
+
+export default OnboardingMobileScreen;
diff --git a/frontend/src/features/onboarding/pages/OnboardingPage.tsx b/frontend/src/features/onboarding/pages/OnboardingPage.tsx
new file mode 100644
index 0000000..0e6ddcd
--- /dev/null
+++ b/frontend/src/features/onboarding/pages/OnboardingPage.tsx
@@ -0,0 +1,147 @@
+/**
+ * @ai-summary Desktop onboarding page with multi-step wizard
+ */
+
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useSavePreferences, useCompleteOnboarding } from '../hooks/useOnboarding';
+import { PreferencesStep } from '../components/PreferencesStep';
+import { AddVehicleStep } from '../components/AddVehicleStep';
+import { CompleteStep } from '../components/CompleteStep';
+import { OnboardingStep, OnboardingPreferences } from '../types/onboarding.types';
+import { CreateVehicleRequest } from '../../vehicles/types/vehicles.types';
+import { vehiclesApi } from '../../vehicles/api/vehicles.api';
+import toast from 'react-hot-toast';
+
+export const OnboardingPage: React.FC = () => {
+ const navigate = useNavigate();
+ const [currentStep, setCurrentStep] = useState
('preferences');
+ const savePreferences = useSavePreferences();
+ const completeOnboarding = useCompleteOnboarding();
+ const [isAddingVehicle, setIsAddingVehicle] = useState(false);
+
+ const stepNumbers: Record = {
+ preferences: 1,
+ vehicle: 2,
+ complete: 3,
+ };
+
+ const handleSavePreferences = async (data: OnboardingPreferences) => {
+ try {
+ await savePreferences.mutateAsync(data);
+ setCurrentStep('vehicle');
+ } catch (error) {
+ // Error is handled by the mutation hook
+ }
+ };
+
+ const handleAddVehicle = async (data: CreateVehicleRequest) => {
+ setIsAddingVehicle(true);
+ try {
+ await vehiclesApi.create(data);
+ toast.success('Vehicle added successfully');
+ setCurrentStep('complete');
+ } catch (error: any) {
+ toast.error(error.response?.data?.error || 'Failed to add vehicle');
+ } finally {
+ setIsAddingVehicle(false);
+ }
+ };
+
+ const handleSkipVehicle = () => {
+ setCurrentStep('complete');
+ };
+
+ const handleComplete = async () => {
+ try {
+ await completeOnboarding.mutateAsync();
+ navigate('/vehicles');
+ } catch (error) {
+ // Error is handled by the mutation hook
+ }
+ };
+
+ const handleBack = () => {
+ if (currentStep === 'vehicle') {
+ setCurrentStep('preferences');
+ }
+ };
+
+ return (
+
+
+ {/* Progress Indicator */}
+
+
+ {(['preferences', 'vehicle', 'complete'] as OnboardingStep[]).map((step, index) => (
+
+
+
= stepNumbers[step]
+ ? 'bg-primary-600 text-white'
+ : 'bg-gray-200 text-gray-500'
+ }`}
+ >
+ {stepNumbers[step]}
+
+
= stepNumbers[step]
+ ? 'text-primary-600'
+ : 'text-gray-500'
+ }`}
+ >
+ {step === 'preferences' && 'Preferences'}
+ {step === 'vehicle' && 'Add Vehicle'}
+ {step === 'complete' && 'Complete'}
+
+
+ {index < 2 && (
+ stepNumbers[step]
+ ? 'bg-primary-600'
+ : 'bg-gray-200'
+ }`}
+ />
+ )}
+
+ ))}
+
+
+ Step {stepNumbers[currentStep]} of 3
+
+
+
+ {/* Step Content */}
+
+ {currentStep === 'preferences' && (
+
+ )}
+
+ {currentStep === 'vehicle' && (
+
+ )}
+
+ {currentStep === 'complete' && (
+
+ )}
+
+
+
+ );
+};
+
+export default OnboardingPage;
diff --git a/frontend/src/features/onboarding/types/onboarding.types.ts b/frontend/src/features/onboarding/types/onboarding.types.ts
new file mode 100644
index 0000000..72d3dde
--- /dev/null
+++ b/frontend/src/features/onboarding/types/onboarding.types.ts
@@ -0,0 +1,17 @@
+/**
+ * @ai-summary TypeScript types for onboarding feature
+ */
+
+export interface OnboardingPreferences {
+ unitSystem: 'imperial' | 'metric';
+ currencyCode: string;
+ timeZone: string;
+}
+
+export interface OnboardingStatus {
+ preferencesSet: boolean;
+ onboardingCompleted: boolean;
+ onboardingCompletedAt: string | null;
+}
+
+export type OnboardingStep = 'preferences' | 'vehicle' | 'complete';
diff --git a/frontend/src/features/settings/api/profile.api.ts b/frontend/src/features/settings/api/profile.api.ts
index fd08861..1be7be1 100644
--- a/frontend/src/features/settings/api/profile.api.ts
+++ b/frontend/src/features/settings/api/profile.api.ts
@@ -3,9 +3,19 @@
*/
import { apiClient } from '../../../core/api/client';
-import { UserProfile, UpdateProfileRequest } from '../types/profile.types';
+import {
+ UserProfile,
+ UpdateProfileRequest,
+ DeletionStatus,
+ RequestDeletionRequest,
+ RequestDeletionResponse,
+ CancelDeletionResponse,
+} from '../types/profile.types';
export const profileApi = {
getProfile: () => apiClient.get
('/user/profile'),
updateProfile: (data: UpdateProfileRequest) => apiClient.put('/user/profile', data),
+ requestDeletion: (data: RequestDeletionRequest) => apiClient.post('/user/delete', data),
+ cancelDeletion: () => apiClient.post('/user/cancel-deletion'),
+ getDeletionStatus: () => apiClient.get('/user/deletion-status'),
};
diff --git a/frontend/src/features/settings/components/DeleteAccountDialog.tsx b/frontend/src/features/settings/components/DeleteAccountDialog.tsx
new file mode 100644
index 0000000..14adb79
--- /dev/null
+++ b/frontend/src/features/settings/components/DeleteAccountDialog.tsx
@@ -0,0 +1,102 @@
+/**
+ * @ai-summary Desktop dialog for requesting account deletion with 30-day grace period
+ */
+
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ Button,
+ Alert,
+ CircularProgress,
+ Typography,
+ Box,
+} from '@mui/material';
+import { useRequestDeletion } from '../hooks/useDeletion';
+
+interface DeleteAccountDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const DeleteAccountDialog: React.FC = ({ open, onClose }) => {
+ const [password, setPassword] = useState('');
+ const [confirmationText, setConfirmationText] = useState('');
+ const requestDeletionMutation = useRequestDeletion();
+
+ // Clear form when dialog closes
+ useEffect(() => {
+ if (!open) {
+ setPassword('');
+ setConfirmationText('');
+ }
+ }, [open]);
+
+ const handleSubmit = async () => {
+ if (!password || confirmationText !== 'DELETE') {
+ return;
+ }
+
+ await requestDeletionMutation.mutateAsync({ password, confirmationText });
+ onClose();
+ };
+
+ const isValid = password.length > 0 && confirmationText === 'DELETE';
+
+ return (
+
+ );
+};
diff --git a/frontend/src/features/settings/components/PendingDeletionBanner.tsx b/frontend/src/features/settings/components/PendingDeletionBanner.tsx
new file mode 100644
index 0000000..3cf815e
--- /dev/null
+++ b/frontend/src/features/settings/components/PendingDeletionBanner.tsx
@@ -0,0 +1,52 @@
+/**
+ * @ai-summary Desktop banner showing pending account deletion with cancel option
+ */
+
+import React from 'react';
+import { Alert, AlertTitle, Button, CircularProgress, Box, Typography } from '@mui/material';
+import { useDeletionStatus, useCancelDeletion } from '../hooks/useDeletion';
+
+export const PendingDeletionBanner: React.FC = () => {
+ const { data: deletionStatus, isLoading } = useDeletionStatus();
+ const cancelDeletionMutation = useCancelDeletion();
+
+ // Don't show banner if not loading and not pending deletion
+ if (isLoading || !deletionStatus?.isPendingDeletion) {
+ return null;
+ }
+
+ const handleCancelDeletion = async () => {
+ await cancelDeletionMutation.mutateAsync();
+ };
+
+ return (
+ : null}
+ >
+ {cancelDeletionMutation.isPending ? 'Cancelling...' : 'Cancel Deletion'}
+
+ }
+ >
+ Account Deletion Pending
+
+
+ Your account is scheduled for deletion in{' '}
+ {deletionStatus.daysRemaining} {deletionStatus.daysRemaining === 1 ? 'day' : 'days'}.
+
+ {deletionStatus.deletionScheduledFor && (
+
+ Scheduled for: {new Date(deletionStatus.deletionScheduledFor).toLocaleDateString()}
+
+ )}
+
+
+ );
+};
diff --git a/frontend/src/features/settings/hooks/useDeletion.ts b/frontend/src/features/settings/hooks/useDeletion.ts
new file mode 100644
index 0000000..ffd3253
--- /dev/null
+++ b/frontend/src/features/settings/hooks/useDeletion.ts
@@ -0,0 +1,74 @@
+/**
+ * @ai-summary React hooks for account deletion functionality
+ */
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useAuth0 } from '@auth0/auth0-react';
+import { profileApi } from '../api/profile.api';
+import { RequestDeletionRequest } from '../types/profile.types';
+import toast from 'react-hot-toast';
+
+interface ApiError {
+ response?: {
+ data?: {
+ error?: string;
+ };
+ };
+ message?: string;
+}
+
+export const useDeletionStatus = () => {
+ const { isAuthenticated, isLoading } = useAuth0();
+
+ return useQuery({
+ queryKey: ['user-deletion-status'],
+ queryFn: async () => {
+ const response = await profileApi.getDeletionStatus();
+ return response.data;
+ },
+ enabled: isAuthenticated && !isLoading,
+ staleTime: 1 * 60 * 1000, // 1 minute
+ gcTime: 5 * 60 * 1000, // 5 minutes cache time
+ refetchOnWindowFocus: true,
+ refetchOnMount: true,
+ });
+};
+
+export const useRequestDeletion = () => {
+ const queryClient = useQueryClient();
+ const { logout } = useAuth0();
+
+ return useMutation({
+ mutationFn: (data: RequestDeletionRequest) => profileApi.requestDeletion(data),
+ onSuccess: (response) => {
+ queryClient.invalidateQueries({ queryKey: ['user-deletion-status'] });
+ queryClient.invalidateQueries({ queryKey: ['user-profile'] });
+ toast.success(response.data.message || 'Account deletion scheduled');
+
+ // Logout after 2 seconds
+ setTimeout(() => {
+ logout({ logoutParams: { returnTo: window.location.origin } });
+ }, 2000);
+ },
+ onError: (error: ApiError) => {
+ toast.error(error.response?.data?.error || 'Failed to request account deletion');
+ },
+ });
+};
+
+export const useCancelDeletion = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => profileApi.cancelDeletion(),
+ onSuccess: (response) => {
+ queryClient.invalidateQueries({ queryKey: ['user-deletion-status'] });
+ queryClient.invalidateQueries({ queryKey: ['user-profile'] });
+ queryClient.setQueryData(['user-profile'], response.data.profile);
+ toast.success('Welcome back! Account deletion cancelled');
+ },
+ onError: (error: ApiError) => {
+ toast.error(error.response?.data?.error || 'Failed to cancel account deletion');
+ },
+ });
+};
diff --git a/frontend/src/features/settings/mobile/DeleteAccountModal.tsx b/frontend/src/features/settings/mobile/DeleteAccountModal.tsx
new file mode 100644
index 0000000..8e7f537
--- /dev/null
+++ b/frontend/src/features/settings/mobile/DeleteAccountModal.tsx
@@ -0,0 +1,116 @@
+/**
+ * @ai-summary Mobile modal for requesting account deletion with 30-day grace period
+ */
+
+import React, { useState, useEffect } from 'react';
+import { useRequestDeletion } from '../hooks/useDeletion';
+
+interface DeleteAccountModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export const DeleteAccountModal: React.FC = ({ isOpen, onClose }) => {
+ const [password, setPassword] = useState('');
+ const [confirmationText, setConfirmationText] = useState('');
+ const requestDeletionMutation = useRequestDeletion();
+
+ // Clear form when modal closes
+ useEffect(() => {
+ if (!isOpen) {
+ setPassword('');
+ setConfirmationText('');
+ }
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ const handleSubmit = async () => {
+ if (!password || confirmationText !== 'DELETE') {
+ return;
+ }
+
+ await requestDeletionMutation.mutateAsync({ password, confirmationText });
+ onClose();
+ };
+
+ const isValid = password.length > 0 && confirmationText === 'DELETE';
+
+ return (
+
+
+
Delete Account
+
+ {/* Warning Alert */}
+
+
30-Day Grace Period
+
+ Your account will be scheduled for deletion in 30 days. You can cancel this request at any time during
+ the grace period by logging back in.
+
+
+
+ {/* Password Input */}
+
+
+
setPassword(e.target.value)}
+ placeholder="Enter your password"
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
+ style={{ fontSize: '16px', minHeight: '44px' }}
+ autoComplete="current-password"
+ />
+
Enter your password to confirm
+
+
+ {/* Confirmation Input */}
+
+
+
setConfirmationText(e.target.value)}
+ placeholder="DELETE"
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 ${
+ confirmationText.length > 0 && confirmationText !== 'DELETE'
+ ? 'border-red-300 focus:ring-red-500 focus:border-red-500'
+ : 'border-slate-300 focus:ring-red-500 focus:border-red-500'
+ }`}
+ style={{ fontSize: '16px', minHeight: '44px' }}
+ />
+
Type the word "DELETE" (all caps) to confirm
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx
index 6f4db72..4c758bb 100644
--- a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx
+++ b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx
@@ -6,6 +6,8 @@ import { useSettings } from '../hooks/useSettings';
import { useProfile, useUpdateProfile } from '../hooks/useProfile';
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
import { useNavigationStore } from '../../../core/store';
+import { DeleteAccountModal } from './DeleteAccountModal';
+import { PendingDeletionBanner } from './PendingDeletionBanner';
interface ToggleSwitchProps {
enabled: boolean;
@@ -105,11 +107,6 @@ export const MobileSettingsScreen: React.FC = () => {
setShowDataExport(false);
};
- const handleDeleteAccount = () => {
- // TODO: Implement account deletion
- console.log('Deleting account...');
- setShowDeleteConfirm(false);
- };
const handleEditProfile = () => {
setIsEditingProfile(true);
@@ -190,6 +187,9 @@ export const MobileSettingsScreen: React.FC = () => {
Manage your account and preferences
+ {/* Pending Deletion Banner */}
+
+
{/* Profile Section */}
@@ -488,30 +488,11 @@ export const MobileSettingsScreen: React.FC = () => {
- {/* Delete Account Confirmation */}
- setShowDeleteConfirm(false)}
- title="Delete Account"
- >
-
- This action cannot be undone. All your data will be permanently deleted.
-
-
-
-
-
-
+ />
);
diff --git a/frontend/src/features/settings/mobile/PendingDeletionBanner.tsx b/frontend/src/features/settings/mobile/PendingDeletionBanner.tsx
new file mode 100644
index 0000000..f885128
--- /dev/null
+++ b/frontend/src/features/settings/mobile/PendingDeletionBanner.tsx
@@ -0,0 +1,53 @@
+/**
+ * @ai-summary Mobile banner showing pending account deletion with cancel option
+ */
+
+import React from 'react';
+import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
+import { useDeletionStatus, useCancelDeletion } from '../hooks/useDeletion';
+
+export const PendingDeletionBanner: React.FC = () => {
+ const { data: deletionStatus, isLoading } = useDeletionStatus();
+ const cancelDeletionMutation = useCancelDeletion();
+
+ // Don't show banner if not loading and not pending deletion
+ if (isLoading || !deletionStatus?.isPendingDeletion) {
+ return null;
+ }
+
+ const handleCancelDeletion = async () => {
+ await cancelDeletionMutation.mutateAsync();
+ };
+
+ return (
+
+
+
+
Account Deletion Pending
+
+ Your account is scheduled for deletion in{' '}
+ {deletionStatus.daysRemaining} {deletionStatus.daysRemaining === 1 ? 'day' : 'days'}.
+
+ {deletionStatus.deletionScheduledFor && (
+
+ Scheduled for: {new Date(deletionStatus.deletionScheduledFor).toLocaleDateString()}
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/features/settings/types/profile.types.ts b/frontend/src/features/settings/types/profile.types.ts
index 7df94a4..acf0bae 100644
--- a/frontend/src/features/settings/types/profile.types.ts
+++ b/frontend/src/features/settings/types/profile.types.ts
@@ -16,3 +16,26 @@ export interface UpdateProfileRequest {
displayName?: string;
notificationEmail?: string;
}
+
+export interface DeletionStatus {
+ isPendingDeletion: boolean;
+ deletionRequestedAt: string | null;
+ deletionScheduledFor: string | null;
+ daysRemaining: number | null;
+}
+
+export interface RequestDeletionRequest {
+ password: string;
+ confirmationText: string;
+}
+
+export interface RequestDeletionResponse {
+ message: string;
+ deletionScheduledFor: string;
+ daysRemaining: number;
+}
+
+export interface CancelDeletionResponse {
+ message: string;
+ profile: UserProfile;
+}
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index 36e6be9..cf57021 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -19,6 +19,10 @@ export const HomePage = () => {
loginWithRedirect({ appState: { returnTo: '/garage' } });
};
+ const handleSignup = () => {
+ navigate('/signup');
+ };
+
return (
{/* Navigation Bar */}
@@ -44,6 +48,12 @@ export const HomePage = () => {
About
+