feat: delete users - not tested
This commit is contained in:
38
backend/package-lock.json
generated
38
backend/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<FastifyInstance> {
|
||||
@@ -82,7 +84,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
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<FastifyInstance> {
|
||||
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<FastifyInstance> {
|
||||
});
|
||||
|
||||
// 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' });
|
||||
|
||||
200
backend/src/core/auth/auth0-management.client.ts
Normal file
200
backend/src/core/auth/auth0-management.client.ts
Normal file
@@ -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<string> {
|
||||
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<UserDetails> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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();
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface UserPreferences {
|
||||
export interface CreateUserPreferencesRequest {
|
||||
userId: string;
|
||||
unitSystem?: UnitSystem;
|
||||
currencyCode?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserPreferencesRequest {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
162
backend/src/features/auth/README.md
Normal file
162
backend/src/features/auth/README.md
Normal file
@@ -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.
|
||||
122
backend/src/features/auth/api/auth.controller.ts
Normal file
122
backend/src/features/auth/api/auth.controller.ts
Normal file
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
30
backend/src/features/auth/api/auth.routes.ts
Normal file
30
backend/src/features/auth/api/auth.routes.ts
Normal file
@@ -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),
|
||||
});
|
||||
};
|
||||
23
backend/src/features/auth/api/auth.validation.ts
Normal file
23
backend/src/features/auth/api/auth.validation.ts
Normal file
@@ -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<typeof signupSchema>;
|
||||
130
backend/src/features/auth/domain/auth.service.ts
Normal file
130
backend/src/features/auth/domain/auth.service.ts
Normal file
@@ -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<SignupResponse> {
|
||||
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<VerifyStatusResponse> {
|
||||
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<ResendVerificationResponse> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
backend/src/features/auth/domain/auth.types.ts
Normal file
28
backend/src/features/auth/domain/auth.types.ts
Normal file
@@ -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;
|
||||
}
|
||||
18
backend/src/features/auth/index.ts
Normal file
18
backend/src/features/auth/index.ts
Normal file
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
205
backend/src/features/auth/tests/unit/auth.service.test.ts
Normal file
205
backend/src/features/auth/tests/unit/auth.service.test.ts
Normal file
@@ -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<UserProfileRepository>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
186
backend/src/features/onboarding/README.md
Normal file
186
backend/src/features/onboarding/README.md
Normal file
@@ -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)
|
||||
143
backend/src/features/onboarding/api/onboarding.controller.ts
Normal file
143
backend/src/features/onboarding/api/onboarding.controller.ts
Normal file
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
33
backend/src/features/onboarding/api/onboarding.routes.ts
Normal file
33
backend/src/features/onboarding/api/onboarding.routes.ts
Normal file
@@ -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),
|
||||
});
|
||||
};
|
||||
21
backend/src/features/onboarding/api/onboarding.validation.ts
Normal file
21
backend/src/features/onboarding/api/onboarding.validation.ts
Normal file
@@ -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<typeof savePreferencesSchema>;
|
||||
155
backend/src/features/onboarding/domain/onboarding.service.ts
Normal file
155
backend/src/features/onboarding/domain/onboarding.service.ts
Normal file
@@ -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<OnboardingPreferences> {
|
||||
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<Date> {
|
||||
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<OnboardingStatus> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
backend/src/features/onboarding/domain/onboarding.types.ts
Normal file
40
backend/src/features/onboarding/domain/onboarding.types.ts
Normal file
@@ -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;
|
||||
}
|
||||
20
backend/src/features/onboarding/index.ts
Normal file
20
backend/src/features/onboarding/index.ts
Normal file
@@ -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';
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,3 +16,12 @@ export const updateProfileSchema = z.object({
|
||||
);
|
||||
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||
|
||||
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<typeof requestDeletionSchema>;
|
||||
|
||||
@@ -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<UserProfile> {
|
||||
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<UserProfile> {
|
||||
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<UserProfile> {
|
||||
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<UserProfile> {
|
||||
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<UserProfile> {
|
||||
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<UserProfile[]> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UserProfile> {
|
||||
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<UserProfile> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
87
backend/src/features/user-profile/jobs/account-purge.job.ts
Normal file
87
backend/src/features/user-profile/jobs/account-purge.job.ts
Normal file
@@ -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<PurgeResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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)';
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<ThemeProvider theme={md3Theme}>
|
||||
<CssBaseline />
|
||||
<React.Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
}>
|
||||
{mobileMode ? <SignupMobileScreen /> : <SignupPage />}
|
||||
</React.Suspense>
|
||||
<DebugInfo />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Verify email and onboarding routes require authentication but not full initialization
|
||||
if (isVerifyEmailRoute) {
|
||||
return (
|
||||
<ThemeProvider theme={md3Theme}>
|
||||
<CssBaseline />
|
||||
<React.Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
}>
|
||||
{mobileMode ? <VerifyEmailMobileScreen /> : <VerifyEmailPage />}
|
||||
</React.Suspense>
|
||||
<DebugInfo />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOnboardingRoute) {
|
||||
return (
|
||||
<ThemeProvider theme={md3Theme}>
|
||||
<CssBaseline />
|
||||
<React.Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
}>
|
||||
{mobileMode ? <OnboardingMobileScreen /> : <OnboardingPage />}
|
||||
</React.Suspense>
|
||||
<DebugInfo />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<ManagedUser | null>(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
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!selectedUser.isAdmin && (
|
||||
<button
|
||||
onClick={handleHardDeleteClick}
|
||||
className="w-full py-3 text-left text-red-600 font-medium min-h-[44px]"
|
||||
>
|
||||
Delete Permanently
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -716,6 +758,82 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Hard Delete Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={showHardDeleteModal}
|
||||
onClose={() => !hardDeleteMutation.isPending && handleHardDeleteCancel()}
|
||||
title="Permanently Delete User"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={handleHardDeleteCancel}
|
||||
disabled={hardDeleteMutation.isPending}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleHardDeleteConfirm}
|
||||
disabled={hardDeleteMutation.isPending || hardDeleteConfirmText !== 'DELETE'}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
||||
>
|
||||
{hardDeleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<p className="text-sm font-semibold text-red-800">
|
||||
Warning: This action cannot be undone!
|
||||
</p>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
All user data will be permanently deleted, including vehicles, fuel logs,
|
||||
maintenance records, and documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-600">
|
||||
Are you sure you want to permanently delete{' '}
|
||||
<strong>{selectedUser?.email}</strong>?
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">
|
||||
Reason for deletion
|
||||
</label>
|
||||
<textarea
|
||||
value={hardDeleteReason}
|
||||
onChange={(e) => setHardDeleteReason(e.target.value)}
|
||||
placeholder="GDPR request, user request, etc..."
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 resize-none min-h-[60px]"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">
|
||||
Type <strong>DELETE</strong> to confirm
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hardDeleteConfirmText}
|
||||
onChange={(e) => 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' && (
|
||||
<p className="text-sm text-red-500 mt-1">Please type DELETE exactly</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
40
frontend/src/features/auth/api/auth.api.ts
Normal file
40
frontend/src/features/auth/api/auth.api.ts
Normal file
@@ -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<SignupResponse> => {
|
||||
const response = await unauthenticatedClient.post('/auth/signup', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getVerifyStatus: async (): Promise<VerifyStatusResponse> => {
|
||||
const response = await apiClient.get('/auth/verify-status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
resendVerification: async (): Promise<ResendVerificationResponse> => {
|
||||
const response = await apiClient.post('/auth/resend-verification');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
148
frontend/src/features/auth/components/SignupForm.tsx
Normal file
148
frontend/src/features/auth/components/SignupForm.tsx
Normal file
@@ -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<SignupFormProps> = ({ onSubmit, loading }) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<SignupRequest & { confirmPassword: string }>({
|
||||
resolver: zodResolver(signupSchema),
|
||||
});
|
||||
|
||||
const handleFormSubmit = (data: SignupRequest & { confirmPassword: string }) => {
|
||||
const { email, password } = data;
|
||||
onSubmit({ email, password });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email Address <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
inputMode="email"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
placeholder="your.email@example.com"
|
||||
style={{ fontSize: '16px' }}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...register('password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
placeholder="At least 8 characters"
|
||||
style={{ fontSize: '16px' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 focus:outline-none min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Must be at least 8 characters with one uppercase letter and one number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...register('confirmPassword')}
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
placeholder="Re-enter your password"
|
||||
style={{ fontSize: '16px' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 focus:outline-none min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button type="submit" loading={loading} className="w-full min-h-[44px]">
|
||||
Create Account
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
32
frontend/src/features/auth/hooks/useSignup.ts
Normal file
32
frontend/src/features/auth/hooks/useSignup.ts
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
54
frontend/src/features/auth/hooks/useVerifyStatus.ts
Normal file
54
frontend/src/features/auth/hooks/useVerifyStatus.ts
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
24
frontend/src/features/auth/index.ts
Normal file
24
frontend/src/features/auth/index.ts
Normal file
@@ -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';
|
||||
54
frontend/src/features/auth/mobile/SignupMobileScreen.tsx
Normal file
54
frontend/src/features/auth/mobile/SignupMobileScreen.tsx
Normal file
@@ -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 (
|
||||
<MobileContainer>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="text-center pt-8 pb-4">
|
||||
<h1 className="text-3xl font-bold text-primary-600 mb-2">MotoVaultPro</h1>
|
||||
<h2 className="text-xl font-semibold text-slate-800">Create Your Account</h2>
|
||||
<p className="text-sm text-slate-600 mt-2">
|
||||
Start tracking your vehicle maintenance and fuel logs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<SignupForm onSubmit={handleSubmit} loading={isPending} />
|
||||
</GlassCard>
|
||||
|
||||
<div className="text-center text-sm text-slate-600 pb-8">
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-primary-600 hover:text-primary-700 font-medium focus:outline-none focus:underline min-h-[44px] inline-flex items-center"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignupMobileScreen;
|
||||
@@ -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 (
|
||||
<MobileContainer>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-lg text-slate-600">Loading...</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="text-center pt-8 pb-4">
|
||||
<div className="mx-auto w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-primary-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 mb-2">Check Your Email</h1>
|
||||
<p className="text-slate-600">
|
||||
We've sent a verification link to
|
||||
</p>
|
||||
<p className="text-primary-600 font-medium mt-1 break-words px-4">
|
||||
{verifyStatus?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-sm text-slate-700">
|
||||
<p className="mb-2">Click the link in the email to verify your account.</p>
|
||||
<p>Once verified, you'll be automatically redirected to complete your profile.</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-slate-600 mb-3">Didn't receive the email?</p>
|
||||
<Button
|
||||
onClick={handleResend}
|
||||
loading={isResending}
|
||||
variant="secondary"
|
||||
className="w-full min-h-[44px]"
|
||||
>
|
||||
Resend Verification Email
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div className="text-center text-sm text-slate-500 pb-8 px-4">
|
||||
<p>Check your spam folder if you don't see the email in your inbox.</p>
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyEmailMobileScreen;
|
||||
50
frontend/src/features/auth/pages/SignupPage.tsx
Normal file
50
frontend/src/features/auth/pages/SignupPage.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary-600 mb-2">MotoVaultPro</h1>
|
||||
<h2 className="text-xl font-semibold text-gray-800">Create Your Account</h2>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Start tracking your vehicle maintenance and fuel logs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SignupForm onSubmit={handleSubmit} loading={isPending} />
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-primary-600 hover:text-primary-700 font-medium focus:outline-none focus:underline"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
90
frontend/src/features/auth/pages/VerifyEmailPage.tsx
Normal file
90
frontend/src/features/auth/pages/VerifyEmailPage.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4">
|
||||
<div className="text-lg text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-primary-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">Check Your Email</h1>
|
||||
<p className="text-gray-600">
|
||||
We've sent a verification link to
|
||||
</p>
|
||||
<p className="text-primary-600 font-medium mt-1">
|
||||
{verifyStatus?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-sm text-gray-700">
|
||||
<p className="mb-2">Click the link in the email to verify your account.</p>
|
||||
<p>Once verified, you'll be automatically redirected to complete your profile.</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 mb-3">Didn't receive the email?</p>
|
||||
<Button
|
||||
onClick={handleResend}
|
||||
loading={isResending}
|
||||
variant="secondary"
|
||||
className="w-full min-h-[44px]"
|
||||
>
|
||||
Resend Verification Email
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
<p>Check your spam folder if you don't see the email in your inbox.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
frontend/src/features/auth/types/auth.types.ts
Normal file
23
frontend/src/features/auth/types/auth.types.ts
Normal file
@@ -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;
|
||||
}
|
||||
23
frontend/src/features/onboarding/api/onboarding.api.ts
Normal file
23
frontend/src/features/onboarding/api/onboarding.api.ts
Normal file
@@ -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<OnboardingStatus>('/onboarding/status');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
114
frontend/src/features/onboarding/components/AddVehicleStep.tsx
Normal file
114
frontend/src/features/onboarding/components/AddVehicleStep.tsx
Normal file
@@ -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<AddVehicleStepProps> = ({
|
||||
onNext,
|
||||
onAddVehicle,
|
||||
onBack,
|
||||
loading,
|
||||
}) => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const handleSkip = () => {
|
||||
onNext();
|
||||
};
|
||||
|
||||
const handleAddVehicle = (data: CreateVehicleRequest) => {
|
||||
onAddVehicle(data);
|
||||
};
|
||||
|
||||
if (!showForm) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-20 h-20 bg-primary-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-10 h-10 text-primary-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Add Your First Vehicle</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Add a vehicle now or skip this step and add it later from your garage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="w-full min-h-[44px]"
|
||||
>
|
||||
Add Vehicle
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSkip}
|
||||
className="w-full min-h-[44px]"
|
||||
>
|
||||
Skip for Now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="min-h-[44px]"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Add Your First Vehicle</h2>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Fill in the details below. You can always edit this later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VehicleForm
|
||||
onSubmit={handleAddVehicle}
|
||||
onCancel={() => setShowForm(false)}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="min-h-[44px]"
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
70
frontend/src/features/onboarding/components/CompleteStep.tsx
Normal file
70
frontend/src/features/onboarding/components/CompleteStep.tsx
Normal file
@@ -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<CompleteStepProps> = ({ onComplete, loading }) => {
|
||||
return (
|
||||
<div className="space-y-6 text-center py-8">
|
||||
<div className="mx-auto w-24 h-24 bg-green-100 rounded-full flex items-center justify-center animate-bounce">
|
||||
<svg
|
||||
className="w-12 h-12 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800 mb-2">You're All Set!</h2>
|
||||
<p className="text-slate-600 max-w-md mx-auto">
|
||||
Welcome to MotoVault Pro. Your account is ready and you can now start tracking your vehicles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary-50 rounded-lg p-6 max-w-md mx-auto">
|
||||
<h3 className="font-semibold text-primary-900 mb-2">What's Next?</h3>
|
||||
<ul className="text-left space-y-2 text-sm text-primary-800">
|
||||
<li className="flex items-start">
|
||||
<svg className="w-5 h-5 text-primary-600 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Add or manage your vehicles in the garage</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<svg className="w-5 h-5 text-primary-600 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Track fuel logs and maintenance records</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<svg className="w-5 h-5 text-primary-600 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Upload important vehicle documents</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<Button onClick={onComplete} loading={loading} className="min-h-[44px] px-8">
|
||||
Go to My Garage
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
141
frontend/src/features/onboarding/components/PreferencesStep.tsx
Normal file
141
frontend/src/features/onboarding/components/PreferencesStep.tsx
Normal file
@@ -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<PreferencesStepProps> = ({ onNext, loading }) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<OnboardingPreferences>({
|
||||
resolver: zodResolver(preferencesSchema),
|
||||
defaultValues: {
|
||||
unitSystem: 'imperial',
|
||||
currencyCode: 'USD',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
});
|
||||
|
||||
const unitSystem = watch('unitSystem');
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onNext)} className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-4">Set Your Preferences</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Choose your preferred units and settings to personalize your experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Unit System Toggle */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">
|
||||
Unit System
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('unitSystem', 'imperial')}
|
||||
className={`min-h-[44px] py-3 px-4 rounded-lg border-2 font-medium transition-all ${
|
||||
unitSystem === 'imperial'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">Imperial</div>
|
||||
<div className="text-xs mt-1">Miles & Gallons</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('unitSystem', 'metric')}
|
||||
className={`min-h-[44px] py-3 px-4 rounded-lg border-2 font-medium transition-all ${
|
||||
unitSystem === 'metric'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">Metric</div>
|
||||
<div className="text-xs mt-1">Kilometers & Liters</div>
|
||||
</button>
|
||||
</div>
|
||||
{errors.unitSystem && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.unitSystem.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Currency Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
{...register('currencyCode')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
style={{ fontSize: '16px' }}
|
||||
>
|
||||
<option value="USD">USD - US Dollar</option>
|
||||
<option value="EUR">EUR - Euro</option>
|
||||
<option value="GBP">GBP - British Pound</option>
|
||||
<option value="CAD">CAD - Canadian Dollar</option>
|
||||
<option value="AUD">AUD - Australian Dollar</option>
|
||||
</select>
|
||||
{errors.currencyCode && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.currencyCode.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timezone Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Time Zone
|
||||
</label>
|
||||
<select
|
||||
{...register('timeZone')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
style={{ fontSize: '16px' }}
|
||||
>
|
||||
<option value="America/New_York">Eastern Time (ET)</option>
|
||||
<option value="America/Chicago">Central Time (CT)</option>
|
||||
<option value="America/Denver">Mountain Time (MT)</option>
|
||||
<option value="America/Los_Angeles">Pacific Time (PT)</option>
|
||||
<option value="America/Phoenix">Arizona Time (MST)</option>
|
||||
<option value="America/Anchorage">Alaska Time (AKT)</option>
|
||||
<option value="Pacific/Honolulu">Hawaii Time (HST)</option>
|
||||
<option value="Europe/London">London (GMT/BST)</option>
|
||||
<option value="Europe/Paris">Paris (CET/CEST)</option>
|
||||
<option value="Asia/Tokyo">Tokyo (JST)</option>
|
||||
<option value="Australia/Sydney">Sydney (AEST/AEDT)</option>
|
||||
</select>
|
||||
{errors.timeZone && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.timeZone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" loading={loading} className="min-h-[44px]">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
63
frontend/src/features/onboarding/hooks/useOnboarding.ts
Normal file
63
frontend/src/features/onboarding/hooks/useOnboarding.ts
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
28
frontend/src/features/onboarding/index.ts
Normal file
28
frontend/src/features/onboarding/index.ts
Normal file
@@ -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';
|
||||
@@ -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<OnboardingStep>('preferences');
|
||||
const savePreferences = useSavePreferences();
|
||||
const completeOnboarding = useCompleteOnboarding();
|
||||
const [isAddingVehicle, setIsAddingVehicle] = useState(false);
|
||||
|
||||
const stepNumbers: Record<OnboardingStep, number> = {
|
||||
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 (
|
||||
<MobileContainer>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center pt-4">
|
||||
<h1 className="text-2xl font-bold text-slate-800 mb-2">Welcome to MotoVault Pro</h1>
|
||||
<p className="text-slate-600 text-sm">Let's set up your account</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="flex items-center justify-between px-4">
|
||||
{(['preferences', 'vehicle', 'complete'] as OnboardingStep[]).map((step, index) => (
|
||||
<React.Fragment key={step}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm transition-all ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{stepNumbers[step]}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs mt-1 font-medium ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{step === 'preferences' && 'Setup'}
|
||||
{step === 'vehicle' && 'Vehicle'}
|
||||
{step === 'complete' && 'Done'}
|
||||
</span>
|
||||
</div>
|
||||
{index < 2 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 rounded transition-all ${
|
||||
stepNumbers[currentStep] > stepNumbers[step]
|
||||
? 'bg-primary-600'
|
||||
: 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<GlassCard padding="md">
|
||||
{currentStep === 'preferences' && (
|
||||
<PreferencesStep
|
||||
onNext={handleSavePreferences}
|
||||
loading={savePreferences.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'vehicle' && (
|
||||
<AddVehicleStep
|
||||
onNext={handleSkipVehicle}
|
||||
onAddVehicle={handleAddVehicle}
|
||||
onBack={handleBack}
|
||||
loading={isAddingVehicle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'complete' && (
|
||||
<CompleteStep
|
||||
onComplete={handleComplete}
|
||||
loading={completeOnboarding.isPending}
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingMobileScreen;
|
||||
147
frontend/src/features/onboarding/pages/OnboardingPage.tsx
Normal file
147
frontend/src/features/onboarding/pages/OnboardingPage.tsx
Normal file
@@ -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<OnboardingStep>('preferences');
|
||||
const savePreferences = useSavePreferences();
|
||||
const completeOnboarding = useCompleteOnboarding();
|
||||
const [isAddingVehicle, setIsAddingVehicle] = useState(false);
|
||||
|
||||
const stepNumbers: Record<OnboardingStep, number> = {
|
||||
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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Progress Indicator */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{(['preferences', 'vehicle', 'complete'] as OnboardingStep[]).map((step, index) => (
|
||||
<React.Fragment key={step}>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold transition-all ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{stepNumbers[step]}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 text-sm font-medium hidden sm:inline ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{step === 'preferences' && 'Preferences'}
|
||||
{step === 'vehicle' && 'Add Vehicle'}
|
||||
{step === 'complete' && 'Complete'}
|
||||
</span>
|
||||
</div>
|
||||
{index < 2 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 rounded transition-all ${
|
||||
stepNumbers[currentStep] > stepNumbers[step]
|
||||
? 'bg-primary-600'
|
||||
: 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 text-center mt-4">
|
||||
Step {stepNumbers[currentStep]} of 3
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-slate-200 p-6 md:p-8">
|
||||
{currentStep === 'preferences' && (
|
||||
<PreferencesStep
|
||||
onNext={handleSavePreferences}
|
||||
loading={savePreferences.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'vehicle' && (
|
||||
<AddVehicleStep
|
||||
onNext={handleSkipVehicle}
|
||||
onAddVehicle={handleAddVehicle}
|
||||
onBack={handleBack}
|
||||
loading={isAddingVehicle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'complete' && (
|
||||
<CompleteStep
|
||||
onComplete={handleComplete}
|
||||
loading={completeOnboarding.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingPage;
|
||||
17
frontend/src/features/onboarding/types/onboarding.types.ts
Normal file
17
frontend/src/features/onboarding/types/onboarding.types.ts
Normal file
@@ -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';
|
||||
@@ -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<UserProfile>('/user/profile'),
|
||||
updateProfile: (data: UpdateProfileRequest) => apiClient.put<UserProfile>('/user/profile', data),
|
||||
requestDeletion: (data: RequestDeletionRequest) => apiClient.post<RequestDeletionResponse>('/user/delete', data),
|
||||
cancelDeletion: () => apiClient.post<CancelDeletionResponse>('/user/cancel-deletion'),
|
||||
getDeletionStatus: () => apiClient.get<DeletionStatus>('/user/deletion-status'),
|
||||
};
|
||||
|
||||
@@ -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<DeleteAccountDialogProps> = ({ 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 (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Delete Account</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
30-Day Grace Period
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
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.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="current-password"
|
||||
helperText="Enter your password to confirm"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Type DELETE to confirm"
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
helperText='Type the word "DELETE" (all caps) to confirm'
|
||||
error={confirmationText.length > 0 && confirmationText !== 'DELETE'}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={requestDeletionMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={!isValid || requestDeletionMutation.isPending}
|
||||
startIcon={requestDeletionMutation.isPending ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{requestDeletionMutation.isPending ? 'Deleting...' : 'Delete Account'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{ mb: 3 }}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={handleCancelDeletion}
|
||||
disabled={cancelDeletionMutation.isPending}
|
||||
startIcon={cancelDeletionMutation.isPending ? <CircularProgress size={16} /> : null}
|
||||
>
|
||||
{cancelDeletionMutation.isPending ? 'Cancelling...' : 'Cancel Deletion'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<AlertTitle>Account Deletion Pending</AlertTitle>
|
||||
<Box>
|
||||
<Typography variant="body2">
|
||||
Your account is scheduled for deletion in{' '}
|
||||
<strong>{deletionStatus.daysRemaining} {deletionStatus.daysRemaining === 1 ? 'day' : 'days'}</strong>.
|
||||
</Typography>
|
||||
{deletionStatus.deletionScheduledFor && (
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||
Scheduled for: {new Date(deletionStatus.deletionScheduledFor).toLocaleDateString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
74
frontend/src/features/settings/hooks/useDeletion.ts
Normal file
74
frontend/src/features/settings/hooks/useDeletion.ts
Normal file
@@ -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');
|
||||
},
|
||||
});
|
||||
};
|
||||
116
frontend/src/features/settings/mobile/DeleteAccountModal.tsx
Normal file
116
frontend/src/features/settings/mobile/DeleteAccountModal.tsx
Normal file
@@ -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<DeleteAccountModalProps> = ({ 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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h3 className="text-xl font-semibold text-slate-800 mb-4">Delete Account</h3>
|
||||
|
||||
{/* Warning Alert */}
|
||||
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="font-semibold text-amber-900 mb-2">30-Day Grace Period</p>
|
||||
<p className="text-sm text-amber-800">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Enter your password to confirm</p>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Input */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Type DELETE to confirm
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmationText}
|
||||
onChange={(e) => 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' }}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Type the word "DELETE" (all caps) to confirm</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={requestDeletionMutation.isPending}
|
||||
className="flex-1 py-2.5 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors disabled:opacity-50"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || requestDeletionMutation.isPending}
|
||||
className="flex-1 py-2.5 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{requestDeletionMutation.isPending ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
) : (
|
||||
'Delete Account'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 = () => {
|
||||
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Pending Deletion Banner */}
|
||||
<PendingDeletionBanner />
|
||||
|
||||
{/* Profile Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
@@ -488,30 +488,11 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Account Confirmation */}
|
||||
<Modal
|
||||
{/* Delete Account Modal */}
|
||||
<DeleteAccountModal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title="Delete Account"
|
||||
>
|
||||
<p className="text-slate-600 mb-4">
|
||||
This action cannot be undone. All your data will be permanently deleted.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
className="flex-1 py-2 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
/>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<GlassCard padding="md" className="bg-amber-50/80 border-amber-200/70">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-amber-900 mb-1">Account Deletion Pending</h3>
|
||||
<p className="text-sm text-amber-800">
|
||||
Your account is scheduled for deletion in{' '}
|
||||
<strong>{deletionStatus.daysRemaining} {deletionStatus.daysRemaining === 1 ? 'day' : 'days'}</strong>.
|
||||
</p>
|
||||
{deletionStatus.deletionScheduledFor && (
|
||||
<p className="text-xs text-amber-700 mt-1">
|
||||
Scheduled for: {new Date(deletionStatus.deletionScheduledFor).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCancelDeletion}
|
||||
disabled={cancelDeletionMutation.isPending}
|
||||
className="w-full py-2.5 px-4 bg-amber-600 text-white rounded-lg font-medium hover:bg-amber-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{cancelDeletionMutation.isPending ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
) : (
|
||||
'Cancel Deletion'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ export const HomePage = () => {
|
||||
loginWithRedirect({ appState: { returnTo: '/garage' } });
|
||||
};
|
||||
|
||||
const handleSignup = () => {
|
||||
navigate('/signup');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Navigation Bar */}
|
||||
@@ -44,6 +48,12 @@ export const HomePage = () => {
|
||||
<a href="#about" className="text-gray-700 hover:text-primary-500 transition-colors">
|
||||
About
|
||||
</a>
|
||||
<button
|
||||
onClick={handleSignup}
|
||||
className="border-2 border-primary-500 text-primary-500 hover:bg-primary-50 font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAuthAction}
|
||||
className="bg-primary-500 hover:bg-primary-700 text-white font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
|
||||
@@ -103,6 +113,12 @@ export const HomePage = () => {
|
||||
>
|
||||
About
|
||||
</a>
|
||||
<button
|
||||
onClick={handleSignup}
|
||||
className="w-full border-2 border-primary-500 text-primary-500 hover:bg-primary-50 font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAuthAction}
|
||||
className="w-full bg-primary-500 hover:bg-primary-700 text-white font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useUnits } from '../core/units/UnitsContext';
|
||||
import { useAdminAccess } from '../core/auth/useAdminAccess';
|
||||
import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile';
|
||||
import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog';
|
||||
import { PendingDeletionBanner } from '../features/settings/components/PendingDeletionBanner';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -52,6 +54,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
||||
const [editedDisplayName, setEditedDisplayName] = useState('');
|
||||
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
// Initialize edit form when profile loads or edit mode starts
|
||||
React.useEffect(() => {
|
||||
@@ -105,6 +108,8 @@ export const SettingsPage: React.FC = () => {
|
||||
Settings
|
||||
</Typography>
|
||||
|
||||
<PendingDeletionBanner />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Profile Section */}
|
||||
<Card>
|
||||
@@ -477,9 +482,10 @@ export const SettingsPage: React.FC = () => {
|
||||
>
|
||||
Sign Out
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
Delete Account
|
||||
@@ -487,6 +493,8 @@ export const SettingsPage: React.FC = () => {
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<DeleteAccountDialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
PersonAdd,
|
||||
Edit,
|
||||
Security,
|
||||
DeleteForever,
|
||||
} from '@mui/icons-material';
|
||||
import { useAdminAccess } from '../../core/auth/useAdminAccess';
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
useReactivateUser,
|
||||
useUpdateUserProfile,
|
||||
usePromoteToAdmin,
|
||||
useHardDeleteUser,
|
||||
} from '../../features/admin/hooks/useUsers';
|
||||
import {
|
||||
ManagedUser,
|
||||
@@ -84,6 +86,7 @@ export const AdminUsersPage: React.FC = () => {
|
||||
const reactivateMutation = useReactivateUser();
|
||||
const updateProfileMutation = useUpdateUserProfile();
|
||||
const promoteToAdminMutation = usePromoteToAdmin();
|
||||
const hardDeleteMutation = useHardDeleteUser();
|
||||
|
||||
// Action menu state
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
@@ -102,6 +105,11 @@ export const AdminUsersPage: React.FC = () => {
|
||||
const [promoteDialogOpen, setPromoteDialogOpen] = useState(false);
|
||||
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
|
||||
|
||||
// Hard delete dialog state
|
||||
const [hardDeleteDialogOpen, setHardDeleteDialogOpen] = useState(false);
|
||||
const [hardDeleteReason, setHardDeleteReason] = useState('');
|
||||
const [hardDeleteConfirmText, setHardDeleteConfirmText] = useState('');
|
||||
|
||||
// Handlers
|
||||
const handleSearch = useCallback(() => {
|
||||
setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 }));
|
||||
@@ -262,6 +270,34 @@ export const AdminUsersPage: React.FC = () => {
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handleHardDeleteClick = useCallback(() => {
|
||||
setHardDeleteDialogOpen(true);
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const handleHardDeleteConfirm = useCallback(() => {
|
||||
if (selectedUser && hardDeleteConfirmText === 'DELETE') {
|
||||
hardDeleteMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHardDeleteDialogOpen(false);
|
||||
setHardDeleteReason('');
|
||||
setHardDeleteConfirmText('');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [selectedUser, hardDeleteReason, hardDeleteConfirmText, hardDeleteMutation]);
|
||||
|
||||
const handleHardDeleteCancel = useCallback(() => {
|
||||
setHardDeleteDialogOpen(false);
|
||||
setHardDeleteReason('');
|
||||
setHardDeleteConfirmText('');
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (adminLoading) {
|
||||
return (
|
||||
@@ -485,6 +521,12 @@ export const AdminUsersPage: React.FC = () => {
|
||||
Deactivate User
|
||||
</MenuItem>
|
||||
)}
|
||||
{!selectedUser?.isAdmin && (
|
||||
<MenuItem onClick={handleHardDeleteClick} sx={{ color: 'error.main' }}>
|
||||
<DeleteForever sx={{ mr: 1 }} fontSize="small" />
|
||||
Delete Permanently
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
{/* Deactivate Confirmation Dialog */}
|
||||
@@ -624,6 +666,81 @@ export const AdminUsersPage: React.FC = () => {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Hard Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={hardDeleteDialogOpen}
|
||||
onClose={() => !hardDeleteMutation.isPending && handleHardDeleteCancel()}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ color: 'error.main' }}>
|
||||
Permanently Delete User
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ bgcolor: 'error.light', color: 'error.contrastText', p: 2, borderRadius: 1, mb: 3 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
Warning: This action cannot be undone!
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
All user data will be permanently deleted, including vehicles, fuel logs,
|
||||
maintenance records, and documents. The user's Auth0 account will also be deleted.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ mb: 2 }}>
|
||||
Are you sure you want to permanently delete{' '}
|
||||
<strong>{selectedUser?.email}</strong>?
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label="Reason for deletion"
|
||||
value={hardDeleteReason}
|
||||
onChange={(e) => setHardDeleteReason(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="GDPR request, user request, etc..."
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Type <strong>DELETE</strong> to confirm:
|
||||
</Typography>
|
||||
<TextField
|
||||
value={hardDeleteConfirmText}
|
||||
onChange={(e) => setHardDeleteConfirmText(e.target.value.toUpperCase())}
|
||||
fullWidth
|
||||
placeholder="Type DELETE"
|
||||
error={hardDeleteConfirmText.length > 0 && hardDeleteConfirmText !== 'DELETE'}
|
||||
helperText={
|
||||
hardDeleteConfirmText.length > 0 && hardDeleteConfirmText !== 'DELETE'
|
||||
? 'Please type DELETE exactly'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleHardDeleteCancel}
|
||||
disabled={hardDeleteMutation.isPending}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleHardDeleteConfirm}
|
||||
disabled={hardDeleteMutation.isPending || hardDeleteConfirmText !== 'DELETE'}
|
||||
color="error"
|
||||
variant="contained"
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
{hardDeleteMutation.isPending ? <CircularProgress size={20} /> : 'Delete Permanently'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
# Required GitLab CI/CD Variables (File type):
|
||||
# - POSTGRES_PASSWORD
|
||||
# - AUTH0_CLIENT_SECRET
|
||||
# - AUTH0_MANAGEMENT_CLIENT_ID (Auth0 Management API client ID for user signup)
|
||||
# - AUTH0_MANAGEMENT_CLIENT_SECRET (Auth0 Management API client secret)
|
||||
# - GOOGLE_MAPS_API_KEY
|
||||
# - GOOGLE_MAPS_MAP_ID
|
||||
# - CF_DNS_API_TOKEN (Cloudflare DNS API token for Let's Encrypt certificates)
|
||||
@@ -31,6 +33,8 @@ SECRETS_DIR="${DEPLOY_PATH}/secrets/app"
|
||||
SECRET_FILES=(
|
||||
"postgres-password.txt"
|
||||
"auth0-client-secret.txt"
|
||||
"auth0-management-client-id.txt"
|
||||
"auth0-management-client-secret.txt"
|
||||
"google-maps-api-key.txt"
|
||||
"google-maps-map-id.txt"
|
||||
"cloudflare-dns-token.txt"
|
||||
@@ -100,6 +104,8 @@ FAILED=0
|
||||
|
||||
inject_secret "POSTGRES_PASSWORD" "postgres-password.txt" || FAILED=1
|
||||
inject_secret "AUTH0_CLIENT_SECRET" "auth0-client-secret.txt" || FAILED=1
|
||||
inject_secret "AUTH0_MANAGEMENT_CLIENT_ID" "auth0-management-client-id.txt" || FAILED=1
|
||||
inject_secret "AUTH0_MANAGEMENT_CLIENT_SECRET" "auth0-management-client-secret.txt" || FAILED=1
|
||||
inject_secret "GOOGLE_MAPS_API_KEY" "google-maps-api-key.txt" || FAILED=1
|
||||
inject_secret "GOOGLE_MAPS_MAP_ID" "google-maps-map-id.txt" || FAILED=1
|
||||
inject_secret "CF_DNS_API_TOKEN" "cloudflare-dns-token.txt" || FAILED=1
|
||||
|
||||
Reference in New Issue
Block a user