feat: delete users - not tested

This commit is contained in:
Eric Gullickson
2025-12-22 18:20:25 -06:00
parent 91b4534e76
commit 4897f0a52c
73 changed files with 4923 additions and 62 deletions

View File

@@ -15,6 +15,7 @@
"@fastify/multipart": "^9.0.1",
"@fastify/type-provider-typebox": "^6.1.0",
"@sinclair/typebox": "^0.34.0",
"auth0": "^4.12.0",
"axios": "^1.7.9",
"fastify": "^5.2.0",
"fastify-plugin": "^5.0.1",
@@ -2516,6 +2517,33 @@
"node": ">=8.0.0"
}
},
"node_modules/auth0": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/auth0/-/auth0-4.37.0.tgz",
"integrity": "sha512-+TqJRxh4QvbD4TQIYx1ak2vanykQkG/nIZLuR6o8LoQj425gjVG3tFuUbbOeh/nCpP1rnvU0CCV1ChZHYXLU/A==",
"license": "MIT",
"dependencies": {
"jose": "^4.13.2",
"undici-types": "^6.15.0",
"uuid": "^9.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/auth0/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/avvio": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz",
@@ -5511,6 +5539,15 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-beautify": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
@@ -8008,7 +8045,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {

View File

@@ -37,7 +37,8 @@
"get-jwks": "^11.0.3",
"file-type": "^16.5.4",
"resend": "^3.0.0",
"node-cron": "^3.0.3"
"node-cron": "^3.0.3",
"auth0": "^4.12.0"
},
"devDependencies": {
"@types/node": "^22.0.0",

View File

@@ -15,6 +15,7 @@ import errorPlugin from './core/plugins/error.plugin';
import { appConfig } from './core/config/config-loader';
// Fastify feature routes
import { authRoutes } from './features/auth';
import { vehiclesRoutes } from './features/vehicles/api/vehicles.routes';
import { fuelLogsRoutes } from './features/fuel-logs/api/fuel-logs.routes';
import { stationsRoutes } from './features/stations/api/stations.routes';
@@ -25,6 +26,7 @@ import { platformRoutes } from './features/platform';
import { adminRoutes } from './features/admin/api/admin.routes';
import { notificationsRoutes } from './features/notifications';
import { userProfileRoutes } from './features/user-profile';
import { onboardingRoutes } from './features/onboarding';
import { pool } from './core/config/database';
async function buildApp(): Promise<FastifyInstance> {
@@ -82,7 +84,7 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'healthy',
timestamp: new Date().toISOString(),
environment: process.env['NODE_ENV'],
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
});
});
@@ -92,7 +94,7 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'healthy',
scope: 'api',
timestamp: new Date().toISOString(),
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
});
});
@@ -118,6 +120,8 @@ async function buildApp(): Promise<FastifyInstance> {
});
// Register Fastify feature routes
await app.register(authRoutes, { prefix: '/api' });
await app.register(onboardingRoutes, { prefix: '/api' });
await app.register(platformRoutes, { prefix: '/api' });
await app.register(vehiclesRoutes, { prefix: '/api' });
await app.register(documentsRoutes, { prefix: '/api' });

View 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();

View File

@@ -122,6 +122,8 @@ const configSchema = z.object({
const secretsSchema = z.object({
postgres_password: z.string(),
auth0_client_secret: z.string(),
auth0_management_client_id: z.string(),
auth0_management_client_secret: z.string(),
google_maps_api_key: z.string(),
resend_api_key: z.string(),
});
@@ -137,6 +139,7 @@ export interface AppConfiguration {
getDatabaseUrl(): string;
getRedisUrl(): string;
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string };
}
class ConfigurationLoader {
@@ -171,6 +174,8 @@ class ConfigurationLoader {
const secretFiles = [
'postgres-password',
'auth0-client-secret',
'auth0-management-client-id',
'auth0-management-client-secret',
'google-maps-api-key',
'resend-api-key',
];
@@ -227,6 +232,14 @@ class ConfigurationLoader {
clientSecret: secrets.auth0_client_secret,
};
},
getAuth0ManagementConfig() {
return {
domain: config.auth0.domain,
clientId: secrets.auth0_management_client_id,
clientSecret: secrets.auth0_management_client_secret,
};
},
};
// Set RESEND_API_KEY in environment for EmailService

View File

@@ -1,6 +1,7 @@
/**
* @ai-summary Fastify JWT authentication plugin using Auth0
* @ai-context Validates JWT tokens against Auth0 JWKS endpoint, hydrates userContext with profile
* @ai-context Includes email verification guard to block unverified users
*/
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
import fp from 'fastify-plugin';
@@ -10,6 +11,15 @@ import { appConfig } from '../config/config-loader';
import { logger } from '../logging/logger';
import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository';
import { pool } from '../config/database';
import { auth0ManagementClient } from '../auth/auth0-management.client';
// Routes that don't require email verification
const VERIFICATION_EXEMPT_ROUTES = [
'/api/auth/',
'/api/onboarding/',
'/api/health',
'/health',
];
// Define the Auth0 JWT payload type
interface Auth0JwtPayload {
@@ -42,6 +52,8 @@ declare module 'fastify' {
userId: string;
email?: string;
displayName?: string;
emailVerified: boolean;
onboardingCompleted: boolean;
isAdmin: boolean;
adminRecord?: any;
};
@@ -97,31 +109,91 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
// Initialize profile repository for user profile hydration
const profileRepo = new UserProfileRepository(pool);
// Helper to check if route is exempt from verification
const isVerificationExempt = (url: string): boolean => {
return VERIFICATION_EXEMPT_ROUTES.some(route => url.startsWith(route));
};
// Decorate with authenticate function that validates JWT
fastify.decorate('authenticate', async function(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = request.user?.sub;
if (!userId) {
throw new Error('Missing user ID in JWT');
}
// Get or create user profile from database
// This ensures we have reliable email/displayName for notifications
let email = request.user?.email;
let displayName: string | undefined;
let emailVerified = false;
let onboardingCompleted = false;
try {
// If JWT doesn't have email, fetch from Auth0 Management API
if (!email || email.includes('@unknown.local')) {
try {
const auth0User = await auth0ManagementClient.getUser(userId);
if (auth0User.email) {
email = auth0User.email;
emailVerified = auth0User.emailVerified;
logger.info('Fetched email from Auth0 Management API', {
userId: userId.substring(0, 8) + '...',
hasEmail: true,
});
}
} catch (auth0Error) {
logger.warn('Failed to fetch user from Auth0 Management API', {
userId: userId.substring(0, 8) + '...',
error: auth0Error instanceof Error ? auth0Error.message : 'Unknown error',
});
}
}
// Get or create profile with correct email
const profile = await profileRepo.getOrCreate(userId, {
email: request.user?.email || `${userId}@unknown.local`,
email: email || `${userId}@unknown.local`,
displayName: request.user?.name || request.user?.nickname,
});
// If profile has placeholder email but we now have real email, update it
if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) {
await profileRepo.updateEmail(userId, email);
logger.info('Updated profile with correct email from Auth0', {
userId: userId.substring(0, 8) + '...',
});
}
// Use notificationEmail if set, otherwise fall back to profile email
email = profile.notificationEmail || profile.email;
displayName = profile.displayName || undefined;
emailVerified = profile.emailVerified;
onboardingCompleted = profile.onboardingCompletedAt !== null;
// Sync email verification status from Auth0 if needed
if (!emailVerified) {
try {
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(userId);
if (isVerifiedInAuth0 && !profile.emailVerified) {
await profileRepo.updateEmailVerified(userId, true);
emailVerified = true;
logger.info('Synced email verification status from Auth0', {
userId: userId.substring(0, 8) + '...',
});
}
} catch (syncError) {
// Don't fail auth if sync fails, just log
logger.warn('Failed to sync email verification status', {
userId: userId.substring(0, 8) + '...',
error: syncError instanceof Error ? syncError.message : 'Unknown error',
});
}
}
} catch (profileError) {
// Log but don't fail auth if profile fetch fails
logger.warn('Failed to fetch user profile', {
userId: userId?.substring(0, 8) + '...',
userId: userId.substring(0, 8) + '...',
error: profileError instanceof Error ? profileError.message : 'Unknown error',
});
// Fall back to JWT email if available
@@ -133,13 +205,29 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
userId,
email,
displayName,
emailVerified,
onboardingCompleted,
isAdmin: false, // Default to false; admin status checked by admin guard
};
// Email verification guard - block unverified users from non-exempt routes
if (!emailVerified && !isVerificationExempt(request.url)) {
logger.warn('Blocked unverified user from accessing protected route', {
userId: userId.substring(0, 8) + '...',
path: request.url,
});
return reply.code(403).send({
error: 'Email not verified',
message: 'Please verify your email address before accessing the application',
code: 'EMAIL_NOT_VERIFIED',
});
}
logger.info('JWT authentication successful', {
userId: userId?.substring(0, 8) + '...',
userId: userId.substring(0, 8) + '...',
hasEmail: !!email,
audience: auth0Config.audience
emailVerified,
audience: auth0Config.audience,
});
} catch (error) {
logger.warn('JWT authentication failed', {
@@ -148,9 +236,9 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
error: error instanceof Error ? error.message : 'Unknown error',
});
reply.code(401).send({
return reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or missing JWT token'
message: 'Invalid or missing JWT token',
});
}
});

