feat: onboarding pre-work
This commit is contained in:
10
Makefile
10
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help setup start stop clean logs shell-backend shell-frontend migrate rebuild traefik-dashboard traefik-logs service-discovery network-inspect health-check-all mobile-setup db-shell-app install type-check lint build-local
|
.PHONY: help setup start stop clean logs shell-backend shell-frontend migrate create-admin rebuild traefik-dashboard traefik-logs service-discovery network-inspect health-check-all mobile-setup db-shell-app install type-check lint build-local
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "MotoVaultPro - Simplified 5-Container Architecture"
|
@echo "MotoVaultPro - Simplified 5-Container Architecture"
|
||||||
@@ -14,6 +14,7 @@ help:
|
|||||||
@echo " make shell-backend - Open shell in backend container"
|
@echo " make shell-backend - Open shell in backend container"
|
||||||
@echo " make shell-frontend - Open shell in frontend container"
|
@echo " make shell-frontend - Open shell in frontend container"
|
||||||
@echo " make migrate - Run database migrations"
|
@echo " make migrate - Run database migrations"
|
||||||
|
@echo " make create-admin - Create initial admin user (fresh deployments only)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "K8s-Ready Architecture Commands:"
|
@echo "K8s-Ready Architecture Commands:"
|
||||||
@echo " make traefik-dashboard - Access Traefik service discovery dashboard"
|
@echo " make traefik-dashboard - Access Traefik service discovery dashboard"
|
||||||
@@ -94,6 +95,13 @@ migrate:
|
|||||||
@docker compose exec mvp-backend node dist/_system/migrations/run-all.js
|
@docker compose exec mvp-backend node dist/_system/migrations/run-all.js
|
||||||
@echo "Migrations completed."
|
@echo "Migrations completed."
|
||||||
|
|
||||||
|
create-admin:
|
||||||
|
@echo ""
|
||||||
|
@echo "Creating initial admin user..."
|
||||||
|
@echo "This command is only for fresh deployments with no existing admins."
|
||||||
|
@echo ""
|
||||||
|
@docker compose exec -it mvp-backend node dist/_system/cli/create-admin.js
|
||||||
|
|
||||||
rebuild:
|
rebuild:
|
||||||
@echo "Rebuilding containers with latest code changes..."
|
@echo "Rebuilding containers with latest code changes..."
|
||||||
@docker compose up -d --build --remove-orphans
|
@docker compose up -d --build --remove-orphans
|
||||||
|
|||||||
193
ONBOARDING-FIX.md
Normal file
193
ONBOARDING-FIX.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Fix New User Signup Wizard Flow
|
||||||
|
|
||||||
|
## Problem Summary
|
||||||
|
The new user signup wizard is broken:
|
||||||
|
1. After Auth0 callback, users go to `/garage` instead of `/verify-email`
|
||||||
|
2. Users can access `/garage/*` without verified email
|
||||||
|
3. Onboarding flow is bypassed entirely
|
||||||
|
4. **New Requirement**: Block login completely at Auth0 for unverified users
|
||||||
|
|
||||||
|
## Root Causes
|
||||||
|
1. **Auth0Provider.tsx:29** - `onRedirectCallback` defaults to `/garage` without checking verification
|
||||||
|
2. **App.tsx:472-481** - Callback route just shows "Processing login..." with no routing logic
|
||||||
|
3. **App.tsx:549+** - Protected routes have no email verification check
|
||||||
|
4. **Auth0** - No rule/action blocking unverified users from logging in
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Auth0 Configuration (Manual Step)
|
||||||
|
|
||||||
|
**Auth0 Dashboard -> Actions -> Flows -> Login**
|
||||||
|
|
||||||
|
Create a Post Login Action to block unverified users:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
exports.onExecutePostLogin = async (event, api) => {
|
||||||
|
if (!event.user.email_verified) {
|
||||||
|
api.access.deny('Please verify your email address before logging in. Check your inbox for a verification link.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures:
|
||||||
|
- Unverified users cannot get a JWT
|
||||||
|
- They see a clear error on the Auth0 login screen
|
||||||
|
- They must click the verification link before logging in
|
||||||
|
|
||||||
|
### Phase 2: Update Signup Flow
|
||||||
|
|
||||||
|
**After signup, redirect to a "Check Your Email" page (not /verify-email)**
|
||||||
|
|
||||||
|
The new flow:
|
||||||
|
1. User submits signup form
|
||||||
|
2. Backend creates Auth0 user (unverified)
|
||||||
|
3. Auth0 automatically sends verification email
|
||||||
|
4. Frontend shows "Check Your Email" page with:
|
||||||
|
- Message: "We've sent a verification link to your email"
|
||||||
|
- Resend button (calls public resend endpoint)
|
||||||
|
- "Back to Login" button
|
||||||
|
5. User clicks email link -> Auth0 marks as verified
|
||||||
|
6. User can now login -> goes to /onboarding
|
||||||
|
|
||||||
|
### Phase 3: Backend Changes
|
||||||
|
|
||||||
|
**File: `backend/src/features/auth/api/auth.routes.ts`**
|
||||||
|
- Add `POST /api/auth/resend-verification-public` (no JWT required)
|
||||||
|
- Takes email address, looks up user, resends verification
|
||||||
|
|
||||||
|
**File: `backend/src/features/auth/api/auth.controller.ts`**
|
||||||
|
- Add `resendVerificationPublic` handler
|
||||||
|
|
||||||
|
**File: `backend/src/features/auth/domain/auth.service.ts`**
|
||||||
|
- Add `resendVerificationByEmail` method
|
||||||
|
|
||||||
|
**File: `backend/src/features/auth/api/auth.routes.ts`**
|
||||||
|
- Add `GET /api/auth/user-status` (JWT required)
|
||||||
|
- Returns: `{ emailVerified, onboardingCompleted, email }`
|
||||||
|
|
||||||
|
**File: `backend/src/core/plugins/auth.plugin.ts`**
|
||||||
|
- Add `/api/auth/user-status` to `VERIFICATION_EXEMPT_ROUTES`
|
||||||
|
|
||||||
|
### Phase 4: Create Callback Handler
|
||||||
|
|
||||||
|
**File: `frontend/src/features/auth/pages/CallbackPage.tsx`** (NEW)
|
||||||
|
- Fetches user status after Auth0 callback
|
||||||
|
- Routes based on status:
|
||||||
|
- Not onboarded -> `/onboarding`
|
||||||
|
- Onboarded -> `/garage` (or returnTo)
|
||||||
|
- Note: Unverified users never reach this (blocked by Auth0)
|
||||||
|
|
||||||
|
**File: `frontend/src/features/auth/mobile/CallbackMobileScreen.tsx`** (NEW)
|
||||||
|
- Mobile version
|
||||||
|
|
||||||
|
### Phase 5: Update Auth0Provider
|
||||||
|
|
||||||
|
**File: `frontend/src/core/auth/Auth0Provider.tsx`**
|
||||||
|
|
||||||
|
Update `onRedirectCallback` (line 27-31):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const onRedirectCallback = (appState?: { returnTo?: string }) => {
|
||||||
|
navigate('/callback', {
|
||||||
|
replace: true,
|
||||||
|
state: { returnTo: appState?.returnTo || '/garage' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 6: Rename/Update Verify Email Page
|
||||||
|
|
||||||
|
**File: `frontend/src/features/auth/pages/VerifyEmailPage.tsx`**
|
||||||
|
- Rename concept to "Check Your Email" page
|
||||||
|
- Remove polling (user can't be authenticated)
|
||||||
|
- Show static message + resend button (calls public endpoint)
|
||||||
|
- Add "Back to Login" button
|
||||||
|
|
||||||
|
**File: `frontend/src/features/auth/mobile/VerifyEmailMobileScreen.tsx`**
|
||||||
|
- Same changes for mobile
|
||||||
|
|
||||||
|
### Phase 7: Update App.tsx Routing
|
||||||
|
|
||||||
|
**File: `frontend/src/App.tsx`**
|
||||||
|
|
||||||
|
1. Replace callback handling (lines 472-481) with CallbackPage
|
||||||
|
2. Add onboarding guard after authentication check
|
||||||
|
3. Remove email verification check from frontend (Auth0 handles it)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After isAuthenticated check:
|
||||||
|
// Fetch onboarding status
|
||||||
|
// If not onboarded -> /onboarding
|
||||||
|
// Otherwise -> proceed to /garage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 8: Create Supporting Files
|
||||||
|
|
||||||
|
**File: `frontend/src/core/auth/useUserStatus.ts`** (NEW)
|
||||||
|
- Hook for fetching user status
|
||||||
|
|
||||||
|
**File: `frontend/src/features/auth/api/auth.api.ts`**
|
||||||
|
- Add `getUserStatus()`
|
||||||
|
- Add `resendVerificationPublic(email)` (no auth)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### Auth0 (Manual Configuration)
|
||||||
|
- Create Post Login Action to block unverified users
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `backend/src/features/auth/api/auth.routes.ts` - Add endpoints
|
||||||
|
- `backend/src/features/auth/api/auth.controller.ts` - Add handlers
|
||||||
|
- `backend/src/features/auth/domain/auth.service.ts` - Add methods
|
||||||
|
- `backend/src/core/plugins/auth.plugin.ts` - Update exempt routes
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `frontend/src/core/auth/Auth0Provider.tsx` - Fix onRedirectCallback
|
||||||
|
- `frontend/src/App.tsx` - Add route guards and callback handler
|
||||||
|
- `frontend/src/features/auth/pages/CallbackPage.tsx` - NEW
|
||||||
|
- `frontend/src/features/auth/mobile/CallbackMobileScreen.tsx` - NEW
|
||||||
|
- `frontend/src/features/auth/pages/VerifyEmailPage.tsx` - Update to static page
|
||||||
|
- `frontend/src/features/auth/mobile/VerifyEmailMobileScreen.tsx` - Update
|
||||||
|
- `frontend/src/core/auth/useUserStatus.ts` - NEW
|
||||||
|
- `frontend/src/features/auth/api/auth.api.ts` - Add functions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New User Flow (After Fix)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Signup form submission
|
||||||
|
2. -> POST /api/auth/signup (creates unverified Auth0 user)
|
||||||
|
3. -> Navigate to /verify-email (static "Check Your Email" page)
|
||||||
|
4. User clicks verification link in email
|
||||||
|
5. -> Auth0 marks user as verified
|
||||||
|
6. User clicks "Login" on /verify-email page
|
||||||
|
7. -> Auth0 login succeeds (user is now verified)
|
||||||
|
8. -> /callback page fetches status
|
||||||
|
9. -> Not onboarded? -> /onboarding
|
||||||
|
10. -> Complete onboarding -> /garage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Returning User Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Login attempt (unverified) -> Auth0 blocks with error message
|
||||||
|
2. Login attempt (verified, not onboarded) -> /callback -> /onboarding
|
||||||
|
3. Login attempt (verified, onboarded) -> /callback -> /garage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
- [ ] Auth0 Action blocks unverified login with clear error
|
||||||
|
- [ ] Signup -> check-email page -> verify via email -> login works
|
||||||
|
- [ ] Resend verification from check-email page works
|
||||||
|
- [ ] Verified user (no onboarding) -> onboarding wizard
|
||||||
|
- [ ] Verified + onboarded user -> direct to garage
|
||||||
|
- [ ] Direct URL access to /garage -> requires login
|
||||||
|
- [ ] All flows work on mobile
|
||||||
|
- [ ] All flows work on desktop
|
||||||
315
backend/src/_system/cli/create-admin.ts
Normal file
315
backend/src/_system/cli/create-admin.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary CLI script for creating initial admin user on fresh deployments
|
||||||
|
* @ai-context Runs inside backend container with access to Auth0 and PostgreSQL
|
||||||
|
*
|
||||||
|
* Usage: node dist/_system/cli/create-admin.js
|
||||||
|
*
|
||||||
|
* Interactive prompts for email and password, then:
|
||||||
|
* 1. Checks if any admin exists (errors if yes)
|
||||||
|
* 2. Creates user in Auth0 via Management API
|
||||||
|
* 3. Creates user_profile record
|
||||||
|
* 4. Creates admin_users record with audit log
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as readline from 'readline';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { appConfig } from '../../core/config/config-loader';
|
||||||
|
import { auth0ManagementClient } from '../../core/auth/auth0-management.client';
|
||||||
|
import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository';
|
||||||
|
import { AdminRepository } from '../../features/admin/data/admin.repository';
|
||||||
|
import { AdminService } from '../../features/admin/domain/admin.service';
|
||||||
|
|
||||||
|
// Terminal color codes
|
||||||
|
const colors = {
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
bold: '\x1b[1m',
|
||||||
|
};
|
||||||
|
|
||||||
|
function printInfo(msg: string): void {
|
||||||
|
console.log(`${colors.green}[INFO]${colors.reset} ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printError(msg: string): void {
|
||||||
|
console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printWarn(msg: string): void {
|
||||||
|
console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for text input
|
||||||
|
*/
|
||||||
|
function prompt(rl: readline.Interface, question: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(question, (answer) => {
|
||||||
|
resolve(answer.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for password input (masked - characters not echoed)
|
||||||
|
*/
|
||||||
|
function promptPassword(question: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
process.stdout.write(question);
|
||||||
|
|
||||||
|
const stdin = process.stdin;
|
||||||
|
const wasRaw = stdin.isRaw;
|
||||||
|
|
||||||
|
stdin.setRawMode?.(true);
|
||||||
|
stdin.resume();
|
||||||
|
|
||||||
|
let password = '';
|
||||||
|
|
||||||
|
const onData = (char: Buffer): void => {
|
||||||
|
const c = char.toString();
|
||||||
|
|
||||||
|
if (c === '\n' || c === '\r') {
|
||||||
|
stdin.removeListener('data', onData);
|
||||||
|
stdin.setRawMode?.(wasRaw);
|
||||||
|
stdin.pause();
|
||||||
|
console.log(); // newline after password
|
||||||
|
resolve(password);
|
||||||
|
} else if (c === '\u0003') {
|
||||||
|
// Ctrl+C - exit gracefully
|
||||||
|
console.log('\n\nAborted by user.');
|
||||||
|
process.exit(1);
|
||||||
|
} else if (c === '\u007f' || c === '\b') {
|
||||||
|
// Backspace
|
||||||
|
password = password.slice(0, -1);
|
||||||
|
// Optionally show backspace effect
|
||||||
|
if (password.length >= 0) {
|
||||||
|
process.stdout.write('\b \b');
|
||||||
|
}
|
||||||
|
} else if (c.charCodeAt(0) >= 32) {
|
||||||
|
// Printable character
|
||||||
|
password += c;
|
||||||
|
process.stdout.write('*'); // Show asterisk for each character
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
stdin.on('data', onData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email format
|
||||||
|
*/
|
||||||
|
function validateEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate password strength
|
||||||
|
* Returns error message or null if valid
|
||||||
|
*/
|
||||||
|
function validatePassword(password: string): string | null {
|
||||||
|
if (password.length < 8) {
|
||||||
|
return 'Password must be at least 8 characters long';
|
||||||
|
}
|
||||||
|
if (!/[A-Z]/.test(password)) {
|
||||||
|
return 'Password must contain at least one uppercase letter';
|
||||||
|
}
|
||||||
|
if (!/[a-z]/.test(password)) {
|
||||||
|
return 'Password must contain at least one lowercase letter';
|
||||||
|
}
|
||||||
|
if (!/[0-9]/.test(password)) {
|
||||||
|
return 'Password must contain at least one number';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for database to be available
|
||||||
|
*/
|
||||||
|
async function waitForDatabase(pool: Pool, timeoutMs = 30000): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error('Database connection timeout');
|
||||||
|
}
|
||||||
|
await new Promise((res) => setTimeout(res, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('');
|
||||||
|
console.log(`${colors.bold}${colors.cyan}========================================${colors.reset}`);
|
||||||
|
console.log(`${colors.bold}${colors.cyan} MotoVaultPro - Create Admin User${colors.reset}`);
|
||||||
|
console.log(`${colors.bold}${colors.cyan}========================================${colors.reset}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Initialize database pool
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: appConfig.getDatabaseUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for database
|
||||||
|
printInfo('Connecting to database...');
|
||||||
|
await waitForDatabase(pool);
|
||||||
|
printInfo('Database connected.');
|
||||||
|
|
||||||
|
// Initialize repositories and services
|
||||||
|
const userProfileRepo = new UserProfileRepository(pool);
|
||||||
|
const adminRepo = new AdminRepository(pool);
|
||||||
|
const adminService = new AdminService(adminRepo);
|
||||||
|
|
||||||
|
// Step 1: Check if any admin already exists
|
||||||
|
printInfo('Checking for existing admin users...');
|
||||||
|
const existingAdmins = await adminService.getActiveAdmins();
|
||||||
|
|
||||||
|
if (existingAdmins.length > 0) {
|
||||||
|
console.log('');
|
||||||
|
printError('An admin user already exists!');
|
||||||
|
console.log('');
|
||||||
|
console.log(` Found ${existingAdmins.length} existing admin(s):`);
|
||||||
|
for (const admin of existingAdmins) {
|
||||||
|
console.log(` - ${admin.email} (${admin.role})`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
console.log(' This command is only for initial admin setup on fresh deployments.');
|
||||||
|
console.log(' To create additional admins, use the admin panel.');
|
||||||
|
console.log('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
printInfo('No existing admins found. Proceeding with setup.');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Step 2: Create readline interface for interactive prompts
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prompt for email
|
||||||
|
let email: string;
|
||||||
|
while (true) {
|
||||||
|
email = await prompt(rl, 'Enter admin email: ');
|
||||||
|
if (!email) {
|
||||||
|
printError('Email is required');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!validateEmail(email)) {
|
||||||
|
printError('Please enter a valid email address');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close readline before password prompts (we handle raw input directly)
|
||||||
|
rl.close();
|
||||||
|
|
||||||
|
// Prompt for password
|
||||||
|
let password: string;
|
||||||
|
while (true) {
|
||||||
|
password = await promptPassword('Enter password: ');
|
||||||
|
const passwordError = validatePassword(password);
|
||||||
|
if (passwordError) {
|
||||||
|
printError(passwordError);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPassword = await promptPassword('Confirm password: ');
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
printError('Passwords do not match');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
printInfo('Creating admin user...');
|
||||||
|
|
||||||
|
// Step 3: Create user in Auth0 (with email pre-verified for trusted CLI-created admins)
|
||||||
|
printInfo('Creating user in Auth0...');
|
||||||
|
let auth0Sub: string;
|
||||||
|
try {
|
||||||
|
auth0Sub = await auth0ManagementClient.createUser({ email, password, emailVerified: true });
|
||||||
|
printInfo(`Auth0 user created: ${auth0Sub}`);
|
||||||
|
} catch (error) {
|
||||||
|
printError(
|
||||||
|
`Failed to create Auth0 user: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Create user_profile record (with email verified for trusted CLI-created admins)
|
||||||
|
printInfo('Creating user profile...');
|
||||||
|
try {
|
||||||
|
const displayName = email.split('@')[0]; // Use email prefix as display name
|
||||||
|
await userProfileRepo.create(auth0Sub, email, displayName);
|
||||||
|
// Mark email as verified in database (trusted admin created via CLI)
|
||||||
|
await userProfileRepo.updateEmailVerified(auth0Sub, true);
|
||||||
|
printInfo('User profile created (email verified).');
|
||||||
|
} catch (error) {
|
||||||
|
printError(
|
||||||
|
`Failed to create user profile: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
// Attempt to clean up Auth0 user
|
||||||
|
printWarn('Attempting to clean up Auth0 user...');
|
||||||
|
try {
|
||||||
|
await auth0ManagementClient.deleteUser(auth0Sub);
|
||||||
|
printInfo('Auth0 user cleaned up.');
|
||||||
|
} catch (cleanupError) {
|
||||||
|
printError('Failed to clean up Auth0 user - manual cleanup may be required');
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Create admin_users record
|
||||||
|
printInfo('Creating admin record...');
|
||||||
|
try {
|
||||||
|
const admin = await adminService.createAdmin(
|
||||||
|
email,
|
||||||
|
'admin',
|
||||||
|
auth0Sub,
|
||||||
|
'system-cli' // createdBy indicator for CLI-created admins
|
||||||
|
);
|
||||||
|
printInfo(`Admin record created: ${admin.email} (${admin.role})`);
|
||||||
|
} catch (error) {
|
||||||
|
printError(
|
||||||
|
`Failed to create admin record: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
printWarn('User was created but admin privileges were not assigned.');
|
||||||
|
printWarn('You may need to manually grant admin access via the database.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
console.log('');
|
||||||
|
console.log(`${colors.bold}${colors.green}========================================${colors.reset}`);
|
||||||
|
console.log(`${colors.bold}${colors.green} Admin User Created Successfully!${colors.reset}`);
|
||||||
|
console.log(`${colors.bold}${colors.green}========================================${colors.reset}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(` Email: ${email}`);
|
||||||
|
console.log(` Role: admin`);
|
||||||
|
console.log(` Auth0 ID: ${auth0Sub}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(' You can now log in at https://motovaultpro.com');
|
||||||
|
console.log('');
|
||||||
|
} catch (error) {
|
||||||
|
printError(`Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run if executed directly
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { logger } from '../logging/logger';
|
|||||||
interface CreateUserParams {
|
interface CreateUserParams {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
emailVerified?: boolean; // Optional: set true for trusted users (e.g., CLI-created admins)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserDetails {
|
interface UserDetails {
|
||||||
@@ -46,7 +47,7 @@ class Auth0ManagementClientSingleton {
|
|||||||
* @param password User's password
|
* @param password User's password
|
||||||
* @returns Auth0 user ID
|
* @returns Auth0 user ID
|
||||||
*/
|
*/
|
||||||
async createUser({ email, password }: CreateUserParams): Promise<string> {
|
async createUser({ email, password, emailVerified = false }: CreateUserParams): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const client = this.getClient();
|
const client = this.getClient();
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ class Auth0ManagementClientSingleton {
|
|||||||
connection: this.CONNECTION_NAME,
|
connection: this.CONNECTION_NAME,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
email_verified: false,
|
email_verified: emailVerified,
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = response.data;
|
const user = response.data;
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ CREATE INDEX IF NOT EXISTS idx_admin_users_created_at ON admin_users(created_at)
|
|||||||
-- Create index on revoked_at for active admin queries
|
-- Create index on revoked_at for active admin queries
|
||||||
CREATE INDEX IF NOT EXISTS idx_admin_users_revoked_at ON admin_users(revoked_at);
|
CREATE INDEX IF NOT EXISTS idx_admin_users_revoked_at ON admin_users(revoked_at);
|
||||||
|
|
||||||
-- Seed initial admin user (idempotent)
|
-- Note: Initial admin user is created via `make create-admin` command
|
||||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
-- This allows for dynamic email/password configuration on fresh deployments
|
||||||
VALUES ('system|bootstrap', 'admin@motovaultpro.com', 'admin', 'system')
|
|
||||||
ON CONFLICT (auth0_sub) DO NOTHING;
|
|
||||||
|
|
||||||
-- Create update trigger function (if not exists)
|
-- Create update trigger function (if not exists)
|
||||||
DO $$
|
DO $$
|
||||||
|
|||||||
@@ -157,12 +157,11 @@ export class UserProfileController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { password, confirmationText } = validation.data;
|
const { confirmationText } = validation.data;
|
||||||
|
|
||||||
// Request deletion
|
// Request deletion (user is already authenticated via JWT)
|
||||||
const profile = await this.userProfileService.requestDeletion(
|
const profile = await this.userProfileService.requestDeletion(
|
||||||
auth0Sub,
|
auth0Sub,
|
||||||
password,
|
|
||||||
confirmationText
|
confirmationText
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -178,13 +177,6 @@ export class UserProfileController {
|
|||||||
userId: request.userContext?.userId,
|
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')) {
|
if (error.message.includes('Invalid confirmation')) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export const updateProfileSchema = z.object({
|
|||||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||||
|
|
||||||
export const requestDeletionSchema = z.object({
|
export const requestDeletionSchema = z.object({
|
||||||
password: z.string().min(1, 'Password is required'),
|
|
||||||
confirmationText: z.string().refine((val) => val === 'DELETE', {
|
confirmationText: z.string().refine((val) => val === 'DELETE', {
|
||||||
message: 'Confirmation text must be exactly "DELETE"',
|
message: 'Confirmation text must be exactly "DELETE"',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -328,12 +328,12 @@ export class UserProfileService {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request account deletion with password verification
|
* Request account deletion
|
||||||
* Sets 30-day grace period before permanent deletion
|
* Sets 30-day grace period before permanent deletion
|
||||||
|
* Note: User is already authenticated via JWT, confirmation text is sufficient
|
||||||
*/
|
*/
|
||||||
async requestDeletion(
|
async requestDeletion(
|
||||||
auth0Sub: string,
|
auth0Sub: string,
|
||||||
password: string,
|
|
||||||
confirmationText: string
|
confirmationText: string
|
||||||
): Promise<UserProfile> {
|
): Promise<UserProfile> {
|
||||||
try {
|
try {
|
||||||
@@ -353,12 +353,6 @@ export class UserProfileService {
|
|||||||
throw new Error('Deletion already requested');
|
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
|
// Request deletion
|
||||||
const updatedProfile = await this.repository.requestDeletion(auth0Sub);
|
const updatedProfile = await this.repository.requestDeletion(auth0Sub);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ comprehensive spec.md - containing requirements, architecture decisions, data mo
|
|||||||
You are a senior software engineer specializsing in NodeJS, Typescript, front end and back end development. You will be delegating tasks to the platform-agent, feature-agent, first-frontend-agent and quality-agent when appropriate.
|
You are a senior software engineer specializsing in NodeJS, Typescript, front end and back end development. You will be delegating tasks to the platform-agent, feature-agent, first-frontend-agent and quality-agent when appropriate.
|
||||||
|
|
||||||
*** ACTION ***
|
*** ACTION ***
|
||||||
- You will be implementing improvements to the User Management.
|
- You will be fixing a workflow logic in the new user sign up wizard.
|
||||||
- Make no assumptions.
|
- Make no assumptions.
|
||||||
- Ask clarifying questions.
|
- Ask clarifying questions.
|
||||||
- Ultrathink
|
- Ultrathink
|
||||||
@@ -27,11 +27,10 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
|
|||||||
*** CONTEXT ***
|
*** CONTEXT ***
|
||||||
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
|
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
|
||||||
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
|
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
|
||||||
- There is no delete option for users
|
- When a new user signs up, they are immediately redirected to https://motovaultpro.com/verify-email which is supposed to send them through a new user wizard.
|
||||||
- GPDR requires that users are able to fully delete their information
|
- The user is also allowed to login before the email is confirmed. There are API errors but the login is allowed.
|
||||||
- There is a Delete button in the user settings. This needs to be implemented
|
- It should not even let people login without a verified email.
|
||||||
- The same functionality should be enabled admin settings for user management.
|
- The new user wizard exists. It worked in the past. Recent user changes must have broken the workflow.
|
||||||
|
|
||||||
|
|
||||||
*** CHANGES TO IMPLEMENT ***
|
*** CHANGES TO IMPLEMENT ***
|
||||||
- Research this code base and ask iterative questions to compile a complete plan.
|
- Research this code base and ask iterative questions to compile a complete plan.
|
||||||
|
|||||||
@@ -23,28 +23,26 @@ interface DeleteAccountDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteAccountDialog: React.FC<DeleteAccountDialogProps> = ({ open, onClose }) => {
|
export const DeleteAccountDialog: React.FC<DeleteAccountDialogProps> = ({ open, onClose }) => {
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [confirmationText, setConfirmationText] = useState('');
|
const [confirmationText, setConfirmationText] = useState('');
|
||||||
const requestDeletionMutation = useRequestDeletion();
|
const requestDeletionMutation = useRequestDeletion();
|
||||||
|
|
||||||
// Clear form when dialog closes
|
// Clear form when dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setPassword('');
|
|
||||||
setConfirmationText('');
|
setConfirmationText('');
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!password || confirmationText !== 'DELETE') {
|
if (confirmationText !== 'DELETE') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await requestDeletionMutation.mutateAsync({ password, confirmationText });
|
await requestDeletionMutation.mutateAsync({ confirmationText });
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValid = password.length > 0 && confirmationText === 'DELETE';
|
const isValid = confirmationText === 'DELETE';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
@@ -61,17 +59,6 @@ export const DeleteAccountDialog: React.FC<DeleteAccountDialogProps> = ({ open,
|
|||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<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
|
<TextField
|
||||||
label="Type DELETE to confirm"
|
label="Type DELETE to confirm"
|
||||||
value={confirmationText}
|
value={confirmationText}
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ interface DeleteAccountModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen, onClose }) => {
|
export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen, onClose }) => {
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [confirmationText, setConfirmationText] = useState('');
|
const [confirmationText, setConfirmationText] = useState('');
|
||||||
const requestDeletionMutation = useRequestDeletion();
|
const requestDeletionMutation = useRequestDeletion();
|
||||||
|
|
||||||
// Clear form when modal closes
|
// Clear form when modal closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setPassword('');
|
|
||||||
setConfirmationText('');
|
setConfirmationText('');
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
@@ -26,15 +24,15 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!password || confirmationText !== 'DELETE') {
|
if (confirmationText !== 'DELETE') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await requestDeletionMutation.mutateAsync({ password, confirmationText });
|
await requestDeletionMutation.mutateAsync({ confirmationText });
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValid = password.length > 0 && confirmationText === 'DELETE';
|
const isValid = confirmationText === 'DELETE';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
@@ -50,23 +48,6 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Confirmation Input */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export interface DeletionStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestDeletionRequest {
|
export interface RequestDeletionRequest {
|
||||||
password: string;
|
|
||||||
confirmationText: string;
|
confirmationText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user