feat: user export service. bug and UX fixes. Complete minus outstanding email template fixes.
This commit is contained in:
@@ -28,6 +28,7 @@ import { notificationsRoutes } from './features/notifications';
|
||||
import { userProfileRoutes } from './features/user-profile';
|
||||
import { onboardingRoutes } from './features/onboarding';
|
||||
import { userPreferencesRoutes } from './features/user-preferences';
|
||||
import { userExportRoutes } from './features/user-export';
|
||||
import { pool } from './core/config/database';
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
@@ -85,7 +86,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env['NODE_ENV'],
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences']
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,7 +96,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
scope: 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences']
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,6 +135,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(notificationsRoutes, { prefix: '/api' });
|
||||
await app.register(userProfileRoutes, { prefix: '/api' });
|
||||
await app.register(userPreferencesRoutes, { prefix: '/api' });
|
||||
await app.register(userExportRoutes, { prefix: '/api' });
|
||||
|
||||
// 404 handler
|
||||
app.setNotFoundHandler(async (_request, reply) => {
|
||||
|
||||
@@ -151,6 +151,40 @@ class Auth0ManagementClientSingleton {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email to user
|
||||
* Uses Auth0 Authentication API (not Management API)
|
||||
* @param email User's email address
|
||||
*/
|
||||
async sendPasswordResetEmail(email: string): Promise<void> {
|
||||
try {
|
||||
const config = appConfig.getAuth0ManagementConfig();
|
||||
|
||||
const response = await fetch(`https://${config.domain}/dbconnections/change_password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: config.clientId,
|
||||
email,
|
||||
connection: this.CONNECTION_NAME,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
logger.error('Password reset email request failed', { email: email.substring(0, 3) + '***', status: response.status, error: errorBody });
|
||||
throw new Error(`Password reset email failed: ${response.status}`);
|
||||
}
|
||||
|
||||
logger.info('Password reset email sent', { email: email.substring(0, 3) + '***' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to send password reset email', { email: email.substring(0, 3) + '***', error });
|
||||
throw new Error(`Failed to send password reset email: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user password using Resource Owner Password Grant
|
||||
* @param email User's email address
|
||||
|
||||
@@ -182,4 +182,63 @@ export class AuthController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/security-status
|
||||
* Get user security status
|
||||
* Protected endpoint - requires JWT
|
||||
*/
|
||||
async getSecurityStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
const result = await this.authService.getSecurityStatus(userId);
|
||||
|
||||
logger.info('Security status retrieved', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
emailVerified: result.emailVerified,
|
||||
});
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get security status', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get security status',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/request-password-reset
|
||||
* Request password reset email
|
||||
* Protected endpoint - requires JWT
|
||||
*/
|
||||
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
const result = await this.authService.requestPasswordReset(userId);
|
||||
|
||||
logger.info('Password reset email requested', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
});
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to request password reset', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to send password reset email. Please try again later.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,16 @@ export const authRoutes: FastifyPluginAsync = async (
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: authController.getUserStatus.bind(authController),
|
||||
});
|
||||
|
||||
// GET /api/auth/security-status - Get security status (requires JWT)
|
||||
fastify.get('/auth/security-status', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: authController.getSecurityStatus.bind(authController),
|
||||
});
|
||||
|
||||
// POST /api/auth/request-password-reset - Request password reset email (requires JWT)
|
||||
fastify.post('/auth/request-password-reset', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: authController.requestPasswordReset.bind(authController),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
SignupResponse,
|
||||
VerifyStatusResponse,
|
||||
ResendVerificationResponse,
|
||||
SecurityStatusResponse,
|
||||
PasswordResetResponse,
|
||||
} from './auth.types';
|
||||
|
||||
export class AuthService {
|
||||
@@ -210,4 +212,61 @@ export class AuthService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security status for the user
|
||||
* Returns email verification status and passkey info
|
||||
*/
|
||||
async getSecurityStatus(auth0Sub: string): Promise<SecurityStatusResponse> {
|
||||
try {
|
||||
// Get user details from Auth0
|
||||
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
|
||||
|
||||
logger.info('Retrieved security status', {
|
||||
auth0Sub: auth0Sub.substring(0, 8) + '...',
|
||||
emailVerified: auth0User.emailVerified,
|
||||
});
|
||||
|
||||
return {
|
||||
emailVerified: auth0User.emailVerified,
|
||||
email: auth0User.email,
|
||||
// Passkeys are enabled at the Auth0 connection level, not per-user
|
||||
// This is informational - actual passkey enrollment happens in Auth0 Universal Login
|
||||
passkeysEnabled: true,
|
||||
// Auth0 doesn't expose password last changed date via Management API
|
||||
// Would require Auth0 Logs API or user_metadata to track this
|
||||
passwordLastChanged: null,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get security status', { auth0Sub, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request password reset email
|
||||
* Triggers Auth0 to send password reset email to user
|
||||
*/
|
||||
async requestPasswordReset(auth0Sub: string): Promise<PasswordResetResponse> {
|
||||
try {
|
||||
// Get user email from Auth0
|
||||
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
|
||||
|
||||
// Send password reset email via Auth0
|
||||
await auth0ManagementClient.sendPasswordResetEmail(auth0User.email);
|
||||
|
||||
logger.info('Password reset email requested', {
|
||||
auth0Sub: auth0Sub.substring(0, 8) + '...',
|
||||
email: auth0User.email.substring(0, 3) + '***',
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Password reset email sent. Please check your inbox.',
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to request password reset', { auth0Sub, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,3 +26,17 @@ export interface VerifyStatusResponse {
|
||||
export interface ResendVerificationResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Response from security status endpoint
|
||||
export interface SecurityStatusResponse {
|
||||
emailVerified: boolean;
|
||||
email: string;
|
||||
passkeysEnabled: boolean;
|
||||
passwordLastChanged: string | null;
|
||||
}
|
||||
|
||||
// Response from password reset request endpoint
|
||||
export interface PasswordResetResponse {
|
||||
message: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
@@ -416,11 +416,12 @@ export class BackupRepository {
|
||||
next.setDate(next.getDate() + 1);
|
||||
next.setHours(3, 0, 0, 0);
|
||||
break;
|
||||
case 'weekly':
|
||||
case 'weekly': {
|
||||
const daysUntilSunday = (7 - next.getDay()) % 7 || 7;
|
||||
next.setDate(next.getDate() + daysUntilSunday);
|
||||
next.setHours(3, 0, 0, 0);
|
||||
break;
|
||||
}
|
||||
case 'monthly':
|
||||
next.setMonth(next.getMonth() + 1);
|
||||
next.setDate(1);
|
||||
|
||||
@@ -384,4 +384,12 @@ export class MaintenanceRepository {
|
||||
);
|
||||
return result.rows[0] ? this.mapMaintenanceSchedule(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findSchedulesByUserId(userId: string): Promise<MaintenanceSchedule[]> {
|
||||
const res = await this.db.query(
|
||||
`SELECT * FROM maintenance_schedules WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows.map(row => this.mapMaintenanceSchedule(row));
|
||||
}
|
||||
}
|
||||
|
||||
213
backend/src/features/user-export/README.md
Normal file
213
backend/src/features/user-export/README.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# User Export Feature
|
||||
|
||||
Provides user data export functionality, allowing authenticated users to download a complete archive of their personal data including all vehicles, fuel logs, documents, maintenance records, and associated files.
|
||||
|
||||
## Overview
|
||||
|
||||
This feature creates a TAR.GZ archive containing all user data in JSON format plus actual files (vehicle images, document PDFs). The export is synchronous and streams directly to the client for immediate download.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
user-export/
|
||||
├── domain/
|
||||
│ ├── user-export.types.ts # Type definitions
|
||||
│ ├── user-export.service.ts # Main service
|
||||
│ └── user-export-archive.service.ts # Archive creation logic
|
||||
└── api/
|
||||
├── user-export.controller.ts # HTTP handlers
|
||||
└── user-export.routes.ts # Route definitions
|
||||
```
|
||||
|
||||
## Archive Structure
|
||||
|
||||
```
|
||||
motovaultpro_export_YYYY-MM-DDTHH-MM-SS.tar.gz
|
||||
├── manifest.json # Archive metadata
|
||||
├── data/
|
||||
│ ├── vehicles.json # All user vehicles
|
||||
│ ├── fuel-logs.json # All fuel logs
|
||||
│ ├── documents.json # All document metadata
|
||||
│ ├── maintenance-records.json # All maintenance records
|
||||
│ └── maintenance-schedules.json # All maintenance schedules
|
||||
└── files/
|
||||
├── vehicle-images/
|
||||
│ └── {vehicleId}/
|
||||
│ └── {filename} # Actual vehicle image files
|
||||
└── documents/
|
||||
└── {documentId}/
|
||||
└── {filename} # Actual document files
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Download User Export
|
||||
|
||||
Downloads a complete archive of all user data.
|
||||
|
||||
**Endpoint:** `GET /api/user/export`
|
||||
|
||||
**Authentication:** Required (JWT)
|
||||
|
||||
**Response:**
|
||||
- Content-Type: `application/gzip`
|
||||
- Content-Disposition: `attachment; filename="motovaultpro_export_YYYY-MM-DDTHH-MM-SS.tar.gz"`
|
||||
- Streaming download
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
https://app.motovaultpro.com/api/user/export \
|
||||
--output my_data.tar.gz
|
||||
```
|
||||
|
||||
## Manifest Format
|
||||
|
||||
The manifest.json file contains metadata about the export:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2025-12-26T10:30:00.000Z",
|
||||
"applicationVersion": "1.0.0",
|
||||
"userId": "auth0|123456",
|
||||
"contents": {
|
||||
"vehicles": {
|
||||
"count": 3,
|
||||
"withImages": 2
|
||||
},
|
||||
"fuelLogs": {
|
||||
"count": 45
|
||||
},
|
||||
"documents": {
|
||||
"count": 8,
|
||||
"withFiles": 7
|
||||
},
|
||||
"maintenanceRecords": {
|
||||
"count": 12
|
||||
},
|
||||
"maintenanceSchedules": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"vehicleImages": 2,
|
||||
"documentFiles": 7,
|
||||
"totalSizeBytes": 15728640
|
||||
},
|
||||
"warnings": [
|
||||
"1 vehicle images not found"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Data Export Details
|
||||
|
||||
### Vehicles
|
||||
All vehicles owned by the user, including:
|
||||
- Basic information (make, model, year, VIN)
|
||||
- Vehicle details (engine, transmission, trim)
|
||||
- Image metadata (if available)
|
||||
- Odometer readings
|
||||
|
||||
### Fuel Logs
|
||||
All fuel log entries including:
|
||||
- Date and odometer readings
|
||||
- Fuel quantity and cost
|
||||
- Station and location information
|
||||
- Calculated MPG (if available)
|
||||
|
||||
### Documents
|
||||
Document metadata plus actual files:
|
||||
- Document type and title
|
||||
- Issue and expiration dates
|
||||
- Associated files (PDFs, images)
|
||||
- Storage metadata
|
||||
|
||||
### Maintenance Records
|
||||
All maintenance history:
|
||||
- Service category and subtypes
|
||||
- Date and odometer reading
|
||||
- Cost and shop information
|
||||
- Notes
|
||||
|
||||
### Maintenance Schedules
|
||||
All maintenance schedules:
|
||||
- Interval-based schedules
|
||||
- Last service information
|
||||
- Next due dates/mileage
|
||||
- Notification settings
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### User Scoping
|
||||
All data is strictly scoped to the authenticated user via `userId`. No data from other users is included.
|
||||
|
||||
### File Handling
|
||||
- Vehicle images: Read from `/app/data/documents/{bucket}/{key}`
|
||||
- Document files: Read from `/app/data/documents/{bucket}/{key}`
|
||||
- Missing files are logged but don't fail the export
|
||||
- Warnings are included in the manifest
|
||||
|
||||
### Temporary Storage
|
||||
- Working directory: `/tmp/user-export-work/export-{userId}-{timestamp}/`
|
||||
- Archive created in `/tmp/user-export-work/`
|
||||
- Cleanup happens automatically after stream completes
|
||||
|
||||
### Streaming
|
||||
The archive is streamed directly to the client to minimize memory usage for large exports.
|
||||
|
||||
### Error Handling
|
||||
- Missing files are logged as warnings
|
||||
- Export continues even if some files are missing
|
||||
- Cleanup happens in `finally` block and stream event handlers
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal
|
||||
- `VehiclesRepository` - Vehicle data access
|
||||
- `FuelLogsRepository` - Fuel log data access
|
||||
- `DocumentsRepository` - Document metadata access
|
||||
- `MaintenanceRepository` - Maintenance data access
|
||||
|
||||
### External
|
||||
- `tar` - TAR.GZ archive creation
|
||||
- `fs/promises` - File system operations
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests should cover:
|
||||
- Archive creation with various data sets
|
||||
- Missing file handling
|
||||
- Manifest generation
|
||||
- Cleanup on success and error
|
||||
|
||||
Integration tests should cover:
|
||||
- Complete export workflow
|
||||
- Authentication enforcement
|
||||
- Streaming download
|
||||
- File integrity
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- User authentication required
|
||||
- Data strictly scoped to authenticated user
|
||||
- No cross-user data leakage possible
|
||||
- Temporary files cleaned up after download
|
||||
- Archive contains only user's own data
|
||||
|
||||
## Performance
|
||||
|
||||
- Synchronous operation (immediate response)
|
||||
- Streaming reduces memory footprint
|
||||
- Parallel file operations where possible
|
||||
- Cleanup prevents disk space accumulation
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- Import functionality to restore exported data
|
||||
- Selective export (e.g., only vehicles)
|
||||
- Export format options (JSON only, without files)
|
||||
- Scheduled exports
|
||||
- Export history tracking
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @ai-summary User export controller
|
||||
* @ai-context Handles HTTP requests for user data export
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { UserExportService } from '../domain/user-export.service';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class UserExportController {
|
||||
private exportService: UserExportService;
|
||||
|
||||
constructor() {
|
||||
this.exportService = new UserExportService();
|
||||
}
|
||||
|
||||
async downloadExport(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
logger.info('User export requested', { userId });
|
||||
|
||||
try {
|
||||
const result = await this.exportService.exportUserData(userId);
|
||||
|
||||
logger.info('User export ready for download', {
|
||||
userId,
|
||||
filename: result.filename,
|
||||
sizeBytes: result.size,
|
||||
});
|
||||
|
||||
return reply
|
||||
.header('Content-Type', 'application/gzip')
|
||||
.header('Content-Disposition', `attachment; filename="${result.filename}"`)
|
||||
.header('Content-Length', result.size)
|
||||
.send(result.stream);
|
||||
} catch (error) {
|
||||
logger.error('User export failed', {
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
backend/src/features/user-export/api/user-export.routes.ts
Normal file
16
backend/src/features/user-export/api/user-export.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @ai-summary User export routes
|
||||
* @ai-context Route definitions for user data export
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { UserExportController } from './user-export.controller';
|
||||
|
||||
export const userExportRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const controller = new UserExportController();
|
||||
|
||||
fastify.get('/user/export', {
|
||||
preHandler: [(fastify as any).authenticate],
|
||||
handler: controller.downloadExport.bind(controller),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* @ai-summary Service for creating user data export archives
|
||||
* @ai-context Creates tar.gz archives containing all user data and files
|
||||
*/
|
||||
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as tar from 'tar';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { VehiclesRepository } from '../../vehicles/data/vehicles.repository';
|
||||
import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository';
|
||||
import { DocumentsRepository } from '../../documents/data/documents.repository';
|
||||
import { MaintenanceRepository } from '../../maintenance/data/maintenance.repository';
|
||||
import { USER_EXPORT_CONFIG, UserExportManifest, UserExportResult } from './user-export.types';
|
||||
|
||||
export class UserExportArchiveService {
|
||||
private readonly tempPath: string;
|
||||
private readonly dataPath: string;
|
||||
|
||||
constructor(
|
||||
private readonly vehiclesRepo: VehiclesRepository,
|
||||
private readonly fuelLogsRepo: FuelLogsRepository,
|
||||
private readonly documentsRepo: DocumentsRepository,
|
||||
private readonly maintenanceRepo: MaintenanceRepository
|
||||
) {
|
||||
this.tempPath = USER_EXPORT_CONFIG.tempPath;
|
||||
this.dataPath = '/app/data/documents';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complete user data export archive
|
||||
*/
|
||||
async createUserExport(userId: string): Promise<UserExportResult> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const workDir = path.join(this.tempPath, `export-${userId}-${Date.now()}`);
|
||||
|
||||
try {
|
||||
// Create working directory structure
|
||||
await fsp.mkdir(workDir, { recursive: true });
|
||||
await fsp.mkdir(path.join(workDir, 'data'), { recursive: true });
|
||||
await fsp.mkdir(path.join(workDir, 'files'), { recursive: true });
|
||||
|
||||
logger.info('Starting user data export', { userId, workDir });
|
||||
|
||||
// Gather user data
|
||||
const [vehicles, fuelLogs, documents, maintenanceRecords] = await Promise.all([
|
||||
this.vehiclesRepo.findByUserId(userId),
|
||||
this.fuelLogsRepo.findByUserId(userId),
|
||||
this.documentsRepo.listByUser(userId),
|
||||
this.maintenanceRepo.findRecordsByUserId(userId),
|
||||
]);
|
||||
|
||||
const maintenanceSchedules = await this.maintenanceRepo.findSchedulesByUserId(userId);
|
||||
|
||||
// Write JSON data files
|
||||
await Promise.all([
|
||||
fsp.writeFile(
|
||||
path.join(workDir, 'data', 'vehicles.json'),
|
||||
JSON.stringify(vehicles, null, 2)
|
||||
),
|
||||
fsp.writeFile(
|
||||
path.join(workDir, 'data', 'fuel-logs.json'),
|
||||
JSON.stringify(fuelLogs, null, 2)
|
||||
),
|
||||
fsp.writeFile(
|
||||
path.join(workDir, 'data', 'documents.json'),
|
||||
JSON.stringify(documents, null, 2)
|
||||
),
|
||||
fsp.writeFile(
|
||||
path.join(workDir, 'data', 'maintenance-records.json'),
|
||||
JSON.stringify(maintenanceRecords, null, 2)
|
||||
),
|
||||
fsp.writeFile(
|
||||
path.join(workDir, 'data', 'maintenance-schedules.json'),
|
||||
JSON.stringify(maintenanceSchedules, null, 2)
|
||||
),
|
||||
]);
|
||||
|
||||
// Copy vehicle images
|
||||
const vehicleImageStats = await this.copyVehicleImages(workDir, vehicles);
|
||||
|
||||
// Copy document files
|
||||
const documentFileStats = await this.copyDocumentFiles(workDir, documents);
|
||||
|
||||
// Create manifest
|
||||
const warnings: string[] = [];
|
||||
if (vehicleImageStats.missingCount > 0) {
|
||||
warnings.push(`${vehicleImageStats.missingCount} vehicle images not found`);
|
||||
}
|
||||
if (documentFileStats.missingCount > 0) {
|
||||
warnings.push(`${documentFileStats.missingCount} document files not found`);
|
||||
}
|
||||
|
||||
const manifest: UserExportManifest = {
|
||||
version: USER_EXPORT_CONFIG.archiveVersion,
|
||||
createdAt: new Date().toISOString(),
|
||||
applicationVersion: process.env.npm_package_version || '1.0.0',
|
||||
userId,
|
||||
contents: {
|
||||
vehicles: {
|
||||
count: vehicles.length,
|
||||
withImages: vehicles.filter(v => v.imageStorageKey).length,
|
||||
},
|
||||
fuelLogs: { count: fuelLogs.length },
|
||||
documents: {
|
||||
count: documents.length,
|
||||
withFiles: documents.filter(d => d.storageKey).length,
|
||||
},
|
||||
maintenanceRecords: { count: maintenanceRecords.length },
|
||||
maintenanceSchedules: { count: maintenanceSchedules.length },
|
||||
},
|
||||
files: {
|
||||
vehicleImages: vehicleImageStats.copiedCount,
|
||||
documentFiles: documentFileStats.copiedCount,
|
||||
totalSizeBytes: vehicleImageStats.totalSize + documentFileStats.totalSize,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
|
||||
await fsp.writeFile(
|
||||
path.join(workDir, 'manifest.json'),
|
||||
JSON.stringify(manifest, null, 2)
|
||||
);
|
||||
|
||||
// Create tar.gz archive
|
||||
const archiveFilename = `motovaultpro_export_${timestamp}.tar.gz`;
|
||||
const archivePath = path.join(this.tempPath, archiveFilename);
|
||||
|
||||
logger.info('Creating tar.gz archive', { userId, archivePath });
|
||||
await tar.create(
|
||||
{
|
||||
gzip: true,
|
||||
file: archivePath,
|
||||
cwd: workDir,
|
||||
},
|
||||
['.']
|
||||
);
|
||||
|
||||
const archiveStats = await fsp.stat(archivePath);
|
||||
|
||||
logger.info('User export archive created successfully', {
|
||||
userId,
|
||||
archivePath,
|
||||
sizeBytes: archiveStats.size,
|
||||
});
|
||||
|
||||
// Create read stream for download
|
||||
const stream = fs.createReadStream(archivePath);
|
||||
|
||||
// Schedule cleanup after stream ends
|
||||
stream.on('end', async () => {
|
||||
await this.cleanupWorkDir(workDir);
|
||||
await this.cleanupArchive(archivePath);
|
||||
});
|
||||
|
||||
stream.on('error', async () => {
|
||||
await this.cleanupWorkDir(workDir);
|
||||
await this.cleanupArchive(archivePath);
|
||||
});
|
||||
|
||||
return {
|
||||
stream,
|
||||
filename: archiveFilename,
|
||||
size: archiveStats.size,
|
||||
};
|
||||
} catch (error) {
|
||||
// Cleanup on error
|
||||
await this.cleanupWorkDir(workDir);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies vehicle images to the export archive
|
||||
*/
|
||||
private async copyVehicleImages(
|
||||
workDir: string,
|
||||
vehicles: any[]
|
||||
): Promise<{ copiedCount: number; missingCount: number; totalSize: number }> {
|
||||
let copiedCount = 0;
|
||||
let missingCount = 0;
|
||||
let totalSize = 0;
|
||||
|
||||
for (const vehicle of vehicles) {
|
||||
if (!vehicle.imageStorageBucket || !vehicle.imageStorageKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePath = path.join(
|
||||
this.dataPath,
|
||||
vehicle.imageStorageBucket,
|
||||
vehicle.imageStorageKey
|
||||
);
|
||||
|
||||
const destDir = path.join(workDir, 'files', 'vehicle-images', vehicle.id);
|
||||
const destPath = path.join(destDir, vehicle.imageFileName || 'image.jpg');
|
||||
|
||||
try {
|
||||
await fsp.mkdir(destDir, { recursive: true });
|
||||
await fsp.copyFile(sourcePath, destPath);
|
||||
|
||||
const stats = await fsp.stat(destPath);
|
||||
totalSize += stats.size;
|
||||
copiedCount++;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to copy vehicle image', {
|
||||
vehicleId: vehicle.id,
|
||||
sourcePath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
missingCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { copiedCount, missingCount, totalSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies document files to the export archive
|
||||
*/
|
||||
private async copyDocumentFiles(
|
||||
workDir: string,
|
||||
documents: any[]
|
||||
): Promise<{ copiedCount: number; missingCount: number; totalSize: number }> {
|
||||
let copiedCount = 0;
|
||||
let missingCount = 0;
|
||||
let totalSize = 0;
|
||||
|
||||
for (const doc of documents) {
|
||||
if (!doc.storageBucket || !doc.storageKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePath = path.join(this.dataPath, doc.storageBucket, doc.storageKey);
|
||||
|
||||
const destDir = path.join(workDir, 'files', 'documents', doc.id);
|
||||
const destPath = path.join(destDir, doc.fileName || 'file.pdf');
|
||||
|
||||
try {
|
||||
await fsp.mkdir(destDir, { recursive: true });
|
||||
await fsp.copyFile(sourcePath, destPath);
|
||||
|
||||
const stats = await fsp.stat(destPath);
|
||||
totalSize += stats.size;
|
||||
copiedCount++;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to copy document file', {
|
||||
documentId: doc.id,
|
||||
sourcePath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
missingCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { copiedCount, missingCount, totalSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up a working directory
|
||||
*/
|
||||
private async cleanupWorkDir(workDir: string): Promise<void> {
|
||||
try {
|
||||
await fsp.rm(workDir, { recursive: true, force: true });
|
||||
logger.debug('Cleaned up work directory', { workDir });
|
||||
} catch (error) {
|
||||
logger.warn('Failed to cleanup work directory', {
|
||||
workDir,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up an archive file
|
||||
*/
|
||||
private async cleanupArchive(archivePath: string): Promise<void> {
|
||||
try {
|
||||
await fsp.unlink(archivePath);
|
||||
logger.debug('Cleaned up archive file', { archivePath });
|
||||
} catch (error) {
|
||||
logger.warn('Failed to cleanup archive file', {
|
||||
archivePath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @ai-summary User export service
|
||||
* @ai-context Coordinates user data export operations
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import pool from '../../../core/config/database';
|
||||
import { VehiclesRepository } from '../../vehicles/data/vehicles.repository';
|
||||
import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository';
|
||||
import { DocumentsRepository } from '../../documents/data/documents.repository';
|
||||
import { MaintenanceRepository } from '../../maintenance/data/maintenance.repository';
|
||||
import { UserExportArchiveService } from './user-export-archive.service';
|
||||
import { UserExportResult } from './user-export.types';
|
||||
|
||||
export class UserExportService {
|
||||
private archiveService: UserExportArchiveService;
|
||||
|
||||
constructor(db: Pool = pool) {
|
||||
this.archiveService = new UserExportArchiveService(
|
||||
new VehiclesRepository(db),
|
||||
new FuelLogsRepository(db),
|
||||
new DocumentsRepository(db),
|
||||
new MaintenanceRepository(db)
|
||||
);
|
||||
}
|
||||
|
||||
async exportUserData(userId: string): Promise<UserExportResult> {
|
||||
return this.archiveService.createUserExport(userId);
|
||||
}
|
||||
}
|
||||
37
backend/src/features/user-export/domain/user-export.types.ts
Normal file
37
backend/src/features/user-export/domain/user-export.types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @ai-summary User export types and constants
|
||||
* @ai-context Types for user data export feature
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
||||
export interface UserExportManifest {
|
||||
version: string;
|
||||
createdAt: string;
|
||||
applicationVersion: string;
|
||||
userId: string;
|
||||
contents: {
|
||||
vehicles: { count: number; withImages: number };
|
||||
fuelLogs: { count: number };
|
||||
documents: { count: number; withFiles: number };
|
||||
maintenanceRecords: { count: number };
|
||||
maintenanceSchedules: { count: number };
|
||||
};
|
||||
files: {
|
||||
vehicleImages: number;
|
||||
documentFiles: number;
|
||||
totalSizeBytes: number;
|
||||
};
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface UserExportResult {
|
||||
stream: fs.ReadStream;
|
||||
filename: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const USER_EXPORT_CONFIG = {
|
||||
tempPath: '/tmp/user-export-work',
|
||||
archiveVersion: '1.0.0',
|
||||
} as const;
|
||||
6
backend/src/features/user-export/index.ts
Normal file
6
backend/src/features/user-export/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @ai-summary User export feature public API
|
||||
* @ai-context Exports routes for registration in app.ts
|
||||
*/
|
||||
|
||||
export { userExportRoutes } from './api/user-export.routes';
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for user export service
|
||||
* @ai-context Tests the export service with mocked repositories
|
||||
*/
|
||||
|
||||
import { UserExportService } from '../../domain/user-export.service';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
describe('UserExportService', () => {
|
||||
let service: UserExportService;
|
||||
let mockPool: Pool;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock pool
|
||||
mockPool = {
|
||||
query: jest.fn(),
|
||||
} as any;
|
||||
|
||||
service = new UserExportService(mockPool);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('exportUserData', () => {
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
expect(service.exportUserData).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept a userId parameter', async () => {
|
||||
const userId = 'test-user-id';
|
||||
|
||||
// This will fail without actual data setup, but validates the interface
|
||||
await expect(async () => {
|
||||
await service.exportUserData(userId);
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,15 +22,16 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
|
||||
- Make no assumptions.
|
||||
- Ask clarifying questions.
|
||||
- Ultrathink
|
||||
- You will be auditing the Dark vs Light theme implementation
|
||||
- You will be implementing a user security improvement.
|
||||
|
||||
*** 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.
|
||||
- You need to audit the Dark vs Light theme.
|
||||
- The colors were not all changed so some of the dark theme
|
||||
- The dark versus light theme does not save between logins.
|
||||
- Think hard about the color choices and if any better colors are available form the MVP-COLOR-SCHEME.md
|
||||
- There is a "Manage" button in the settings screen under "Security & Privacy" that is not implemented.
|
||||
- Right now authentication is done via Auth0 with a robust onboarding workflow.
|
||||
- When implementing the ability to manage your password in the app, we also need to add the ability for passkey authentication with Auth0
|
||||
- We cannot break the onboarding workflow where users are not allowed to login until they are confirmed
|
||||
- Auth0 passkey documentation is located here https://auth0.com/docs/native-passkeys-api.md
|
||||
|
||||
*** CHANGES TO IMPLEMENT ***
|
||||
- Research this code base and ask iterative questions to compile a complete plan.
|
||||
@@ -93,26 +94,21 @@ Running after script...
|
||||
|
||||
|
||||
*** ROLE ***
|
||||
- You are a senior DBA with expert knowledge in Postgres SQL.
|
||||
- You are a expert frontend developer specializing in advanced techniques using Tailwind and React frameworks.
|
||||
|
||||
*** ACTION ***
|
||||
- Make no assumptions.
|
||||
- Ask clarifying questions.
|
||||
- Ultrathink
|
||||
- You will be implementing an ETL process that takes a export of the NHTSA vPIC database in Postgres and transforming it for use in this application.
|
||||
- You will be making changes to the color theme of this application.
|
||||
|
||||
*** 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 an existing database import process in this directory. This process works and should not be changed.
|
||||
- The source database from the NHTSA vPIC dataset is located in the @vpic-source directory
|
||||
- Deep research needs to be conducted on how to execute this ETL process.
|
||||
- The source database is designed for VIN decoding only.
|
||||
- Example VIN: 2025 Honda Civic Hybrid - 2HGFE4F88SH315466
|
||||
- Example VIN: 2023 GMC Sierra 1500 AT4x - 3GTUUFEL6PG140748
|
||||
- Example VIN: 2017 Chevrolet Corvette Z06 - 1G1YU3D64H5602799
|
||||
- Currently the log is washed out when using the light theme. See image.
|
||||
- We need to put a background shape or static color to make the logo visible
|
||||
|
||||
*** CHANGES TO IMPLEMENT ***
|
||||
- Research this code base and ask iterative questions to compile a complete plan.
|
||||
- generate a project plan
|
||||
- break into bite-sized tasks and milestones
|
||||
- Give options for how to put a background behind the logo
|
||||
- Research if it's possible to put a rounded rectangle behind it similar to the "Settings" button. The rounded ends need to be smaller then the settings button if possible.
|
||||
@@ -16,6 +16,7 @@ import { UnitsProvider } from './core/units/UnitsContext';
|
||||
const VehiclesPage = lazy(() => import('./features/vehicles/pages/VehiclesPage').then(m => ({ default: m.VehiclesPage })));
|
||||
const VehicleDetailPage = lazy(() => import('./features/vehicles/pages/VehicleDetailPage').then(m => ({ default: m.VehicleDetailPage })));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
|
||||
const SecuritySettingsPage = lazy(() => import('./pages/SecuritySettingsPage').then(m => ({ default: m.SecuritySettingsPage })));
|
||||
const FuelLogsPage = lazy(() => import('./features/fuel-logs/pages/FuelLogsPage').then(m => ({ default: m.FuelLogsPage })));
|
||||
const DocumentsPage = lazy(() => import('./features/documents/pages/DocumentsPage').then(m => ({ default: m.DocumentsPage })));
|
||||
const DocumentDetailPage = lazy(() => import('./features/documents/pages/DocumentDetailPage').then(m => ({ default: m.DocumentDetailPage })));
|
||||
@@ -71,6 +72,7 @@ import { VehicleForm } from './features/vehicles/components/VehicleForm';
|
||||
import { useOptimisticVehicles } from './features/vehicles/hooks/useOptimisticVehicles';
|
||||
import { CreateVehicleRequest } from './features/vehicles/types/vehicles.types';
|
||||
import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen';
|
||||
import { SecurityMobileScreen } from './features/settings/mobile/SecurityMobileScreen';
|
||||
import { useNavigationStore, useUserStore } from './core/store';
|
||||
import { useDataSync } from './core/hooks/useDataSync';
|
||||
import { MobileDebugPanel } from './core/debug/MobileDebugPanel';
|
||||
@@ -641,6 +643,19 @@ function App() {
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "Security" && (
|
||||
<motion.div
|
||||
key="security"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="Security">
|
||||
<SecurityMobileScreen />
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "Documents" && (
|
||||
<motion.div
|
||||
key="documents"
|
||||
@@ -883,6 +898,7 @@ function App() {
|
||||
<Route path="/garage/maintenance" element={<MaintenancePage />} />
|
||||
<Route path="/garage/stations" element={<StationsPage />} />
|
||||
<Route path="/garage/settings" element={<SettingsPage />} />
|
||||
<Route path="/garage/settings/security" element={<SecuritySettingsPage />} />
|
||||
<Route path="/garage/settings/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/garage/settings/admin/catalog" element={<AdminCatalogPage />} />
|
||||
<Route path="/garage/settings/admin/community-stations" element={<AdminCommunityStationsPage />} />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import React from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material';
|
||||
import { Container, Paper, Typography, Box, IconButton, Avatar, useTheme } from '@mui/material';
|
||||
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
||||
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
|
||||
@@ -28,6 +28,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
const { user, logout } = useAuth0();
|
||||
const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore();
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
|
||||
// Sync theme preference with backend
|
||||
useThemeSync();
|
||||
@@ -69,11 +70,13 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
{/* App header */}
|
||||
<div className="px-5 pt-5 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="bg-primary-500 dark:bg-transparent rounded px-2 py-1 inline-flex items-center">
|
||||
<img
|
||||
src="/images/logos/motovaultpro-title-slogan.png"
|
||||
alt="MotoVaultPro"
|
||||
className="h-6 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<div className="text-xs text-slate-500 dark:text-titanio">v1.0</div>
|
||||
@@ -124,12 +127,23 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
borderColor: 'divider',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: theme.palette.mode === 'light' ? 'primary.main' : 'transparent',
|
||||
borderRadius: 1,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/images/logos/motovaultpro-title-slogan.png"
|
||||
alt="MotoVaultPro"
|
||||
style={{ height: 24, width: 'auto', maxWidth: 180 }}
|
||||
/>
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={toggleSidebar}
|
||||
size="small"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { safeStorage } from '../utils/safe-storage';
|
||||
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup';
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup';
|
||||
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
||||
|
||||
interface NavigationHistory {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { FormControl, Select, MenuItem } from '@mui/material';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { OnboardingPreferences } from '../types/onboarding.types';
|
||||
|
||||
@@ -22,7 +23,7 @@ interface PreferencesStepProps {
|
||||
|
||||
export const PreferencesStep: React.FC<PreferencesStepProps> = ({ onNext, loading }) => {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
@@ -88,17 +89,38 @@ export const PreferencesStep: React.FC<PreferencesStepProps> = ({ onNext, loadin
|
||||
<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' }}
|
||||
<FormControl fullWidth>
|
||||
<Controller
|
||||
name="currencyCode"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
displayEmpty
|
||||
sx={{
|
||||
minHeight: '44px',
|
||||
fontSize: '16px',
|
||||
borderRadius: '8px',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#d1d5db',
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#9ca3af',
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#7A212A',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<MenuItem value="USD">USD - US Dollar</MenuItem>
|
||||
<MenuItem value="EUR">EUR - Euro</MenuItem>
|
||||
<MenuItem value="GBP">GBP - British Pound</MenuItem>
|
||||
<MenuItem value="CAD">CAD - Canadian Dollar</MenuItem>
|
||||
<MenuItem value="AUD">AUD - Australian Dollar</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
{errors.currencyCode && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.currencyCode.message}</p>
|
||||
)}
|
||||
@@ -109,23 +131,44 @@ export const PreferencesStep: React.FC<PreferencesStepProps> = ({ onNext, loadin
|
||||
<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' }}
|
||||
<FormControl fullWidth>
|
||||
<Controller
|
||||
name="timeZone"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
displayEmpty
|
||||
sx={{
|
||||
minHeight: '44px',
|
||||
fontSize: '16px',
|
||||
borderRadius: '8px',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#d1d5db',
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#9ca3af',
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#7A212A',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<MenuItem value="America/New_York">Eastern Time (ET)</MenuItem>
|
||||
<MenuItem value="America/Chicago">Central Time (CT)</MenuItem>
|
||||
<MenuItem value="America/Denver">Mountain Time (MT)</MenuItem>
|
||||
<MenuItem value="America/Los_Angeles">Pacific Time (PT)</MenuItem>
|
||||
<MenuItem value="America/Phoenix">Arizona Time (MST)</MenuItem>
|
||||
<MenuItem value="America/Anchorage">Alaska Time (AKT)</MenuItem>
|
||||
<MenuItem value="Pacific/Honolulu">Hawaii Time (HST)</MenuItem>
|
||||
<MenuItem value="Europe/London">London (GMT/BST)</MenuItem>
|
||||
<MenuItem value="Europe/Paris">Paris (CET/CEST)</MenuItem>
|
||||
<MenuItem value="Asia/Tokyo">Tokyo (JST)</MenuItem>
|
||||
<MenuItem value="Australia/Sydney">Sydney (AEST/AEDT)</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
{errors.timeZone && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.timeZone.message}</p>
|
||||
)}
|
||||
|
||||
16
frontend/src/features/settings/api/export.api.ts
Normal file
16
frontend/src/features/settings/api/export.api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @ai-summary API client for user data export
|
||||
* @ai-context Downloads user export as tar.gz blob
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
|
||||
export const exportApi = {
|
||||
downloadUserExport: async (): Promise<Blob> => {
|
||||
const response = await apiClient.get('/user/export', {
|
||||
responseType: 'blob',
|
||||
timeout: 120000, // 2 minute timeout for large exports
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
11
frontend/src/features/settings/api/security.api.ts
Normal file
11
frontend/src/features/settings/api/security.api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @ai-summary API client for security endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { SecurityStatus, PasswordResetResponse } from '../types/security.types';
|
||||
|
||||
export const securityApi = {
|
||||
getSecurityStatus: () => apiClient.get<SecurityStatus>('/auth/security-status'),
|
||||
requestPasswordReset: () => apiClient.post<PasswordResetResponse>('/auth/request-password-reset'),
|
||||
};
|
||||
38
frontend/src/features/settings/hooks/useExportUserData.ts
Normal file
38
frontend/src/features/settings/hooks/useExportUserData.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @ai-summary React Query hook for user data export
|
||||
* @ai-context Downloads tar.gz archive with all user data
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { exportApi } from '../api/export.api';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useExportUserData = () => {
|
||||
return useMutation({
|
||||
mutationFn: () => exportApi.downloadUserExport(),
|
||||
onSuccess: (blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
link.download = `motovaultpro_export_${timestamp}.tar.gz`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success('Data exported successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to export data');
|
||||
},
|
||||
});
|
||||
};
|
||||
57
frontend/src/features/settings/hooks/useSecurity.ts
Normal file
57
frontend/src/features/settings/hooks/useSecurity.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @ai-summary React hooks for security settings management
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { securityApi } from '../api/security.api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useSecurityStatus = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['security-status'],
|
||||
queryFn: async () => {
|
||||
const response = await securityApi.getSecurityStatus();
|
||||
return response.data;
|
||||
},
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes cache time
|
||||
retry: (failureCount, error: ApiError) => {
|
||||
if (error?.response?.data?.error === 'Unauthorized' && failureCount < 3) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRequestPasswordReset = () => {
|
||||
return useMutation({
|
||||
mutationFn: () => securityApi.requestPasswordReset(),
|
||||
onSuccess: (response) => {
|
||||
toast.success(response.data.message);
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to send password reset email'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useProfile, useUpdateProfile } from '../hooks/useProfile';
|
||||
import { useExportUserData } from '../hooks/useExportUserData';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import { useNavigationStore } from '../../../core/store';
|
||||
import { DeleteAccountModal } from './DeleteAccountModal';
|
||||
@@ -32,7 +33,7 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
<button
|
||||
onClick={onChange}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? 'bg-blue-600' : 'bg-gray-200'
|
||||
enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -78,6 +79,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
const { settings, updateSetting, isLoading, error } = useSettings();
|
||||
const { data: profile, isLoading: profileLoading } = useProfile();
|
||||
const updateProfileMutation = useUpdateProfile();
|
||||
const exportMutation = useExportUserData();
|
||||
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
||||
const [showDataExport, setShowDataExport] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -102,9 +104,8 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleExportData = () => {
|
||||
// TODO: Implement data export functionality
|
||||
console.log('Exporting user data...');
|
||||
setShowDataExport(false);
|
||||
exportMutation.mutate();
|
||||
};
|
||||
|
||||
|
||||
@@ -149,7 +150,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading settings...</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
@@ -167,7 +168,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<p className="text-sm text-slate-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg dark:bg-primary-600 dark:hover:bg-primary-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -198,7 +199,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
{!isEditingProfile && !profileLoading && (
|
||||
<button
|
||||
onClick={handleEditProfile}
|
||||
className="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors"
|
||||
className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
Edit
|
||||
@@ -208,7 +209,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
|
||||
{profileLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
) : isEditingProfile ? (
|
||||
<div className="space-y-4">
|
||||
@@ -235,7 +236,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
value={editedDisplayName}
|
||||
onChange={(e) => setEditedDisplayName(e.target.value)}
|
||||
placeholder="Enter your display name"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:border-silverstone dark:text-avus"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -249,7 +250,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
value={editedNotificationEmail}
|
||||
onChange={(e) => setEditedNotificationEmail(e.target.value)}
|
||||
placeholder="Leave blank to use your primary email"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:border-silverstone dark:text-avus"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Optional: Use a different email for notifications</p>
|
||||
@@ -267,7 +268,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="flex-1 py-2.5 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
className="flex-1 py-2.5 px-4 bg-primary-500 text-white rounded-lg font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 flex items-center justify-center dark:bg-primary-600 dark:hover:bg-primary-700"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{updateProfileMutation.isPending ? (
|
||||
@@ -288,7 +289,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-500 flex items-center justify-center text-white font-semibold">
|
||||
{profile?.displayName?.charAt(0) || user?.name?.charAt(0) || user?.email?.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
@@ -372,7 +373,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateSetting('unitSystem', settings.unitSystem === 'imperial' ? 'metric' : 'imperial')}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors"
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
{settings.unitSystem === 'imperial' ? 'Switch to Metric' : 'Switch to Imperial'}
|
||||
</button>
|
||||
@@ -388,7 +389,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowDataExport(true)}
|
||||
className="w-full text-left p-3 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors"
|
||||
className="w-full text-left p-3 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
>
|
||||
Export My Data
|
||||
</button>
|
||||
@@ -399,43 +400,60 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Security & Privacy Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white mb-4">Security & Privacy</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => navigateToScreen('Security')}
|
||||
className="w-full text-left p-4 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors active:bg-primary-200 dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Security Settings</div>
|
||||
<div className="text-sm text-primary-500 mt-1 dark:text-primary-400">Password, passkeys, verification</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Admin Console Section */}
|
||||
{!adminLoading && isAdmin && (
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-blue-600 mb-4">Admin Console</h2>
|
||||
<h2 className="text-lg font-semibold text-primary-500 mb-4">Admin Console</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => navigateToScreen('AdminUsers')}
|
||||
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
|
||||
className="w-full text-left p-4 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors active:bg-primary-200 dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">User Management</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage admin users and permissions</div>
|
||||
<div className="text-sm text-primary-500 mt-1 dark:text-primary-400">Manage admin users and permissions</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToScreen('AdminCatalog')}
|
||||
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
|
||||
className="w-full text-left p-4 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors active:bg-primary-200 dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Vehicle Catalog</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage makes, models, and engines</div>
|
||||
<div className="text-sm text-primary-500 mt-1 dark:text-primary-400">Manage makes, models, and engines</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToScreen('AdminEmailTemplates')}
|
||||
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
|
||||
className="w-full text-left p-4 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors active:bg-primary-200 dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Email Templates</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage notification email templates</div>
|
||||
<div className="text-sm text-primary-500 mt-1 dark:text-primary-400">Manage notification email templates</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToScreen('AdminBackup')}
|
||||
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
|
||||
className="w-full text-left p-4 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors active:bg-primary-200 dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Backup & Restore</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Create backups and restore data</div>
|
||||
<div className="text-sm text-primary-500 mt-1 dark:text-primary-400">Create backups and restore data</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -481,9 +499,10 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportData}
|
||||
className="flex-1 py-2 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
disabled={exportMutation.isPending}
|
||||
className="flex-1 py-2 px-4 bg-primary-500 text-white rounded-lg font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 dark:bg-primary-600 dark:hover:bg-primary-700"
|
||||
>
|
||||
Export
|
||||
{exportMutation.isPending ? 'Exporting...' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
202
frontend/src/features/settings/mobile/SecurityMobileScreen.tsx
Normal file
202
frontend/src/features/settings/mobile/SecurityMobileScreen.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @ai-summary Security settings screen for mobile application
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useNavigationStore } from '../../../core/store';
|
||||
import { useSecurityStatus, useRequestPasswordReset } from '../hooks/useSecurity';
|
||||
|
||||
export const SecurityMobileScreen: React.FC = () => {
|
||||
const { goBack } = useNavigationStore();
|
||||
const { data: securityStatus, isLoading, error } = useSecurityStatus();
|
||||
const passwordResetMutation = useRequestPasswordReset();
|
||||
|
||||
const handlePasswordReset = () => {
|
||||
passwordResetMutation.mutate();
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
goBack();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-6">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="mr-4 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
<svg className="w-6 h-6 text-slate-600 dark:text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white">Security</h1>
|
||||
</div>
|
||||
|
||||
<GlassCard padding="md">
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-6">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="mr-4 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
<svg className="w-6 h-6 text-slate-600 dark:text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white">Security</h1>
|
||||
</div>
|
||||
|
||||
<GlassCard padding="md">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-600 mb-4">Failed to load security settings</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg dark:bg-primary-600 dark:hover:bg-primary-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-6">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="mr-4 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
<svg className="w-6 h-6 text-slate-600 dark:text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white">Security</h1>
|
||||
</div>
|
||||
|
||||
{/* Email Verification Status */}
|
||||
<GlassCard padding="md">
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white mb-4">Account Verification</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Email Address</p>
|
||||
<p className="text-sm text-slate-800 dark:text-white">{securityStatus?.email || 'Not available'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Email Verification</p>
|
||||
<p className="text-sm text-slate-800 dark:text-white">
|
||||
{securityStatus?.emailVerified ? 'Verified' : 'Not verified'}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
securityStatus?.emailVerified
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
{securityStatus?.emailVerified ? 'Verified' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Password Management */}
|
||||
<GlassCard padding="md">
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white mb-4">Password</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Click below to receive an email with a link to reset your password.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handlePasswordReset}
|
||||
disabled={passwordResetMutation.isPending}
|
||||
className="w-full py-3 px-4 bg-primary-500 text-white rounded-lg font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 flex items-center justify-center dark:bg-primary-600 dark:hover:bg-primary-700"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{passwordResetMutation.isPending ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
) : (
|
||||
'Reset Password'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{passwordResetMutation.isSuccess && (
|
||||
<div className="p-3 bg-green-50 text-green-800 rounded-lg text-sm dark:bg-green-900/20 dark:text-green-300">
|
||||
Password reset email sent! Please check your inbox.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Passkeys Information */}
|
||||
<GlassCard padding="md">
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white mb-4">Passkeys</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Passkey Authentication</p>
|
||||
<p className="text-sm text-slate-800 dark:text-white">
|
||||
{securityStatus?.passkeysEnabled ? 'Available' : 'Not available'}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
securityStatus?.passkeysEnabled
|
||||
? 'bg-primary-100 text-primary-800 dark:bg-primary-900/30 dark:text-primary-300'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{securityStatus?.passkeysEnabled ? 'Available' : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-50 text-blue-800 rounded-lg text-sm dark:bg-blue-900/20 dark:text-blue-300">
|
||||
<p className="font-medium mb-1">About Passkeys</p>
|
||||
<p>
|
||||
Passkeys are a secure, passwordless way to sign in using your device's biometric authentication (fingerprint, face recognition) or PIN.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
You can register a passkey during the sign-in process. When you see the option to "Create a passkey," follow the prompts to set up passwordless authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityMobileScreen;
|
||||
15
frontend/src/features/settings/types/security.types.ts
Normal file
15
frontend/src/features/settings/types/security.types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @ai-summary Security types for settings feature
|
||||
*/
|
||||
|
||||
export interface SecurityStatus {
|
||||
emailVerified: boolean;
|
||||
email: string;
|
||||
passkeysEnabled: boolean;
|
||||
passwordLastChanged: string | null;
|
||||
}
|
||||
|
||||
export interface PasswordResetResponse {
|
||||
message: string;
|
||||
success: boolean;
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export const Premium93TabContent: React.FC<Premium93TabContentProps> = ({
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundColor: 'background.default',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
@@ -177,7 +177,7 @@ export const Premium93TabContent: React.FC<Premium93TabContentProps> = ({
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundColor: 'background.default',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -338,7 +338,7 @@ export const StationMap: React.FC<StationMapProps> = ({
|
||||
width: '100%',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#e0e0e0'
|
||||
backgroundColor: 'grey.300'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -136,7 +136,7 @@ export const SubmitFor93Dialog: React.FC<SubmitFor93DialogProps> = ({
|
||||
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||
{/* Station name for context */}
|
||||
<Box sx={{ backgroundColor: '#f5f5f5', p: 1.5, borderRadius: 1 }}>
|
||||
<Box sx={{ backgroundColor: 'action.hover', p: 1.5, borderRadius: 1 }}>
|
||||
<strong>{station.name}</strong>
|
||||
<Box sx={{ fontSize: '0.875rem', color: 'text.secondary', mt: 0.5 }}>
|
||||
{station.address}
|
||||
|
||||
@@ -86,7 +86,7 @@ export const VehicleImage: React.FC<VehicleImageProps> = ({
|
||||
|
||||
if (vehicle.imageUrl && !imgError && (blobUrl || isLoading)) {
|
||||
return (
|
||||
<Box sx={{ height, borderRadius, overflow: 'hidden', mb: 2, bgcolor: isLoading ? '#F2EAEA' : undefined }}>
|
||||
<Box sx={{ height, borderRadius, overflow: 'hidden', mb: 2, bgcolor: isLoading ? 'grey.100' : undefined }}>
|
||||
{blobUrl && (
|
||||
<img
|
||||
src={blobUrl}
|
||||
@@ -105,7 +105,7 @@ export const VehicleImage: React.FC<VehicleImageProps> = ({
|
||||
<Box sx={{
|
||||
height,
|
||||
borderRadius,
|
||||
bgcolor: '#F2EAEA',
|
||||
bgcolor: 'grey.100',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -126,7 +126,7 @@ export const VehicleImage: React.FC<VehicleImageProps> = ({
|
||||
<Box
|
||||
sx={{
|
||||
height,
|
||||
bgcolor: vehicle.color || '#F2EAEA',
|
||||
bgcolor: vehicle.color || 'grey.100',
|
||||
borderRadius,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
223
frontend/src/pages/SecuritySettingsPage.tsx
Normal file
223
frontend/src/pages/SecuritySettingsPage.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @ai-summary Security settings page for desktop application
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSecurityStatus, useRequestPasswordReset } from '../features/settings/hooks/useSecurity';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Button as MuiButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
|
||||
import FingerprintIcon from '@mui/icons-material/Fingerprint';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import { Card } from '../shared-minimal/components/Card';
|
||||
|
||||
export const SecuritySettingsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { data: securityStatus, isLoading, error } = useSecurityStatus();
|
||||
const passwordResetMutation = useRequestPasswordReset();
|
||||
|
||||
const handlePasswordReset = () => {
|
||||
passwordResetMutation.mutate();
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/garage/settings');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 4 }}>
|
||||
<MuiButton
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={handleBack}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Back
|
||||
</MuiButton>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
Security Settings
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 4 }}>
|
||||
<MuiButton
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={handleBack}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Back
|
||||
</MuiButton>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
Security Settings
|
||||
</Typography>
|
||||
</Box>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Failed to load security settings. Please try again.
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 4 }}>
|
||||
<MuiButton
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={handleBack}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Back
|
||||
</MuiButton>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
Security Settings
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Email Verification Status */}
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Account Verification
|
||||
</Typography>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<EmailIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Email Address"
|
||||
secondary={securityStatus?.email || 'Not available'}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<VerifiedUserIcon color={securityStatus?.emailVerified ? 'success' : 'warning'} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Email Verification"
|
||||
secondary={
|
||||
securityStatus?.emailVerified
|
||||
? 'Your email address has been verified'
|
||||
: 'Your email address is not verified'
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
label={securityStatus?.emailVerified ? 'Verified' : 'Not Verified'}
|
||||
color={securityStatus?.emailVerified ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
{/* Password Management */}
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Password
|
||||
</Typography>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<LockIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Change Password"
|
||||
secondary="Receive an email with a link to reset your password"
|
||||
/>
|
||||
<MuiButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handlePasswordReset}
|
||||
disabled={passwordResetMutation.isPending}
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{passwordResetMutation.isPending ? (
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
) : (
|
||||
'Reset Password'
|
||||
)}
|
||||
</MuiButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
{passwordResetMutation.isSuccess && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
Password reset email sent! Please check your inbox.
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Passkeys Information */}
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Passkeys
|
||||
</Typography>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<FingerprintIcon color={securityStatus?.passkeysEnabled ? 'primary' : 'disabled'} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Passkey Authentication"
|
||||
secondary={
|
||||
securityStatus?.passkeysEnabled
|
||||
? 'Passkeys are available for your account'
|
||||
: 'Passkeys are not enabled'
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
label={securityStatus?.passkeysEnabled ? 'Available' : 'Not Available'}
|
||||
color={securityStatus?.passkeysEnabled ? 'primary' : 'default'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>About Passkeys:</strong> Passkeys are a secure, passwordless way to sign in using your device's biometric authentication (fingerprint, face recognition) or PIN.
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
You can register a passkey during the sign-in process. When you see the option to "Create a passkey," follow the prompts to set up passwordless authentication.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ 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 { useExportUserData } from '../features/settings/hooks/useExportUserData';
|
||||
import { useTheme } from '../shared-minimal/theme/ThemeContext';
|
||||
import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog';
|
||||
import { PendingDeletionBanner } from '../features/settings/components/PendingDeletionBanner';
|
||||
@@ -56,6 +57,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const [editedDisplayName, setEditedDisplayName] = useState('');
|
||||
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const exportMutation = useExportUserData();
|
||||
|
||||
// Initialize edit form when profile loads or edit mode starts
|
||||
React.useEffect(() => {
|
||||
@@ -120,10 +122,17 @@ export const SettingsPage: React.FC = () => {
|
||||
</Typography>
|
||||
{!isEditingProfile && !profileLoading && (
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={handleEditProfile}
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</MuiButton>
|
||||
@@ -246,7 +255,18 @@ export const SettingsPage: React.FC = () => {
|
||||
secondary="Password, two-factor authentication"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton variant="outlined" size="small">
|
||||
<MuiButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => navigate('/garage/settings/security')}
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
@@ -366,21 +386,20 @@ export const SettingsPage: React.FC = () => {
|
||||
secondary="Download your vehicle and fuel log data"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton variant="outlined" size="small">
|
||||
Export
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Clear Cache"
|
||||
secondary="Remove cached data to free up space"
|
||||
sx={{ pl: 7 }}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton variant="outlined" size="small" color="warning">
|
||||
Clear
|
||||
<MuiButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={exportMutation.isPending}
|
||||
onClick={() => exportMutation.mutate()}
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{exportMutation.isPending ? 'Exporting...' : 'Export'}
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
@@ -405,9 +424,16 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => navigate('/garage/settings/admin/users')}
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</MuiButton>
|
||||
@@ -422,9 +448,16 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => navigate('/garage/settings/admin/catalog')}
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</MuiButton>
|
||||
@@ -439,9 +472,16 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => navigate('/garage/settings/admin/email-templates')}
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</MuiButton>
|
||||
@@ -456,9 +496,16 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => navigate('/garage/settings/admin/backup')}
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</MuiButton>
|
||||
@@ -470,16 +517,22 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
{/* Account Actions */}
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, color: 'error.main' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, color: 'primary.main' }}>
|
||||
Account Actions
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<MuiButton
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleLogout}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
sx={{
|
||||
borderRadius: '999px',
|
||||
backgroundColor: 'grey.600',
|
||||
color: 'common.white',
|
||||
'&:hover': {
|
||||
backgroundColor: 'grey.700'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
</MuiButton>
|
||||
|
||||
@@ -23,9 +23,9 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
const baseStyles = 'font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
primary: 'bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500 dark:bg-primary-600 dark:hover:bg-primary-700',
|
||||
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 dark:bg-red-700 dark:hover:bg-red-800',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
|
||||
Reference in New Issue
Block a user