View File

@@ -6,6 +6,7 @@
import cron from 'node-cron';
import { logger } from '../logging/logger';
import { processScheduledNotifications } from '../../features/notifications/jobs/notification-processor.job';
import { processAccountPurges } from '../../features/user-profile/jobs/account-purge.job';
let schedulerInitialized = false;
@@ -29,8 +30,25 @@ export function initializeScheduler(): void {
}
});
// Daily account purge job at 2 AM (GDPR compliance - 30-day grace period)
cron.schedule('0 2 * * *', async () => {
logger.info('Running account purge job');
try {
const result = await processAccountPurges();
logger.info('Account purge job completed successfully', {
processed: result.processed,
deleted: result.deleted,
errors: result.errors.length,
});
} catch (error) {
logger.error('Account purge job failed', {
error: error instanceof Error ? error.message : String(error)
});
}
});
schedulerInitialized = true;
logger.info('Cron scheduler initialized - notification job scheduled for 8 AM daily');
logger.info('Cron scheduler initialized - notification job (8 AM) and account purge job (2 AM) scheduled daily');
}
export function isSchedulerInitialized(): boolean {

View File

@@ -26,12 +26,12 @@ export class UserPreferencesRepository {
VALUES ($1, $2, $3, $4)
RETURNING *
`;
const values = [
data.userId,
data.unitSystem || 'imperial',
(data as any).currencyCode || 'USD',
(data as any).timeZone || 'UTC'
data.currencyCode || 'USD',
data.timeZone || 'UTC'
];
const result = await this.db.query(query, values);
@@ -47,13 +47,13 @@ export class UserPreferencesRepository {
fields.push(`unit_system = $${paramCount++}`);
values.push(data.unitSystem);
}
if ((data as any).currencyCode !== undefined) {
if (data.currencyCode !== undefined) {
fields.push(`currency_code = $${paramCount++}`);
values.push((data as any).currencyCode);
values.push(data.currencyCode);
}
if ((data as any).timeZone !== undefined) {
if (data.timeZone !== undefined) {
fields.push(`time_zone = $${paramCount++}`);
values.push((data as any).timeZone);
values.push(data.timeZone);
}
if (fields.length === 0) {
@@ -61,12 +61,12 @@ export class UserPreferencesRepository {
}
const query = `
UPDATE user_preferences
UPDATE user_preferences
SET ${fields.join(', ')}, updated_at = CURRENT_TIMESTAMP
WHERE user_id = $${paramCount}
RETURNING *
`;
values.push(userId);
const result = await this.db.query(query, values);
return result.rows.length > 0 ? this.mapRow(result.rows[0]) : null;

View File

@@ -18,6 +18,8 @@ export interface UserPreferences {
export interface CreateUserPreferencesRequest {
userId: string;
unitSystem?: UnitSystem;
currencyCode?: string;
timeZone?: string;
}
export interface UpdateUserPreferencesRequest {

View File

@@ -155,6 +155,12 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
handler: usersController.promoteToAdmin.bind(usersController)
});
// DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent)
fastify.delete<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', {
preHandler: [fastify.requireAdmin],
handler: usersController.hardDeleteUser.bind(usersController)
});
// Phase 3: Catalog CRUD endpoints
// Makes endpoints

View File

@@ -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',
});
}
}
}

View 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.

View 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',
});
}
}
}

View 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),
});
};

View 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>;

View 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;
}
}
}

View 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;
}

View 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';

View File

@@ -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();
});
});
});

View 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');
});
});
});

View 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)

View 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',
});
}
}
}

View 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),
});
};

View 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>;

View 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;
}
}
}

View 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;
}

View 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';

View File

@@ -6,16 +6,24 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { UserProfileService } from '../domain/user-profile.service';
import { UserProfileRepository } from '../data/user-profile.repository';
import { AdminRepository } from '../../admin/data/admin.repository';
import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import { UpdateProfileInput, updateProfileSchema } from './user-profile.validation';
import {
UpdateProfileInput,
updateProfileSchema,
RequestDeletionInput,
requestDeletionSchema,
} from './user-profile.validation';
export class UserProfileController {
private userProfileService: UserProfileService;
constructor() {
const repository = new UserProfileRepository(pool);
const adminRepository = new AdminRepository(pool);
this.userProfileService = new UserProfileService(repository);
this.userProfileService.setAdminRepository(adminRepository);
}
/**
@@ -121,4 +129,178 @@ export class UserProfileController {
});
}
}
/**
* POST /api/user/delete - Request account deletion
*/
async requestDeletion(
request: FastifyRequest<{ Body: RequestDeletionInput }>,
reply: FastifyReply
) {
try {
const auth0Sub = request.userContext?.userId;
if (!auth0Sub) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing',
});
}
// Validate request body
const validation = requestDeletionSchema.safeParse(request.body);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: validation.error.errors,
});
}
const { password, confirmationText } = validation.data;
// Request deletion
const profile = await this.userProfileService.requestDeletion(
auth0Sub,
password,
confirmationText
);
const deletionStatus = this.userProfileService.getDeletionStatus(profile);
return reply.code(200).send({
message: 'Account deletion requested successfully',
deletionStatus,
});
} catch (error: any) {
logger.error('Error requesting account deletion', {
error: error.message,
userId: request.userContext?.userId,
});
if (error.message.includes('Invalid password')) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid password',
});
}
if (error.message.includes('Invalid confirmation')) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Confirmation text must be exactly "DELETE"',
});
}
if (error.message.includes('already requested')) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Account deletion already requested',
});
}
if (error.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: 'User profile not found',
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to request account deletion',
});
}
}
/**
* POST /api/user/cancel-deletion - Cancel account deletion
*/
async cancelDeletion(request: FastifyRequest, reply: FastifyReply) {
try {
const auth0Sub = request.userContext?.userId;
if (!auth0Sub) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing',
});
}
// Cancel deletion
const profile = await this.userProfileService.cancelDeletion(auth0Sub);
return reply.code(200).send({
message: 'Account deletion canceled successfully',
profile,
});
} catch (error: any) {
logger.error('Error canceling account deletion', {
error: error.message,
userId: request.userContext?.userId,
});
if (error.message.includes('no deletion request')) {
return reply.code(400).send({
error: 'Bad Request',
message: 'No deletion request pending',
});
}
if (error.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: 'User profile not found',
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to cancel account deletion',
});
}
}
/**
* GET /api/user/deletion-status - Get deletion status
*/
async getDeletionStatus(request: FastifyRequest, reply: FastifyReply) {
try {
const auth0Sub = request.userContext?.userId;
if (!auth0Sub) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing',
});
}
// Get user data from Auth0 token
const auth0User = {
sub: auth0Sub,
email: (request as any).user?.email || request.userContext?.email || '',
name: (request as any).user?.name,
};
// Get or create profile
const profile = await this.userProfileService.getOrCreateProfile(
auth0Sub,
auth0User
);
const deletionStatus = this.userProfileService.getDeletionStatus(profile);
return reply.code(200).send(deletionStatus);
} catch (error: any) {
logger.error('Error getting deletion status', {
error: error.message,
userId: request.userContext?.userId,
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get deletion status',
});
}
}
}

View File

@@ -5,7 +5,7 @@
import { FastifyPluginAsync } from 'fastify';
import { UserProfileController } from './user-profile.controller';
import { UpdateProfileInput } from './user-profile.validation';
import { UpdateProfileInput, RequestDeletionInput } from './user-profile.validation';
export const userProfileRoutes: FastifyPluginAsync = async (fastify) => {
const userProfileController = new UserProfileController();
@@ -21,4 +21,22 @@ export const userProfileRoutes: FastifyPluginAsync = async (fastify) => {
preHandler: [fastify.authenticate],
handler: userProfileController.updateProfile.bind(userProfileController),
});
// POST /api/user/delete - Request account deletion
fastify.post<{ Body: RequestDeletionInput }>('/user/delete', {
preHandler: [fastify.authenticate],
handler: userProfileController.requestDeletion.bind(userProfileController),
});
// POST /api/user/cancel-deletion - Cancel account deletion
fastify.post('/user/cancel-deletion', {
preHandler: [fastify.authenticate],
handler: userProfileController.cancelDeletion.bind(userProfileController),
});
// GET /api/user/deletion-status - Get deletion status
fastify.get('/user/deletion-status', {
preHandler: [fastify.authenticate],
handler: userProfileController.getDeletionStatus.bind(userProfileController),
});
};

View File

@@ -16,3 +16,12 @@ export const updateProfileSchema = z.object({
);
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
export const requestDeletionSchema = z.object({
password: z.string().min(1, 'Password is required'),
confirmationText: z.string().refine((val) => val === 'DELETE', {
message: 'Confirmation text must be exactly "DELETE"',
}),
});
export type RequestDeletionInput = z.infer<typeof requestDeletionSchema>;

View File

@@ -16,7 +16,8 @@ import { logger } from '../../../core/logging/logger';
// Base columns for user profile queries
const USER_PROFILE_COLUMNS = `
id, auth0_sub, email, display_name, notification_email,
subscription_tier, deactivated_at, deactivated_by,
subscription_tier, email_verified, onboarding_completed_at,
deactivated_at, deactivated_by, deletion_requested_at, deletion_scheduled_for,
created_at, updated_at
`;
@@ -139,8 +140,12 @@ export class UserProfileRepository {
displayName: row.display_name,
notificationEmail: row.notification_email,
subscriptionTier: row.subscription_tier || 'free',
emailVerified: row.email_verified ?? false,
onboardingCompletedAt: row.onboarding_completed_at ? new Date(row.onboarding_completed_at) : null,
deactivatedAt: row.deactivated_at ? new Date(row.deactivated_at) : null,
deactivatedBy: row.deactivated_by || null,
deletionRequestedAt: row.deletion_requested_at ? new Date(row.deletion_requested_at) : null,
deletionScheduledFor: row.deletion_scheduled_for ? new Date(row.deletion_scheduled_for) : null,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
};
@@ -213,8 +218,8 @@ export class UserProfileRepository {
const dataQuery = `
SELECT
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
up.subscription_tier, up.deactivated_at, up.deactivated_by,
up.created_at, up.updated_at,
up.subscription_tier, up.email_verified, up.onboarding_completed_at,
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
au.auth0_sub as admin_auth0_sub,
au.role as admin_role
FROM user_profiles up
@@ -247,8 +252,8 @@ export class UserProfileRepository {
const query = `
SELECT
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
up.subscription_tier, up.deactivated_at, up.deactivated_by,
up.created_at, up.updated_at,
up.subscription_tier, up.email_verified, up.onboarding_completed_at,
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
au.auth0_sub as admin_auth0_sub,
au.role as admin_role
FROM user_profiles up
@@ -388,4 +393,221 @@ export class UserProfileRepository {
throw error;
}
}
/**
* Update email verification status
*/
async updateEmailVerified(auth0Sub: string, emailVerified: boolean): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET email_verified = $1, updated_at = NOW()
WHERE auth0_sub = $2
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [emailVerified, auth0Sub]);
if (result.rows.length === 0) {
throw new Error('User profile not found');
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error updating email verified status', { error, auth0Sub, emailVerified });
throw error;
}
}
/**
* Mark onboarding as complete
*/
async markOnboardingComplete(auth0Sub: string): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET onboarding_completed_at = NOW(), updated_at = NOW()
WHERE auth0_sub = $1 AND onboarding_completed_at IS NULL
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
if (result.rows.length === 0) {
// Check if already completed or profile not found
const existing = await this.getByAuth0Sub(auth0Sub);
if (existing && existing.onboardingCompletedAt) {
return existing; // Already completed, return as-is
}
throw new Error('User profile not found');
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error marking onboarding complete', { error, auth0Sub });
throw error;
}
}
/**
* Update user email (used when fetching correct email from Auth0)
*/
async updateEmail(auth0Sub: string, email: string): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET email = $1, updated_at = NOW()
WHERE auth0_sub = $2
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [email, auth0Sub]);
if (result.rows.length === 0) {
throw new Error('User profile not found');
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error updating user email', { error, auth0Sub });
throw error;
}
}
/**
* Request account deletion (sets deletion timestamps and deactivates account)
* 30-day grace period before permanent deletion
*/
async requestDeletion(auth0Sub: string): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET
deletion_requested_at = NOW(),
deletion_scheduled_for = NOW() + INTERVAL '30 days',
deactivated_at = NOW(),
updated_at = NOW()
WHERE auth0_sub = $1 AND deletion_requested_at IS NULL
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
if (result.rows.length === 0) {
throw new Error('User profile not found or deletion already requested');
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error requesting account deletion', { error, auth0Sub });
throw error;
}
}
/**
* Cancel deletion request (clears deletion timestamps and reactivates account)
*/
async cancelDeletion(auth0Sub: string): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET
deletion_requested_at = NULL,
deletion_scheduled_for = NULL,
deactivated_at = NULL,
deactivated_by = NULL,
updated_at = NOW()
WHERE auth0_sub = $1 AND deletion_requested_at IS NOT NULL
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
if (result.rows.length === 0) {
throw new Error('User profile not found or no deletion request pending');
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error canceling account deletion', { error, auth0Sub });
throw error;
}
}
/**
* Get users whose deletion grace period has expired
*/
async getUsersPastGracePeriod(): Promise<UserProfile[]> {
const query = `
SELECT ${USER_PROFILE_COLUMNS}
FROM user_profiles
WHERE deletion_scheduled_for IS NOT NULL
AND deletion_scheduled_for <= NOW()
ORDER BY deletion_scheduled_for ASC
`;
try {
const result = await this.pool.query(query);
return result.rows.map(row => this.mapRowToUserProfile(row));
} catch (error) {
logger.error('Error fetching users past grace period', { error });
throw error;
}
}
/**
* Hard delete user and all associated data
* This is a permanent operation - use with caution
*/
async hardDeleteUser(auth0Sub: string): Promise<void> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// 1. Anonymize community station submissions (keep data but remove user reference)
await client.query(
`UPDATE community_stations
SET submitted_by = 'deleted-user'
WHERE submitted_by = $1`,
[auth0Sub]
);
// 2. Delete notification logs
await client.query(
'DELETE FROM notification_logs WHERE user_id = $1',
[auth0Sub]
);
// 3. Delete user notifications
await client.query(
'DELETE FROM user_notifications WHERE user_id = $1',
[auth0Sub]
);
// 4. Delete saved stations
await client.query(
'DELETE FROM saved_stations WHERE user_id = $1',
[auth0Sub]
);
// 5. Delete vehicles (cascades to fuel_logs, maintenance_logs, maintenance_schedules, documents)
await client.query(
'DELETE FROM vehicles WHERE user_id = $1',
[auth0Sub]
);
// 6. Delete user preferences
await client.query(
'DELETE FROM user_preferences WHERE user_id = $1',
[auth0Sub]
);
// 7. Delete user profile (final step)
await client.query(
'DELETE FROM user_profiles WHERE auth0_sub = $1',
[auth0Sub]
);
await client.query('COMMIT');
logger.info('User hard deleted successfully', { auth0Sub });
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error hard deleting user', { error, auth0Sub });
throw error;
} finally {
client.release();
}
}
}

View File

@@ -12,9 +12,11 @@ import {
ListUsersQuery,
ListUsersResponse,
SubscriptionTier,
DeletionStatus,
} from './user-profile.types';
import { AdminRepository } from '../../admin/data/admin.repository';
import { logger } from '../../../core/logging/logger';
import { auth0ManagementClient } from '../../../core/auth/auth0-management.client';
export class UserProfileService {
private adminRepository: AdminRepository | null = null;
@@ -320,4 +322,178 @@ export class UserProfileService {
throw error;
}
}
// ============================================
// Account deletion methods (GDPR compliance)
// ============================================
/**
* Request account deletion with password verification
* Sets 30-day grace period before permanent deletion
*/
async requestDeletion(
auth0Sub: string,
password: string,
confirmationText: string
): Promise<UserProfile> {
try {
// Validate confirmation text
if (confirmationText !== 'DELETE') {
throw new Error('Invalid confirmation text');
}
// Get user profile
const profile = await this.repository.getByAuth0Sub(auth0Sub);
if (!profile) {
throw new Error('User not found');
}
// Check if deletion already requested
if (profile.deletionRequestedAt) {
throw new Error('Deletion already requested');
}
// Verify password with Auth0
const passwordValid = await auth0ManagementClient.verifyPassword(profile.email, password);
if (!passwordValid) {
throw new Error('Invalid password');
}
// Request deletion
const updatedProfile = await this.repository.requestDeletion(auth0Sub);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
auth0Sub,
'REQUEST_DELETION',
auth0Sub,
'user_profile',
updatedProfile.id,
{
deletionScheduledFor: updatedProfile.deletionScheduledFor,
}
);
}
logger.info('Account deletion requested', {
auth0Sub,
deletionScheduledFor: updatedProfile.deletionScheduledFor,
});
return updatedProfile;
} catch (error) {
logger.error('Error requesting account deletion', { error, auth0Sub });
throw error;
}
}
/**
* Cancel pending deletion request
*/
async cancelDeletion(auth0Sub: string): Promise<UserProfile> {
try {
// Cancel deletion
const updatedProfile = await this.repository.cancelDeletion(auth0Sub);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
auth0Sub,
'CANCEL_DELETION',
auth0Sub,
'user_profile',
updatedProfile.id,
{}
);
}
logger.info('Account deletion canceled', { auth0Sub });
return updatedProfile;
} catch (error) {
logger.error('Error canceling account deletion', { error, auth0Sub });
throw error;
}
}
/**
* Get deletion status for a user profile
*/
getDeletionStatus(profile: UserProfile): DeletionStatus {
if (!profile.deletionRequestedAt || !profile.deletionScheduledFor) {
return {
isPendingDeletion: false,
deletionRequestedAt: null,
deletionScheduledFor: null,
daysRemaining: null,
};
}
const now = new Date();
const scheduledDate = new Date(profile.deletionScheduledFor);
const millisecondsRemaining = scheduledDate.getTime() - now.getTime();
const daysRemaining = Math.ceil(millisecondsRemaining / (1000 * 60 * 60 * 24));
return {
isPendingDeletion: true,
deletionRequestedAt: profile.deletionRequestedAt,
deletionScheduledFor: profile.deletionScheduledFor,
daysRemaining: Math.max(0, daysRemaining),
};
}
/**
* Admin hard delete user (permanent deletion)
* Prevents self-delete
*/
async adminHardDeleteUser(
auth0Sub: string,
actorAuth0Sub: string,
reason?: string
): Promise<void> {
try {
// Prevent self-delete
if (auth0Sub === actorAuth0Sub) {
throw new Error('Cannot delete your own account');
}
// Get user profile before deletion for audit log
const profile = await this.repository.getByAuth0Sub(auth0Sub);
if (!profile) {
throw new Error('User not found');
}
// Log to audit trail before deletion
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
actorAuth0Sub,
'HARD_DELETE_USER',
auth0Sub,
'user_profile',
profile.id,
{
reason: reason || 'No reason provided',
email: profile.email,
displayName: profile.displayName,
}
);
}
// Hard delete from database
await this.repository.hardDeleteUser(auth0Sub);
// Delete from Auth0
await auth0ManagementClient.deleteUser(auth0Sub);
logger.info('User hard deleted by admin', {
auth0Sub,
actorAuth0Sub,
reason,
});
} catch (error) {
logger.error('Error hard deleting user', { error, auth0Sub, actorAuth0Sub });
throw error;
}
}
}

View File

@@ -13,8 +13,12 @@ export interface UserProfile {
displayName?: string;
notificationEmail?: string;
subscriptionTier: SubscriptionTier;
emailVerified: boolean;
onboardingCompletedAt: Date | null;
deactivatedAt: Date | null;
deactivatedBy: string | null;
deletionRequestedAt: Date | null;
deletionScheduledFor: Date | null;
createdAt: Date;
updatedAt: Date;
}
@@ -64,3 +68,17 @@ export interface Auth0UserData {
email: string;
name?: string;
}
// Request to delete user account
export interface RequestDeletionRequest {
password: string;
confirmationText: string;
}
// Deletion status for user account
export interface DeletionStatus {
isPendingDeletion: boolean;
deletionRequestedAt: Date | null;
deletionScheduledFor: Date | null;
daysRemaining: number | null;
}

View 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;
}
}

View File

@@ -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';

View File

@@ -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)';

View File

@@ -110,6 +110,8 @@ services:
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
# Filesystem storage for documents
- ./data/documents:/app/data/documents
networks:

View File

@@ -19,7 +19,7 @@ comprehensive spec.md - containing requirements, architecture decisions, data mo
You are a senior software engineer specializsing in NodeJS, Typescript, front end and back end development. You will be delegating tasks to the platform-agent, feature-agent, first-frontend-agent and quality-agent when appropriate.
*** ACTION ***
- You will be enhancing the maintenance record feature.
- You will be implementing improvements to the User Management.
- Make no assumptions.
- Ask clarifying questions.
- Ultrathink
@@ -27,8 +27,11 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
*** CONTEXT ***
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
- There is a basic maintenance record system implemented.
- We need to implement schedule maintenance records with notifications tied into the existing notification system.
- There is no delete option for users
- GPDR requires that users are able to fully delete their information
- There is a Delete button in the user settings. This needs to be implemented
- The same functionality should be enabled admin settings for user management.
*** CHANGES TO IMPLEMENT ***
- Research this code base and ask iterative questions to compile a complete plan.

View File

@@ -43,6 +43,17 @@ const AdminEmailTemplatesMobileScreen = lazy(() => import('./features/admin/mobi
// Admin Community Stations (lazy-loaded)
const AdminCommunityStationsPage = lazy(() => import('./features/admin/pages/AdminCommunityStationsPage').then(m => ({ default: m.AdminCommunityStationsPage })));
const AdminCommunityStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminCommunityStationsMobileScreen').then(m => ({ default: m.AdminCommunityStationsMobileScreen })));
// Auth pages (lazy-loaded)
const SignupPage = lazy(() => import('./features/auth/pages/SignupPage').then(m => ({ default: m.SignupPage })));
const VerifyEmailPage = lazy(() => import('./features/auth/pages/VerifyEmailPage').then(m => ({ default: m.VerifyEmailPage })));
const SignupMobileScreen = lazy(() => import('./features/auth/mobile/SignupMobileScreen').then(m => ({ default: m.SignupMobileScreen })));
const VerifyEmailMobileScreen = lazy(() => import('./features/auth/mobile/VerifyEmailMobileScreen').then(m => ({ default: m.VerifyEmailMobileScreen })));
// Onboarding pages (lazy-loaded)
const OnboardingPage = lazy(() => import('./features/onboarding/pages/OnboardingPage').then(m => ({ default: m.OnboardingPage })));
const OnboardingMobileScreen = lazy(() => import('./features/onboarding/mobile/OnboardingMobileScreen').then(m => ({ default: m.OnboardingMobileScreen })));
import { HomePage } from './pages/HomePage';
import { BottomNavigation } from './shared-minimal/components/mobile/BottomNavigation';
import { QuickAction } from './shared-minimal/components/mobile/quickActions';
@@ -399,7 +410,11 @@ function App() {
const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/');
const isCallbackRoute = location.pathname === '/callback';
const shouldShowHomePage = !isGarageRoute && !isCallbackRoute;
const isSignupRoute = location.pathname === '/signup';
const isVerifyEmailRoute = location.pathname === '/verify-email';
const isOnboardingRoute = location.pathname === '/onboarding';
const isAuthRoute = isSignupRoute || isVerifyEmailRoute || isOnboardingRoute;
const shouldShowHomePage = !isGarageRoute && !isCallbackRoute && !isAuthRoute;
// Enhanced navigation handlers for mobile
const handleVehicleSelect = useCallback((vehicle: Vehicle) => {
@@ -475,10 +490,60 @@ function App() {
);
}
// Signup route is public - no authentication required
if (isSignupRoute) {
return (
<ThemeProvider theme={md3Theme}>
<CssBaseline />
<React.Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">Loading...</div>
</div>
}>
{mobileMode ? <SignupMobileScreen /> : <SignupPage />}
</React.Suspense>
<DebugInfo />
</ThemeProvider>
);
}
if (!isAuthenticated) {
return <Navigate to="/" replace />;
}
// Verify email and onboarding routes require authentication but not full initialization
if (isVerifyEmailRoute) {
return (
<ThemeProvider theme={md3Theme}>
<CssBaseline />
<React.Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">Loading...</div>
</div>
}>
{mobileMode ? <VerifyEmailMobileScreen /> : <VerifyEmailPage />}
</React.Suspense>
<DebugInfo />
</ThemeProvider>
);
}
if (isOnboardingRoute) {
return (
<ThemeProvider theme={md3Theme}>
<CssBaseline />
<React.Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">Loading...</div>
</div>
}>
{mobileMode ? <OnboardingMobileScreen /> : <OnboardingPage />}
</React.Suspense>
<DebugInfo />
</ThemeProvider>
);
}
// Wait for auth gate to be ready before rendering protected routes
// This prevents a race condition where the page renders before the auth token is ready
if (!isAuthGateReady) {

View File

@@ -359,5 +359,13 @@ export const adminApi = {
);
return response.data;
},
hardDelete: async (auth0Sub: string, reason?: string): Promise<{ message: string }> => {
const response = await apiClient.delete<{ message: string }>(
`/admin/users/${encodeURIComponent(auth0Sub)}`,
{ params: reason ? { reason } : undefined }
);
return response.data;
},
},
};

View File

@@ -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'
);
},
});
};

View File

@@ -15,6 +15,7 @@ import {
useReactivateUser,
useUpdateUserProfile,
usePromoteToAdmin,
useHardDeleteUser,
} from '../hooks/useUsers';
import {
ManagedUser,
@@ -103,6 +104,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
const reactivateMutation = useReactivateUser();
const updateProfileMutation = useUpdateUserProfile();
const promoteToAdminMutation = usePromoteToAdmin();
const hardDeleteMutation = useHardDeleteUser();
// Selected user for actions
const [selectedUser, setSelectedUser] = useState<ManagedUser | null>(null);
@@ -115,6 +117,9 @@ export const AdminUsersMobileScreen: React.FC = () => {
const [editDisplayName, setEditDisplayName] = useState('');
const [showPromoteModal, setShowPromoteModal] = useState(false);
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
const [showHardDeleteModal, setShowHardDeleteModal] = useState(false);
const [hardDeleteReason, setHardDeleteReason] = useState('');
const [hardDeleteConfirmText, setHardDeleteConfirmText] = useState('');
// Handlers
const handleSearch = useCallback(() => {
@@ -256,6 +261,34 @@ export const AdminUsersMobileScreen: React.FC = () => {
setSelectedUser(null);
}, []);
const handleHardDeleteClick = useCallback(() => {
setShowUserActions(false);
setShowHardDeleteModal(true);
}, []);
const handleHardDeleteConfirm = useCallback(() => {
if (selectedUser && hardDeleteConfirmText === 'DELETE') {
hardDeleteMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined },
{
onSuccess: () => {
setShowHardDeleteModal(false);
setHardDeleteReason('');
setHardDeleteConfirmText('');
setSelectedUser(null);
},
}
);
}
}, [selectedUser, hardDeleteReason, hardDeleteConfirmText, hardDeleteMutation]);
const handleHardDeleteCancel = useCallback(() => {
setShowHardDeleteModal(false);
setHardDeleteReason('');
setHardDeleteConfirmText('');
setSelectedUser(null);
}, []);
const handleLoadMore = useCallback(() => {
setParams(prev => ({ ...prev, page: (prev.page || 1) + 1 }));
}, []);
@@ -527,6 +560,15 @@ export const AdminUsersMobileScreen: React.FC = () => {
Deactivate User
</button>
)}
{!selectedUser.isAdmin && (
<button
onClick={handleHardDeleteClick}
className="w-full py-3 text-left text-red-600 font-medium min-h-[44px]"
>
Delete Permanently
</button>
)}
</div>
</div>
)}
@@ -716,6 +758,82 @@ export const AdminUsersMobileScreen: React.FC = () => {
</p>
</div>
</Modal>
{/* Hard Delete Confirmation Modal */}
<Modal
isOpen={showHardDeleteModal}
onClose={() => !hardDeleteMutation.isPending && handleHardDeleteCancel()}
title="Permanently Delete User"
actions={
<>
<button
onClick={handleHardDeleteCancel}
disabled={hardDeleteMutation.isPending}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
>
Cancel
</button>
<button
onClick={handleHardDeleteConfirm}
disabled={hardDeleteMutation.isPending || hardDeleteConfirmText !== 'DELETE'}
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
>
{hardDeleteMutation.isPending ? 'Deleting...' : 'Delete'}
</button>
</>
}
>
<div className="space-y-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm font-semibold text-red-800">
Warning: This action cannot be undone!
</p>
<p className="text-sm text-red-700 mt-1">
All user data will be permanently deleted, including vehicles, fuel logs,
maintenance records, and documents.
</p>
</div>
<p className="text-slate-600">
Are you sure you want to permanently delete{' '}
<strong>{selectedUser?.email}</strong>?
</p>
<div>
<label className="text-sm font-medium text-slate-600 block mb-1">
Reason for deletion
</label>
<textarea
value={hardDeleteReason}
onChange={(e) => setHardDeleteReason(e.target.value)}
placeholder="GDPR request, user request, etc..."
className="w-full px-3 py-2 rounded-lg border border-slate-200 resize-none min-h-[60px]"
rows={2}
/>
</div>
<div>
<label className="text-sm font-medium text-slate-600 block mb-1">
Type <strong>DELETE</strong> to confirm
</label>
<input
type="text"
value={hardDeleteConfirmText}
onChange={(e) => setHardDeleteConfirmText(e.target.value.toUpperCase())}
placeholder="Type DELETE"
className={`w-full px-3 py-2 rounded-lg border min-h-[44px] ${
hardDeleteConfirmText && hardDeleteConfirmText !== 'DELETE'
? 'border-red-500'
: 'border-slate-200'
}`}
style={{ fontSize: '16px' }}
/>
{hardDeleteConfirmText && hardDeleteConfirmText !== 'DELETE' && (
<p className="text-sm text-red-500 mt-1">Please type DELETE exactly</p>
)}
</div>
</div>
</Modal>
</MobileContainer>
);
};

View 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;
},
};

View 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>
);
};

View 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);
},
});
};

View 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);
},
});
};

View 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';

View 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;

View File

@@ -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;

View 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>
);
};

View 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>
);
};

View 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;
}

View 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;
},
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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);
},
});
};

View 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';

View File

@@ -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;

View 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;

View 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';

View File

@@ -3,9 +3,19 @@
*/
import { apiClient } from '../../../core/api/client';
import { UserProfile, UpdateProfileRequest } from '../types/profile.types';
import {
UserProfile,
UpdateProfileRequest,
DeletionStatus,
RequestDeletionRequest,
RequestDeletionResponse,
CancelDeletionResponse,
} from '../types/profile.types';
export const profileApi = {
getProfile: () => apiClient.get<UserProfile>('/user/profile'),
updateProfile: (data: UpdateProfileRequest) => apiClient.put<UserProfile>('/user/profile', data),
requestDeletion: (data: RequestDeletionRequest) => apiClient.post<RequestDeletionResponse>('/user/delete', data),
cancelDeletion: () => apiClient.post<CancelDeletionResponse>('/user/cancel-deletion'),
getDeletionStatus: () => apiClient.get<DeletionStatus>('/user/deletion-status'),
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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');
},
});
};

View 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>
);
};

View File

@@ -6,6 +6,8 @@ import { useSettings } from '../hooks/useSettings';
import { useProfile, useUpdateProfile } from '../hooks/useProfile';
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
import { useNavigationStore } from '../../../core/store';
import { DeleteAccountModal } from './DeleteAccountModal';
import { PendingDeletionBanner } from './PendingDeletionBanner';
interface ToggleSwitchProps {
enabled: boolean;
@@ -105,11 +107,6 @@ export const MobileSettingsScreen: React.FC = () => {
setShowDataExport(false);
};
const handleDeleteAccount = () => {
// TODO: Implement account deletion
console.log('Deleting account...');
setShowDeleteConfirm(false);
};
const handleEditProfile = () => {
setIsEditingProfile(true);
@@ -190,6 +187,9 @@ export const MobileSettingsScreen: React.FC = () => {
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
</div>
{/* Pending Deletion Banner */}
<PendingDeletionBanner />
{/* Profile Section */}
<GlassCard padding="md">
<div>
@@ -488,30 +488,11 @@ export const MobileSettingsScreen: React.FC = () => {
</div>
</Modal>
{/* Delete Account Confirmation */}
<Modal
{/* Delete Account Modal */}
<DeleteAccountModal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title="Delete Account"
>
<p className="text-slate-600 mb-4">
This action cannot be undone. All your data will be permanently deleted.
</p>
<div className="flex space-x-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
>
Cancel
</button>
<button
onClick={handleDeleteAccount}
className="flex-1 py-2 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
Delete
</button>
</div>
</Modal>
/>
</div>
</MobileContainer>
);

View File

@@ -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>
);
};

View File

@@ -16,3 +16,26 @@ export interface UpdateProfileRequest {
displayName?: string;
notificationEmail?: string;
}
export interface DeletionStatus {
isPendingDeletion: boolean;
deletionRequestedAt: string | null;
deletionScheduledFor: string | null;
daysRemaining: number | null;
}
export interface RequestDeletionRequest {
password: string;
confirmationText: string;
}
export interface RequestDeletionResponse {
message: string;
deletionScheduledFor: string;
daysRemaining: number;
}
export interface CancelDeletionResponse {
message: string;
profile: UserProfile;
}

View File

@@ -19,6 +19,10 @@ export const HomePage = () => {
loginWithRedirect({ appState: { returnTo: '/garage' } });
};
const handleSignup = () => {
navigate('/signup');
};
return (
<div className="min-h-screen bg-white">
{/* Navigation Bar */}
@@ -44,6 +48,12 @@ export const HomePage = () => {
<a href="#about" className="text-gray-700 hover:text-primary-500 transition-colors">
About
</a>
<button
onClick={handleSignup}
className="border-2 border-primary-500 text-primary-500 hover:bg-primary-50 font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
>
Sign Up
</button>
<button
onClick={handleAuthAction}
className="bg-primary-500 hover:bg-primary-700 text-white font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
@@ -103,6 +113,12 @@ export const HomePage = () => {
>
About
</a>
<button
onClick={handleSignup}
className="w-full border-2 border-primary-500 text-primary-500 hover:bg-primary-50 font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
>
Sign Up
</button>
<button
onClick={handleAuthAction}
className="w-full bg-primary-500 hover:bg-primary-700 text-white font-semibold py-2 px-6 rounded-lg transition-colors duration-300"

View File

@@ -8,6 +8,8 @@ import { useNavigate } from 'react-router-dom';
import { useUnits } from '../core/units/UnitsContext';
import { useAdminAccess } from '../core/auth/useAdminAccess';
import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile';
import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog';
import { PendingDeletionBanner } from '../features/settings/components/PendingDeletionBanner';
import {
Box,
Typography,
@@ -52,6 +54,7 @@ export const SettingsPage: React.FC = () => {
const [isEditingProfile, setIsEditingProfile] = useState(false);
const [editedDisplayName, setEditedDisplayName] = useState('');
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// Initialize edit form when profile loads or edit mode starts
React.useEffect(() => {
@@ -105,6 +108,8 @@ export const SettingsPage: React.FC = () => {
Settings
</Typography>
<PendingDeletionBanner />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Profile Section */}
<Card>
@@ -477,9 +482,10 @@ export const SettingsPage: React.FC = () => {
>
Sign Out
</MuiButton>
<MuiButton
variant="outlined"
<MuiButton
variant="outlined"
color="error"
onClick={() => setDeleteDialogOpen(true)}
sx={{ borderRadius: '999px' }}
>
Delete Account
@@ -487,6 +493,8 @@ export const SettingsPage: React.FC = () => {
</Box>
</Card>
</Box>
<DeleteAccountDialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)} />
</Box>
);
};

View File

@@ -43,6 +43,7 @@ import {
PersonAdd,
Edit,
Security,
DeleteForever,
} from '@mui/icons-material';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
import {
@@ -52,6 +53,7 @@ import {
useReactivateUser,
useUpdateUserProfile,
usePromoteToAdmin,
useHardDeleteUser,
} from '../../features/admin/hooks/useUsers';
import {
ManagedUser,
@@ -84,6 +86,7 @@ export const AdminUsersPage: React.FC = () => {
const reactivateMutation = useReactivateUser();
const updateProfileMutation = useUpdateUserProfile();
const promoteToAdminMutation = usePromoteToAdmin();
const hardDeleteMutation = useHardDeleteUser();
// Action menu state
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@@ -102,6 +105,11 @@ export const AdminUsersPage: React.FC = () => {
const [promoteDialogOpen, setPromoteDialogOpen] = useState(false);
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
// Hard delete dialog state
const [hardDeleteDialogOpen, setHardDeleteDialogOpen] = useState(false);
const [hardDeleteReason, setHardDeleteReason] = useState('');
const [hardDeleteConfirmText, setHardDeleteConfirmText] = useState('');
// Handlers
const handleSearch = useCallback(() => {
setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 }));
@@ -262,6 +270,34 @@ export const AdminUsersPage: React.FC = () => {
setSelectedUser(null);
}, []);
const handleHardDeleteClick = useCallback(() => {
setHardDeleteDialogOpen(true);
setAnchorEl(null);
}, []);
const handleHardDeleteConfirm = useCallback(() => {
if (selectedUser && hardDeleteConfirmText === 'DELETE') {
hardDeleteMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined },
{
onSuccess: () => {
setHardDeleteDialogOpen(false);
setHardDeleteReason('');
setHardDeleteConfirmText('');
setSelectedUser(null);
},
}
);
}
}, [selectedUser, hardDeleteReason, hardDeleteConfirmText, hardDeleteMutation]);
const handleHardDeleteCancel = useCallback(() => {
setHardDeleteDialogOpen(false);
setHardDeleteReason('');
setHardDeleteConfirmText('');
setSelectedUser(null);
}, []);
// Loading state
if (adminLoading) {
return (
@@ -485,6 +521,12 @@ export const AdminUsersPage: React.FC = () => {
Deactivate User
</MenuItem>
)}
{!selectedUser?.isAdmin && (
<MenuItem onClick={handleHardDeleteClick} sx={{ color: 'error.main' }}>
<DeleteForever sx={{ mr: 1 }} fontSize="small" />
Delete Permanently
</MenuItem>
)}
</Menu>
{/* Deactivate Confirmation Dialog */}
@@ -624,6 +666,81 @@ export const AdminUsersPage: React.FC = () => {
</Button>
</DialogActions>
</Dialog>
{/* Hard Delete Confirmation Dialog */}
<Dialog
open={hardDeleteDialogOpen}
onClose={() => !hardDeleteMutation.isPending && handleHardDeleteCancel()}
maxWidth="sm"
fullWidth
>
<DialogTitle sx={{ color: 'error.main' }}>
Permanently Delete User
</DialogTitle>
<DialogContent>
<Box sx={{ bgcolor: 'error.light', color: 'error.contrastText', p: 2, borderRadius: 1, mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
Warning: This action cannot be undone!
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
All user data will be permanently deleted, including vehicles, fuel logs,
maintenance records, and documents. The user's Auth0 account will also be deleted.
</Typography>
</Box>
<Typography sx={{ mb: 2 }}>
Are you sure you want to permanently delete{' '}
<strong>{selectedUser?.email}</strong>?
</Typography>
<TextField
label="Reason for deletion"
value={hardDeleteReason}
onChange={(e) => setHardDeleteReason(e.target.value)}
fullWidth
multiline
rows={2}
placeholder="GDPR request, user request, etc..."
sx={{ mb: 3 }}
/>
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Type <strong>DELETE</strong> to confirm:
</Typography>
<TextField
value={hardDeleteConfirmText}
onChange={(e) => setHardDeleteConfirmText(e.target.value.toUpperCase())}
fullWidth
placeholder="Type DELETE"
error={hardDeleteConfirmText.length > 0 && hardDeleteConfirmText !== 'DELETE'}
helperText={
hardDeleteConfirmText.length > 0 && hardDeleteConfirmText !== 'DELETE'
? 'Please type DELETE exactly'
: ''
}
/>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={handleHardDeleteCancel}
disabled={hardDeleteMutation.isPending}
sx={{ textTransform: 'none' }}
>
Cancel
</Button>
<Button
onClick={handleHardDeleteConfirm}
disabled={hardDeleteMutation.isPending || hardDeleteConfirmText !== 'DELETE'}
color="error"
variant="contained"
sx={{ textTransform: 'none' }}
>
{hardDeleteMutation.isPending ? <CircularProgress size={20} /> : 'Delete Permanently'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@@ -13,6 +13,8 @@
# Required GitLab CI/CD Variables (File type):
# - POSTGRES_PASSWORD
# - AUTH0_CLIENT_SECRET
# - AUTH0_MANAGEMENT_CLIENT_ID (Auth0 Management API client ID for user signup)
# - AUTH0_MANAGEMENT_CLIENT_SECRET (Auth0 Management API client secret)
# - GOOGLE_MAPS_API_KEY
# - GOOGLE_MAPS_MAP_ID
# - CF_DNS_API_TOKEN (Cloudflare DNS API token for Let's Encrypt certificates)
@@ -31,6 +33,8 @@ SECRETS_DIR="${DEPLOY_PATH}/secrets/app"
SECRET_FILES=(
"postgres-password.txt"
"auth0-client-secret.txt"
"auth0-management-client-id.txt"
"auth0-management-client-secret.txt"
"google-maps-api-key.txt"
"google-maps-map-id.txt"
"cloudflare-dns-token.txt"
@@ -100,6 +104,8 @@ FAILED=0
inject_secret "POSTGRES_PASSWORD" "postgres-password.txt" || FAILED=1
inject_secret "AUTH0_CLIENT_SECRET" "auth0-client-secret.txt" || FAILED=1
inject_secret "AUTH0_MANAGEMENT_CLIENT_ID" "auth0-management-client-id.txt" || FAILED=1
inject_secret "AUTH0_MANAGEMENT_CLIENT_SECRET" "auth0-management-client-secret.txt" || FAILED=1
inject_secret "GOOGLE_MAPS_API_KEY" "google-maps-api-key.txt" || FAILED=1
inject_secret "GOOGLE_MAPS_MAP_ID" "google-maps-map-id.txt" || FAILED=1
inject_secret "CF_DNS_API_TOKEN" "cloudflare-dns-token.txt" || FAILED=1