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/multipart": "^9.0.1",
|
||||||
"@fastify/type-provider-typebox": "^6.1.0",
|
"@fastify/type-provider-typebox": "^6.1.0",
|
||||||
"@sinclair/typebox": "^0.34.0",
|
"@sinclair/typebox": "^0.34.0",
|
||||||
|
"auth0": "^4.12.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
@@ -2516,6 +2517,33 @@
|
|||||||
"node": ">=8.0.0"
|
"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": {
|
"node_modules/avvio": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz",
|
||||||
@@ -5511,6 +5539,15 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"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": {
|
"node_modules/js-beautify": {
|
||||||
"version": "1.15.4",
|
"version": "1.15.4",
|
||||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
|
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
|
||||||
@@ -8008,7 +8045,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
"get-jwks": "^11.0.3",
|
"get-jwks": "^11.0.3",
|
||||||
"file-type": "^16.5.4",
|
"file-type": "^16.5.4",
|
||||||
"resend": "^3.0.0",
|
"resend": "^3.0.0",
|
||||||
"node-cron": "^3.0.3"
|
"node-cron": "^3.0.3",
|
||||||
|
"auth0": "^4.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import errorPlugin from './core/plugins/error.plugin';
|
|||||||
import { appConfig } from './core/config/config-loader';
|
import { appConfig } from './core/config/config-loader';
|
||||||
|
|
||||||
// Fastify feature routes
|
// Fastify feature routes
|
||||||
|
import { authRoutes } from './features/auth';
|
||||||
import { vehiclesRoutes } from './features/vehicles/api/vehicles.routes';
|
import { vehiclesRoutes } from './features/vehicles/api/vehicles.routes';
|
||||||
import { fuelLogsRoutes } from './features/fuel-logs/api/fuel-logs.routes';
|
import { fuelLogsRoutes } from './features/fuel-logs/api/fuel-logs.routes';
|
||||||
import { stationsRoutes } from './features/stations/api/stations.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 { adminRoutes } from './features/admin/api/admin.routes';
|
||||||
import { notificationsRoutes } from './features/notifications';
|
import { notificationsRoutes } from './features/notifications';
|
||||||
import { userProfileRoutes } from './features/user-profile';
|
import { userProfileRoutes } from './features/user-profile';
|
||||||
|
import { onboardingRoutes } from './features/onboarding';
|
||||||
import { pool } from './core/config/database';
|
import { pool } from './core/config/database';
|
||||||
|
|
||||||
async function buildApp(): Promise<FastifyInstance> {
|
async function buildApp(): Promise<FastifyInstance> {
|
||||||
@@ -82,7 +84,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
|||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
environment: process.env['NODE_ENV'],
|
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',
|
status: 'healthy',
|
||||||
scope: 'api',
|
scope: 'api',
|
||||||
timestamp: new Date().toISOString(),
|
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
|
// 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(platformRoutes, { prefix: '/api' });
|
||||||
await app.register(vehiclesRoutes, { prefix: '/api' });
|
await app.register(vehiclesRoutes, { prefix: '/api' });
|
||||||
await app.register(documentsRoutes, { 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({
|
const secretsSchema = z.object({
|
||||||
postgres_password: z.string(),
|
postgres_password: z.string(),
|
||||||
auth0_client_secret: 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(),
|
google_maps_api_key: z.string(),
|
||||||
resend_api_key: z.string(),
|
resend_api_key: z.string(),
|
||||||
});
|
});
|
||||||
@@ -137,6 +139,7 @@ export interface AppConfiguration {
|
|||||||
getDatabaseUrl(): string;
|
getDatabaseUrl(): string;
|
||||||
getRedisUrl(): string;
|
getRedisUrl(): string;
|
||||||
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
|
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
|
||||||
|
getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
class ConfigurationLoader {
|
class ConfigurationLoader {
|
||||||
@@ -171,6 +174,8 @@ class ConfigurationLoader {
|
|||||||
const secretFiles = [
|
const secretFiles = [
|
||||||
'postgres-password',
|
'postgres-password',
|
||||||
'auth0-client-secret',
|
'auth0-client-secret',
|
||||||
|
'auth0-management-client-id',
|
||||||
|
'auth0-management-client-secret',
|
||||||
'google-maps-api-key',
|
'google-maps-api-key',
|
||||||
'resend-api-key',
|
'resend-api-key',
|
||||||
];
|
];
|
||||||
@@ -227,6 +232,14 @@ class ConfigurationLoader {
|
|||||||
clientSecret: secrets.auth0_client_secret,
|
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
|
// Set RESEND_API_KEY in environment for EmailService
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Fastify JWT authentication plugin using Auth0
|
* @ai-summary Fastify JWT authentication plugin using Auth0
|
||||||
* @ai-context Validates JWT tokens against Auth0 JWKS endpoint, hydrates userContext with profile
|
* @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 { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
@@ -10,6 +11,15 @@ import { appConfig } from '../config/config-loader';
|
|||||||
import { logger } from '../logging/logger';
|
import { logger } from '../logging/logger';
|
||||||
import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository';
|
import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository';
|
||||||
import { pool } from '../config/database';
|
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
|
// Define the Auth0 JWT payload type
|
||||||
interface Auth0JwtPayload {
|
interface Auth0JwtPayload {
|
||||||
@@ -42,6 +52,8 @@ declare module 'fastify' {
|
|||||||
userId: string;
|
userId: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
onboardingCompleted: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
adminRecord?: any;
|
adminRecord?: any;
|
||||||
};
|
};
|
||||||
@@ -97,31 +109,91 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Initialize profile repository for user profile hydration
|
// Initialize profile repository for user profile hydration
|
||||||
const profileRepo = new UserProfileRepository(pool);
|
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
|
// Decorate with authenticate function that validates JWT
|
||||||
fastify.decorate('authenticate', async function(request: FastifyRequest, reply: FastifyReply) {
|
fastify.decorate('authenticate', async function(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
|
|
||||||
const userId = request.user?.sub;
|
const userId = request.user?.sub;
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('Missing user ID in JWT');
|
||||||
|
}
|
||||||
|
|
||||||
// Get or create user profile from database
|
// Get or create user profile from database
|
||||||
// This ensures we have reliable email/displayName for notifications
|
|
||||||
let email = request.user?.email;
|
let email = request.user?.email;
|
||||||
let displayName: string | undefined;
|
let displayName: string | undefined;
|
||||||
|
let emailVerified = false;
|
||||||
|
let onboardingCompleted = false;
|
||||||
|
|
||||||
try {
|
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, {
|
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,
|
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
|
// Use notificationEmail if set, otherwise fall back to profile email
|
||||||
email = profile.notificationEmail || profile.email;
|
email = profile.notificationEmail || profile.email;
|
||||||
displayName = profile.displayName || undefined;
|
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) {
|
} catch (profileError) {
|
||||||
// Log but don't fail auth if profile fetch fails
|
// Log but don't fail auth if profile fetch fails
|
||||||
logger.warn('Failed to fetch user profile', {
|
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',
|
error: profileError instanceof Error ? profileError.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
// Fall back to JWT email if available
|
// Fall back to JWT email if available
|
||||||
@@ -133,13 +205,29 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
|||||||
userId,
|
userId,
|
||||||
email,
|
email,
|
||||||
displayName,
|
displayName,
|
||||||
|
emailVerified,
|
||||||
|
onboardingCompleted,
|
||||||
isAdmin: false, // Default to false; admin status checked by admin guard
|
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', {
|
logger.info('JWT authentication successful', {
|
||||||
userId: userId?.substring(0, 8) + '...',
|
userId: userId.substring(0, 8) + '...',
|
||||||
hasEmail: !!email,
|
hasEmail: !!email,
|
||||||
audience: auth0Config.audience
|
emailVerified,
|
||||||
|
audience: auth0Config.audience,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('JWT authentication failed', {
|
logger.warn('JWT authentication failed', {
|
||||||
@@ -148,9 +236,9 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
|||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
|
|
||||||
reply.code(401).send({
|
return reply.code(401).send({
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
message: 'Invalid or missing JWT token'
|
message: 'Invalid or missing JWT token',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { logger } from '../logging/logger';
|
import { logger } from '../logging/logger';
|
||||||
import { processScheduledNotifications } from '../../features/notifications/jobs/notification-processor.job';
|
import { processScheduledNotifications } from '../../features/notifications/jobs/notification-processor.job';
|
||||||
|
import { processAccountPurges } from '../../features/user-profile/jobs/account-purge.job';
|
||||||
|
|
||||||
let schedulerInitialized = false;
|
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;
|
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 {
|
export function isSchedulerInitialized(): boolean {
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ export class UserPreferencesRepository {
|
|||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const values = [
|
const values = [
|
||||||
data.userId,
|
data.userId,
|
||||||
data.unitSystem || 'imperial',
|
data.unitSystem || 'imperial',
|
||||||
(data as any).currencyCode || 'USD',
|
data.currencyCode || 'USD',
|
||||||
(data as any).timeZone || 'UTC'
|
data.timeZone || 'UTC'
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await this.db.query(query, values);
|
const result = await this.db.query(query, values);
|
||||||
@@ -47,13 +47,13 @@ export class UserPreferencesRepository {
|
|||||||
fields.push(`unit_system = $${paramCount++}`);
|
fields.push(`unit_system = $${paramCount++}`);
|
||||||
values.push(data.unitSystem);
|
values.push(data.unitSystem);
|
||||||
}
|
}
|
||||||
if ((data as any).currencyCode !== undefined) {
|
if (data.currencyCode !== undefined) {
|
||||||
fields.push(`currency_code = $${paramCount++}`);
|
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++}`);
|
fields.push(`time_zone = $${paramCount++}`);
|
||||||
values.push((data as any).timeZone);
|
values.push(data.timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0) {
|
||||||
@@ -61,12 +61,12 @@ export class UserPreferencesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
UPDATE user_preferences
|
UPDATE user_preferences
|
||||||
SET ${fields.join(', ')}, updated_at = CURRENT_TIMESTAMP
|
SET ${fields.join(', ')}, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE user_id = $${paramCount}
|
WHERE user_id = $${paramCount}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
values.push(userId);
|
values.push(userId);
|
||||||
const result = await this.db.query(query, values);
|
const result = await this.db.query(query, values);
|
||||||
return result.rows.length > 0 ? this.mapRow(result.rows[0]) : null;
|
return result.rows.length > 0 ? this.mapRow(result.rows[0]) : null;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface UserPreferences {
|
|||||||
export interface CreateUserPreferencesRequest {
|
export interface CreateUserPreferencesRequest {
|
||||||
userId: string;
|
userId: string;
|
||||||
unitSystem?: UnitSystem;
|
unitSystem?: UnitSystem;
|
||||||
|
currencyCode?: string;
|
||||||
|
timeZone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateUserPreferencesRequest {
|
export interface UpdateUserPreferencesRequest {
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
handler: usersController.promoteToAdmin.bind(usersController)
|
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
|
// Phase 3: Catalog CRUD endpoints
|
||||||
|
|
||||||
// Makes 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 { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { UserProfileService } from '../domain/user-profile.service';
|
import { UserProfileService } from '../domain/user-profile.service';
|
||||||
import { UserProfileRepository } from '../data/user-profile.repository';
|
import { UserProfileRepository } from '../data/user-profile.repository';
|
||||||
|
import { AdminRepository } from '../../admin/data/admin.repository';
|
||||||
import { pool } from '../../../core/config/database';
|
import { pool } from '../../../core/config/database';
|
||||||
import { logger } from '../../../core/logging/logger';
|
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 {
|
export class UserProfileController {
|
||||||
private userProfileService: UserProfileService;
|
private userProfileService: UserProfileService;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const repository = new UserProfileRepository(pool);
|
const repository = new UserProfileRepository(pool);
|
||||||
|
const adminRepository = new AdminRepository(pool);
|
||||||
this.userProfileService = new UserProfileService(repository);
|
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 { FastifyPluginAsync } from 'fastify';
|
||||||
import { UserProfileController } from './user-profile.controller';
|
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) => {
|
export const userProfileRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
const userProfileController = new UserProfileController();
|
const userProfileController = new UserProfileController();
|
||||||
@@ -21,4 +21,22 @@ export const userProfileRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
handler: userProfileController.updateProfile.bind(userProfileController),
|
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 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
|
// Base columns for user profile queries
|
||||||
const USER_PROFILE_COLUMNS = `
|
const USER_PROFILE_COLUMNS = `
|
||||||
id, auth0_sub, email, display_name, notification_email,
|
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
|
created_at, updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -139,8 +140,12 @@ export class UserProfileRepository {
|
|||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
notificationEmail: row.notification_email,
|
notificationEmail: row.notification_email,
|
||||||
subscriptionTier: row.subscription_tier || 'free',
|
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,
|
deactivatedAt: row.deactivated_at ? new Date(row.deactivated_at) : null,
|
||||||
deactivatedBy: row.deactivated_by || 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),
|
createdAt: new Date(row.created_at),
|
||||||
updatedAt: new Date(row.updated_at),
|
updatedAt: new Date(row.updated_at),
|
||||||
};
|
};
|
||||||
@@ -213,8 +218,8 @@ export class UserProfileRepository {
|
|||||||
const dataQuery = `
|
const dataQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
|
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
|
||||||
up.subscription_tier, up.deactivated_at, up.deactivated_by,
|
up.subscription_tier, up.email_verified, up.onboarding_completed_at,
|
||||||
up.created_at, up.updated_at,
|
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
|
||||||
au.auth0_sub as admin_auth0_sub,
|
au.auth0_sub as admin_auth0_sub,
|
||||||
au.role as admin_role
|
au.role as admin_role
|
||||||
FROM user_profiles up
|
FROM user_profiles up
|
||||||
@@ -247,8 +252,8 @@ export class UserProfileRepository {
|
|||||||
const query = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
|
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
|
||||||
up.subscription_tier, up.deactivated_at, up.deactivated_by,
|
up.subscription_tier, up.email_verified, up.onboarding_completed_at,
|
||||||
up.created_at, up.updated_at,
|
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
|
||||||
au.auth0_sub as admin_auth0_sub,
|
au.auth0_sub as admin_auth0_sub,
|
||||||
au.role as admin_role
|
au.role as admin_role
|
||||||
FROM user_profiles up
|
FROM user_profiles up
|
||||||
@@ -388,4 +393,221 @@ export class UserProfileRepository {
|
|||||||
throw error;
|
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,
|
ListUsersQuery,
|
||||||
ListUsersResponse,
|
ListUsersResponse,
|
||||||
SubscriptionTier,
|
SubscriptionTier,
|
||||||
|
DeletionStatus,
|
||||||
} from './user-profile.types';
|
} from './user-profile.types';
|
||||||
import { AdminRepository } from '../../admin/data/admin.repository';
|
import { AdminRepository } from '../../admin/data/admin.repository';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
|
import { auth0ManagementClient } from '../../../core/auth/auth0-management.client';
|
||||||
|
|
||||||
export class UserProfileService {
|
export class UserProfileService {
|
||||||
private adminRepository: AdminRepository | null = null;
|
private adminRepository: AdminRepository | null = null;
|
||||||
@@ -320,4 +322,178 @@ export class UserProfileService {
|
|||||||
throw error;
|
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;
|
displayName?: string;
|
||||||
notificationEmail?: string;
|
notificationEmail?: string;
|
||||||
subscriptionTier: SubscriptionTier;
|
subscriptionTier: SubscriptionTier;
|
||||||
|
emailVerified: boolean;
|
||||||
|
onboardingCompletedAt: Date | null;
|
||||||
deactivatedAt: Date | null;
|
deactivatedAt: Date | null;
|
||||||
deactivatedBy: string | null;
|
deactivatedBy: string | null;
|
||||||
|
deletionRequestedAt: Date | null;
|
||||||
|
deletionScheduledFor: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -64,3 +68,17 @@ export interface Auth0UserData {
|
|||||||
email: string;
|
email: string;
|
||||||
name?: 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-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/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/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
|
# Filesystem storage for documents
|
||||||
- ./data/documents:/app/data/documents
|
- ./data/documents:/app/data/documents
|
||||||
networks:
|
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.
|
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 ***
|
*** ACTION ***
|
||||||
- You will be enhancing the maintenance record feature.
|
- You will be implementing improvements to the User Management.
|
||||||
- Make no assumptions.
|
- Make no assumptions.
|
||||||
- Ask clarifying questions.
|
- Ask clarifying questions.
|
||||||
- Ultrathink
|
- Ultrathink
|
||||||
@@ -27,8 +27,11 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
|
|||||||
*** CONTEXT ***
|
*** 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.
|
- 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.
|
- 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.
|
- There is no delete option for users
|
||||||
- We need to implement schedule maintenance records with notifications tied into the existing notification system.
|
- 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 ***
|
*** CHANGES TO IMPLEMENT ***
|
||||||
- Research this code base and ask iterative questions to compile a complete plan.
|
- 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)
|
// Admin Community Stations (lazy-loaded)
|
||||||
const AdminCommunityStationsPage = lazy(() => import('./features/admin/pages/AdminCommunityStationsPage').then(m => ({ default: m.AdminCommunityStationsPage })));
|
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 })));
|
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 { HomePage } from './pages/HomePage';
|
||||||
import { BottomNavigation } from './shared-minimal/components/mobile/BottomNavigation';
|
import { BottomNavigation } from './shared-minimal/components/mobile/BottomNavigation';
|
||||||
import { QuickAction } from './shared-minimal/components/mobile/quickActions';
|
import { QuickAction } from './shared-minimal/components/mobile/quickActions';
|
||||||
@@ -399,7 +410,11 @@ function App() {
|
|||||||
|
|
||||||
const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/');
|
const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/');
|
||||||
const isCallbackRoute = location.pathname === '/callback';
|
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
|
// Enhanced navigation handlers for mobile
|
||||||
const handleVehicleSelect = useCallback((vehicle: Vehicle) => {
|
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) {
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/" replace />;
|
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
|
// 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
|
// This prevents a race condition where the page renders before the auth token is ready
|
||||||
if (!isAuthGateReady) {
|
if (!isAuthGateReady) {
|
||||||
|
|||||||
@@ -359,5 +359,13 @@ export const adminApi = {
|
|||||||
);
|
);
|
||||||
return response.data;
|
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,
|
useReactivateUser,
|
||||||
useUpdateUserProfile,
|
useUpdateUserProfile,
|
||||||
usePromoteToAdmin,
|
usePromoteToAdmin,
|
||||||
|
useHardDeleteUser,
|
||||||
} from '../hooks/useUsers';
|
} from '../hooks/useUsers';
|
||||||
import {
|
import {
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
@@ -103,6 +104,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
|||||||
const reactivateMutation = useReactivateUser();
|
const reactivateMutation = useReactivateUser();
|
||||||
const updateProfileMutation = useUpdateUserProfile();
|
const updateProfileMutation = useUpdateUserProfile();
|
||||||
const promoteToAdminMutation = usePromoteToAdmin();
|
const promoteToAdminMutation = usePromoteToAdmin();
|
||||||
|
const hardDeleteMutation = useHardDeleteUser();
|
||||||
|
|
||||||
// Selected user for actions
|
// Selected user for actions
|
||||||
const [selectedUser, setSelectedUser] = useState<ManagedUser | null>(null);
|
const [selectedUser, setSelectedUser] = useState<ManagedUser | null>(null);
|
||||||
@@ -115,6 +117,9 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
|||||||
const [editDisplayName, setEditDisplayName] = useState('');
|
const [editDisplayName, setEditDisplayName] = useState('');
|
||||||
const [showPromoteModal, setShowPromoteModal] = useState(false);
|
const [showPromoteModal, setShowPromoteModal] = useState(false);
|
||||||
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
|
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
|
||||||
|
const [showHardDeleteModal, setShowHardDeleteModal] = useState(false);
|
||||||
|
const [hardDeleteReason, setHardDeleteReason] = useState('');
|
||||||
|
const [hardDeleteConfirmText, setHardDeleteConfirmText] = useState('');
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
@@ -256,6 +261,34 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
|||||||
setSelectedUser(null);
|
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(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
setParams(prev => ({ ...prev, page: (prev.page || 1) + 1 }));
|
setParams(prev => ({ ...prev, page: (prev.page || 1) + 1 }));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -527,6 +560,15 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
|||||||
Deactivate User
|
Deactivate User
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -716,6 +758,82 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
</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 { 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 = {
|
export const profileApi = {
|
||||||
getProfile: () => apiClient.get<UserProfile>('/user/profile'),
|
getProfile: () => apiClient.get<UserProfile>('/user/profile'),
|
||||||
updateProfile: (data: UpdateProfileRequest) => apiClient.put<UserProfile>('/user/profile', data),
|
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 { useProfile, useUpdateProfile } from '../hooks/useProfile';
|
||||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||||
import { useNavigationStore } from '../../../core/store';
|
import { useNavigationStore } from '../../../core/store';
|
||||||
|
import { DeleteAccountModal } from './DeleteAccountModal';
|
||||||
|
import { PendingDeletionBanner } from './PendingDeletionBanner';
|
||||||
|
|
||||||
interface ToggleSwitchProps {
|
interface ToggleSwitchProps {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -105,11 +107,6 @@ export const MobileSettingsScreen: React.FC = () => {
|
|||||||
setShowDataExport(false);
|
setShowDataExport(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAccount = () => {
|
|
||||||
// TODO: Implement account deletion
|
|
||||||
console.log('Deleting account...');
|
|
||||||
setShowDeleteConfirm(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditProfile = () => {
|
const handleEditProfile = () => {
|
||||||
setIsEditingProfile(true);
|
setIsEditingProfile(true);
|
||||||
@@ -190,6 +187,9 @@ export const MobileSettingsScreen: React.FC = () => {
|
|||||||
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
|
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pending Deletion Banner */}
|
||||||
|
<PendingDeletionBanner />
|
||||||
|
|
||||||
{/* Profile Section */}
|
{/* Profile Section */}
|
||||||
<GlassCard padding="md">
|
<GlassCard padding="md">
|
||||||
<div>
|
<div>
|
||||||
@@ -488,30 +488,11 @@ export const MobileSettingsScreen: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Delete Account Confirmation */}
|
{/* Delete Account Modal */}
|
||||||
<Modal
|
<DeleteAccountModal
|
||||||
isOpen={showDeleteConfirm}
|
isOpen={showDeleteConfirm}
|
||||||
onClose={() => setShowDeleteConfirm(false)}
|
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>
|
</div>
|
||||||
</MobileContainer>
|
</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;
|
displayName?: string;
|
||||||
notificationEmail?: 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' } });
|
loginWithRedirect({ appState: { returnTo: '/garage' } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSignup = () => {
|
||||||
|
navigate('/signup');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
{/* Navigation Bar */}
|
{/* Navigation Bar */}
|
||||||
@@ -44,6 +48,12 @@ export const HomePage = () => {
|
|||||||
<a href="#about" className="text-gray-700 hover:text-primary-500 transition-colors">
|
<a href="#about" className="text-gray-700 hover:text-primary-500 transition-colors">
|
||||||
About
|
About
|
||||||
</a>
|
</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
|
<button
|
||||||
onClick={handleAuthAction}
|
onClick={handleAuthAction}
|
||||||
className="bg-primary-500 hover:bg-primary-700 text-white font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
|
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
|
About
|
||||||
</a>
|
</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
|
<button
|
||||||
onClick={handleAuthAction}
|
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"
|
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 { useUnits } from '../core/units/UnitsContext';
|
||||||
import { useAdminAccess } from '../core/auth/useAdminAccess';
|
import { useAdminAccess } from '../core/auth/useAdminAccess';
|
||||||
import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile';
|
import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile';
|
||||||
|
import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog';
|
||||||
|
import { PendingDeletionBanner } from '../features/settings/components/PendingDeletionBanner';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -52,6 +54,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
||||||
const [editedDisplayName, setEditedDisplayName] = useState('');
|
const [editedDisplayName, setEditedDisplayName] = useState('');
|
||||||
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
|
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Initialize edit form when profile loads or edit mode starts
|
// Initialize edit form when profile loads or edit mode starts
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -105,6 +108,8 @@ export const SettingsPage: React.FC = () => {
|
|||||||
Settings
|
Settings
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
<PendingDeletionBanner />
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
{/* Profile Section */}
|
{/* Profile Section */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -477,9 +482,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</MuiButton>
|
</MuiButton>
|
||||||
<MuiButton
|
<MuiButton
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="error"
|
color="error"
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
sx={{ borderRadius: '999px' }}
|
sx={{ borderRadius: '999px' }}
|
||||||
>
|
>
|
||||||
Delete Account
|
Delete Account
|
||||||
@@ -487,6 +493,8 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<DeleteAccountDialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
PersonAdd,
|
PersonAdd,
|
||||||
Edit,
|
Edit,
|
||||||
Security,
|
Security,
|
||||||
|
DeleteForever,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useAdminAccess } from '../../core/auth/useAdminAccess';
|
import { useAdminAccess } from '../../core/auth/useAdminAccess';
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
useReactivateUser,
|
useReactivateUser,
|
||||||
useUpdateUserProfile,
|
useUpdateUserProfile,
|
||||||
usePromoteToAdmin,
|
usePromoteToAdmin,
|
||||||
|
useHardDeleteUser,
|
||||||
} from '../../features/admin/hooks/useUsers';
|
} from '../../features/admin/hooks/useUsers';
|
||||||
import {
|
import {
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
@@ -84,6 +86,7 @@ export const AdminUsersPage: React.FC = () => {
|
|||||||
const reactivateMutation = useReactivateUser();
|
const reactivateMutation = useReactivateUser();
|
||||||
const updateProfileMutation = useUpdateUserProfile();
|
const updateProfileMutation = useUpdateUserProfile();
|
||||||
const promoteToAdminMutation = usePromoteToAdmin();
|
const promoteToAdminMutation = usePromoteToAdmin();
|
||||||
|
const hardDeleteMutation = useHardDeleteUser();
|
||||||
|
|
||||||
// Action menu state
|
// Action menu state
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
@@ -102,6 +105,11 @@ export const AdminUsersPage: React.FC = () => {
|
|||||||
const [promoteDialogOpen, setPromoteDialogOpen] = useState(false);
|
const [promoteDialogOpen, setPromoteDialogOpen] = useState(false);
|
||||||
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
|
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
|
// Handlers
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 }));
|
setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 }));
|
||||||
@@ -262,6 +270,34 @@ export const AdminUsersPage: React.FC = () => {
|
|||||||
setSelectedUser(null);
|
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
|
// Loading state
|
||||||
if (adminLoading) {
|
if (adminLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -485,6 +521,12 @@ export const AdminUsersPage: React.FC = () => {
|
|||||||
Deactivate User
|
Deactivate User
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{!selectedUser?.isAdmin && (
|
||||||
|
<MenuItem onClick={handleHardDeleteClick} sx={{ color: 'error.main' }}>
|
||||||
|
<DeleteForever sx={{ mr: 1 }} fontSize="small" />
|
||||||
|
Delete Permanently
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
{/* Deactivate Confirmation Dialog */}
|
{/* Deactivate Confirmation Dialog */}
|
||||||
@@ -624,6 +666,81 @@ export const AdminUsersPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
# Required GitLab CI/CD Variables (File type):
|
# Required GitLab CI/CD Variables (File type):
|
||||||
# - POSTGRES_PASSWORD
|
# - POSTGRES_PASSWORD
|
||||||
# - AUTH0_CLIENT_SECRET
|
# - 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_API_KEY
|
||||||
# - GOOGLE_MAPS_MAP_ID
|
# - GOOGLE_MAPS_MAP_ID
|
||||||
# - CF_DNS_API_TOKEN (Cloudflare DNS API token for Let's Encrypt certificates)
|
# - 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=(
|
SECRET_FILES=(
|
||||||
"postgres-password.txt"
|
"postgres-password.txt"
|
||||||
"auth0-client-secret.txt"
|
"auth0-client-secret.txt"
|
||||||
|
"auth0-management-client-id.txt"
|
||||||
|
"auth0-management-client-secret.txt"
|
||||||
"google-maps-api-key.txt"
|
"google-maps-api-key.txt"
|
||||||
"google-maps-map-id.txt"
|
"google-maps-map-id.txt"
|
||||||
"cloudflare-dns-token.txt"
|
"cloudflare-dns-token.txt"
|
||||||
@@ -100,6 +104,8 @@ FAILED=0
|
|||||||
|
|
||||||
inject_secret "POSTGRES_PASSWORD" "postgres-password.txt" || FAILED=1
|
inject_secret "POSTGRES_PASSWORD" "postgres-password.txt" || FAILED=1
|
||||||
inject_secret "AUTH0_CLIENT_SECRET" "auth0-client-secret.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_API_KEY" "google-maps-api-key.txt" || FAILED=1
|
||||||
inject_secret "GOOGLE_MAPS_MAP_ID" "google-maps-map-id.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
|
inject_secret "CF_DNS_API_TOKEN" "cloudflare-dns-token.txt" || FAILED=1
|
||||||
|
|||||||
Reference in New Issue
Block a user