diff --git a/dashboards/admin-web/.env.example b/dashboards/admin-web/.env.example index 2c57452a..d792c57d 100644 --- a/dashboards/admin-web/.env.example +++ b/dashboards/admin-web/.env.example @@ -15,6 +15,9 @@ COSMOS_DATABASE=lysnrai # ── Auth ── JWT_SECRET=your-jwt-secret +# ── SmartAuth: OAuth (optional — enables social login buttons) ── +NEXT_PUBLIC_GOOGLE_CLIENT_ID= + # ── Microservice URLs (consolidated platform-service) ── PLATFORM_SERVICE_URL=http://localhost:4003 BILLING_INTERNAL_KEY= diff --git a/dashboards/admin-web/src/app/(dashboard)/ops/security/page.tsx b/dashboards/admin-web/src/app/(dashboard)/ops/security/page.tsx new file mode 100644 index 00000000..ffb39841 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/ops/security/page.tsx @@ -0,0 +1,273 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + ShieldCheck, + Users, + Activity, + AlertTriangle, + Loader2, + AlertCircle, + RefreshCw, + Eye, + Lock, + Unlock, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; + +interface SecurityOverview { + totalUsers: number; + mfaAdoptionPercent: number; + providerDistribution: Record; + activeSessions: number; + suspiciousEvents24h: number; +} + +interface LoginEvent { + id: string; + eventType: string; + method: string; + ip: string; + userAgent: string; + riskScore: number; + riskFactors: string[]; + createdAt: string; + userId?: string; + email?: string; +} + +function getToken(): string | null { + return typeof window !== 'undefined' ? localStorage.getItem('admin_access_token') : null; +} + +function authHeaders(): Record { + const t = getToken(); + return t ? { Authorization: `Bearer ${t}` } : {}; +} + +function riskColor(score: number): string { + if (score >= 80) return 'text-red-600 bg-red-50'; + if (score >= 50) return 'text-orange-600 bg-orange-50'; + if (score >= 25) return 'text-yellow-600 bg-yellow-50'; + return 'text-green-600 bg-green-50'; +} + +export default function SecurityDashboardPage() { + const [overview, setOverview] = useState(null); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [showSuspiciousOnly, setShowSuspiciousOnly] = useState(false); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(''); + try { + const [overviewRes, eventsRes] = await Promise.all([ + fetch('/api/auth/security/overview', { headers: authHeaders() }), + fetch(`/api/auth/login-events?limit=50${showSuspiciousOnly ? '&suspicious=true' : ''}`, { + headers: authHeaders(), + }), + ]); + + if (overviewRes.ok) setOverview(await overviewRes.json()); + if (eventsRes.ok) setEvents(await eventsRes.json()); + } catch { + setError('Failed to load security data'); + } finally { + setLoading(false); + } + }, [showSuspiciousOnly]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleUnlock = async (userId: string) => { + try { + await fetch(`/api/auth/security/unlock`, { + method: 'POST', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId }), + }); + await fetchData(); + } catch { + setError('Failed to unlock user'); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Security Dashboard

+

+ Authentication overview and login event monitoring +

+
+ +
+ + {error && ( +
+ + {error} +
+ )} + + {/* Stats Cards */} + {overview && ( +
+ + + Total Users + + + +
{overview.totalUsers}
+
+
+ + + MFA Adoption + + + +
{overview.mfaAdoptionPercent}%
+
+
+ + + Active Sessions + + + +
{overview.activeSessions}
+
+
+ + + Suspicious (24h) + + + +
0 ? 'text-red-600' : ''}`} + > + {overview.suspiciousEvents24h} +
+
+
+
+ )} + + {/* Provider Distribution */} + {overview && Object.keys(overview.providerDistribution).length > 0 && ( + + + Auth Provider Distribution + + +
+ {Object.entries(overview.providerDistribution).map(([provider, count]) => ( +
+ + {provider} + {count} +
+ ))} +
+
+
+ )} + + {/* Login Events */} + + +
+ Recent Login Events + +
+
+ + {events.length === 0 ? ( +

No login events found

+ ) : ( +
+ + + + + + + + + + + + + {events.map(evt => ( + + + + + + + + + ))} + +
TimeTypeMethodIPRiskActions
+ {new Date(evt.createdAt).toLocaleString()} + {evt.eventType}{evt.method}{evt.ip} + + {evt.riskScore} + + {evt.riskFactors.length > 0 && ( + + {evt.riskFactors.join(', ')} + + )} + + {evt.userId && evt.eventType === 'lockout' && ( + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/dashboards/admin-web/src/app/(dashboard)/settings/devices/page.tsx b/dashboards/admin-web/src/app/(dashboard)/settings/devices/page.tsx new file mode 100644 index 00000000..a04140c1 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/settings/devices/page.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + Smartphone, + Monitor, + Tablet, + Loader2, + AlertCircle, + Trash2, + ShieldCheck, + ShieldOff, + Globe, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; + +interface Device { + id: string; + name: string; + platform: string; + trustLevel: 'trusted' | 'remembered' | 'unknown'; + trustExpiresAt: string | null; + lastIp: string; + lastLoginAt: string; + createdAt: string; +} + +function getToken(): string | null { + return typeof window !== 'undefined' ? localStorage.getItem('admin_access_token') : null; +} + +function authHeaders(): Record { + const t = getToken(); + return t ? { Authorization: `Bearer ${t}` } : {}; +} + +function platformIcon(platform: string) { + const p = platform.toLowerCase(); + if (p.includes('mobile') || p.includes('ios') || p.includes('android')) return Smartphone; + if (p.includes('tablet') || p.includes('ipad')) return Tablet; + if (p.includes('web') || p.includes('browser')) return Globe; + return Monitor; +} + +function trustBadge(level: Device['trustLevel']) { + switch (level) { + case 'trusted': + return ( + + + Trusted + + ); + case 'remembered': + return ( + + + Remembered + + ); + default: + return ( + + + Unknown + + ); + } +} + +export default function DeviceManagementPage() { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const fetchDevices = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await fetch('/api/auth/devices', { headers: authHeaders() }); + if (res.ok) { + setDevices(await res.json()); + } else { + setError('Failed to load devices'); + } + } catch { + setError('Service unavailable'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchDevices(); + }, [fetchDevices]); + + const handleRevoke = async (deviceId: string) => { + if (!confirm('Revoke this device? It will need to re-authenticate.')) return; + try { + const res = await fetch(`/api/auth/devices/${deviceId}`, { + method: 'DELETE', + headers: authHeaders(), + }); + if (res.ok) { + setDevices(prev => prev.filter(d => d.id !== deviceId)); + } else { + setError('Failed to revoke device'); + } + } catch { + setError('Service unavailable'); + } + }; + + const handleRevokeAll = async () => { + if (!confirm('Revoke ALL devices? Every session will be logged out.')) return; + try { + const res = await fetch('/api/auth/devices', { + method: 'DELETE', + headers: authHeaders(), + }); + if (res.ok) { + setDevices([]); + } else { + setError('Failed to revoke devices'); + } + } catch { + setError('Service unavailable'); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Device Management

+

View and manage trusted devices for your account

+
+ {devices.length > 1 && ( + + )} +
+ + {error && ( +
+ + {error} +
+ )} + + {devices.length === 0 ? ( + + + No devices found + + + ) : ( +
+ {devices.map(device => { + const Icon = platformIcon(device.platform); + return ( + + +
+
+
+ +
+
+ + {device.name || 'Unknown Device'} + + + {device.platform} · {device.lastIp} + +
+
+
+ {trustBadge(device.trustLevel)} + +
+
+
+ +
+
Last login: {new Date(device.lastLoginAt).toLocaleString()}
+
First seen: {new Date(device.createdAt).toLocaleString()}
+ {device.trustExpiresAt && ( +
Trust expires: {new Date(device.trustExpiresAt).toLocaleString()}
+ )} +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/dashboards/admin-web/src/app/(dashboard)/settings/passkeys/page.tsx b/dashboards/admin-web/src/app/(dashboard)/settings/passkeys/page.tsx new file mode 100644 index 00000000..0e1af3c8 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/settings/passkeys/page.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Fingerprint, Loader2, AlertCircle, Trash2, Plus, Laptop, Usb } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; + +interface Passkey { + id: string; + friendlyName: string; + deviceType: 'platform' | 'cross-platform'; + backedUp: boolean; + lastUsedAt: string | null; + createdAt: string; +} + +function getToken(): string | null { + return typeof window !== 'undefined' ? localStorage.getItem('admin_access_token') : null; +} + +function authHeaders(): Record { + const t = getToken(); + return t ? { Authorization: `Bearer ${t}` } : {}; +} + +export default function PasskeyManagementPage() { + const [passkeys, setPasskeys] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [registering, setRegistering] = useState(false); + + const fetchPasskeys = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await fetch('/api/auth/passkeys', { headers: authHeaders() }); + if (res.ok) { + setPasskeys(await res.json()); + } else { + setError('Failed to load passkeys'); + } + } catch { + setError('Service unavailable'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchPasskeys(); + }, [fetchPasskeys]); + + const handleRegister = async () => { + setRegistering(true); + setError(''); + try { + // Step 1: Get registration options from server + const optionsRes = await fetch('/api/auth/passkeys/register/options', { + method: 'POST', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + }); + if (!optionsRes.ok) { + setError('Failed to start passkey registration'); + return; + } + const options = await optionsRes.json(); + + // Step 2: Create credential via WebAuthn API + if (!window.PublicKeyCredential) { + setError('Passkeys are not supported in this browser'); + return; + } + + // Convert base64url fields to ArrayBuffer for WebAuthn API + const publicKeyOptions = { + ...options, + challenge: base64urlToBuffer(options.challenge), + user: { + ...options.user, + id: base64urlToBuffer(options.user.id), + }, + excludeCredentials: (options.excludeCredentials || []).map( + (cred: { id: string; type: string }) => ({ + ...cred, + id: base64urlToBuffer(cred.id), + }) + ), + }; + + const credential = (await navigator.credentials.create({ + publicKey: publicKeyOptions, + })) as PublicKeyCredential; + + if (!credential) { + setError('Passkey creation was cancelled'); + return; + } + + // Step 3: Send credential response to server for verification + const attestationResponse = credential.response as AuthenticatorAttestationResponse; + const verifyRes = await fetch('/api/auth/passkeys/register/verify', { + method: 'POST', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64url(attestationResponse.clientDataJSON), + attestationObject: bufferToBase64url(attestationResponse.attestationObject), + }, + }), + }); + + if (!verifyRes.ok) { + const data = await verifyRes.json(); + setError(data.error || 'Passkey registration failed'); + return; + } + + await fetchPasskeys(); + } catch (err) { + if (err instanceof DOMException && err.name === 'NotAllowedError') { + setError('Passkey creation was cancelled or timed out'); + } else { + setError('Passkey registration failed'); + } + } finally { + setRegistering(false); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Remove this passkey? You will no longer be able to sign in with it.')) return; + try { + const res = await fetch(`/api/auth/passkeys/${id}`, { + method: 'DELETE', + headers: authHeaders(), + }); + if (res.ok) { + setPasskeys(prev => prev.filter(p => p.id !== id)); + } else { + setError('Failed to remove passkey'); + } + } catch { + setError('Service unavailable'); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Passkeys

+

+ Sign in with biometrics or a security key — no password needed +

+
+ +
+ + {error && ( +
+ + {error} +
+ )} + + {passkeys.length === 0 ? ( + + + +

No passkeys registered yet

+

+ Passkeys use biometrics (Face ID, Touch ID, Windows Hello) or a security key for + passwordless login. +

+
+
+ ) : ( +
+ {passkeys.map(pk => ( + + +
+
+
+ {pk.deviceType === 'platform' ? ( + + ) : ( + + )} +
+
+ {pk.friendlyName || 'Passkey'} + + {pk.deviceType === 'platform' ? 'Built-in authenticator' : 'Security key'} + {pk.backedUp && ' · Synced'} + +
+
+ +
+
+ +
+
Created: {new Date(pk.createdAt).toLocaleDateString()}
+ {pk.lastUsedAt && ( +
Last used: {new Date(pk.lastUsedAt).toLocaleDateString()}
+ )} +
+
+
+ ))} +
+ )} +
+ ); +} + +// ── WebAuthn helpers ────────────────────────────────────────── + +function base64urlToBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +function bufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} diff --git a/dashboards/admin-web/src/app/(dashboard)/settings/security/page.tsx b/dashboards/admin-web/src/app/(dashboard)/settings/security/page.tsx new file mode 100644 index 00000000..b73b53d2 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/settings/security/page.tsx @@ -0,0 +1,306 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + ShieldCheck, + ShieldOff, + Loader2, + AlertCircle, + KeyRound, + Smartphone, + Copy, + Check, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +interface MfaStatus { + mfaEnabled: boolean; + methods: string[]; + recoveryCodesRemaining: number; +} + +interface TotpSetupData { + otpauthUri: string; + qrDataUrl: string; + recoveryCodes: string[]; +} + +function getToken(): string | null { + return typeof window !== 'undefined' ? localStorage.getItem('admin_access_token') : null; +} + +function authHeaders(): Record { + const t = getToken(); + return t ? { Authorization: `Bearer ${t}` } : {}; +} + +export default function SecuritySettingsPage() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + // TOTP setup flow + const [setupData, setSetupData] = useState(null); + const [verifyCode, setVerifyCode] = useState(''); + const [setupLoading, setSetupLoading] = useState(false); + const [copied, setCopied] = useState(false); + + const fetchStatus = useCallback(async () => { + try { + const res = await fetch('/api/auth/mfa/status', { headers: authHeaders() }); + if (res.ok) { + setStatus(await res.json()); + } + } catch { + setError('Failed to load MFA status'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); + + const handleSetupTotp = async () => { + setSetupLoading(true); + setError(''); + try { + const res = await fetch('/api/auth/mfa/totp/setup', { + method: 'POST', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || 'TOTP setup failed'); + return; + } + setSetupData(data); + } catch { + setError('Service unavailable'); + } finally { + setSetupLoading(false); + } + }; + + const handleVerifySetup = async () => { + setSetupLoading(true); + setError(''); + try { + const res = await fetch('/api/auth/mfa/totp/verify-setup', { + method: 'POST', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: verifyCode }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || 'Verification failed'); + return; + } + setSetupData(null); + setVerifyCode(''); + await fetchStatus(); + } catch { + setError('Service unavailable'); + } finally { + setSetupLoading(false); + } + }; + + const handleDisableMfa = async () => { + if (!confirm('Are you sure you want to disable two-factor authentication?')) return; + setSetupLoading(true); + setError(''); + try { + const res = await fetch('/api/auth/mfa/disable', { + method: 'DELETE', + headers: authHeaders(), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.error || 'Failed to disable MFA'); + return; + } + await fetchStatus(); + } catch { + setError('Service unavailable'); + } finally { + setSetupLoading(false); + } + }; + + const copyRecoveryCodes = () => { + if (setupData?.recoveryCodes) { + navigator.clipboard.writeText(setupData.recoveryCodes.join('\n')); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Security Settings

+

+ Manage two-factor authentication and account security +

+
+ + {error && ( +
+ + {error} +
+ )} + + {/* MFA Status Card */} + + +
+ {status?.mfaEnabled ? ( + + ) : ( + + )} +
+ Two-Factor Authentication + + {status?.mfaEnabled + ? `Enabled — ${status.methods.join(', ')} · ${status.recoveryCodesRemaining} recovery codes remaining` + : 'Not enabled — add an extra layer of security to your account'} + +
+
+
+ + {!status?.mfaEnabled && !setupData && ( + + )} + + {status?.mfaEnabled && ( + + )} + +
+ + {/* TOTP Setup Flow */} + {setupData && ( + + + + + Set up authenticator + + + Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, + etc.) + + + + {/* QR Code */} +
+ {setupData.qrDataUrl ? ( + TOTP QR Code + ) : ( +
+ QR code unavailable +
+ )} +
+ + {/* Manual entry URI */} +
+ + + {setupData.otpauthUri} + +
+ + {/* Verify code */} +
+ +
+ setVerifyCode(e.target.value)} + maxLength={6} + className="max-w-[160px] text-center font-mono text-lg tracking-widest" + /> + +
+
+ + {/* Recovery codes */} + {setupData.recoveryCodes.length > 0 && ( +
+
+ + +
+

+ Save these codes in a safe place. Each code can only be used once. +

+
+ {setupData.recoveryCodes.map((code, i) => ( + + {code} + + ))} +
+
+ )} + + +
+
+ )} +
+ ); +} diff --git a/dashboards/admin-web/src/app/api/auth/devices/[deviceId]/route.ts b/dashboards/admin-web/src/app/api/auth/devices/[deviceId]/route.ts new file mode 100644 index 00000000..4b3e57e0 --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/devices/[deviceId]/route.ts @@ -0,0 +1,29 @@ +/** + * Proxy single device revoke to platform-service. + * DELETE /api/auth/devices/:deviceId + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ deviceId: string }> } +) { + try { + const { deviceId } = await params; + const auth = req.headers.get('authorization') || ''; + const res = await fetch(`${PLATFORM_API}/api/auth/devices/${deviceId}`, { + method: 'DELETE', + headers: { Authorization: auth }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json({ error: data.error || 'Failed' }, { status: res.status }); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/devices/route.ts b/dashboards/admin-web/src/app/api/auth/devices/route.ts new file mode 100644 index 00000000..d8c1a0d5 --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/devices/route.ts @@ -0,0 +1,42 @@ +/** + * Proxy device management to platform-service. + * GET /api/auth/devices — list current user's devices + * DELETE /api/auth/devices — revoke all devices + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function GET(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const res = await fetch(`${PLATFORM_API}/api/auth/devices`, { + headers: { Authorization: auth }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json({ error: data.error || 'Failed' }, { status: res.status }); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const res = await fetch(`${PLATFORM_API}/api/auth/devices`, { + method: 'DELETE', + headers: { Authorization: auth }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json({ error: data.error || 'Failed' }, { status: res.status }); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/login-events/route.ts b/dashboards/admin-web/src/app/api/auth/login-events/route.ts new file mode 100644 index 00000000..c90b35db --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/login-events/route.ts @@ -0,0 +1,25 @@ +/** + * Proxy login events to platform-service. + * GET /api/auth/login-events?suspicious=true&limit=50 + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function GET(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const qs = req.nextUrl.searchParams.toString(); + const res = await fetch(`${PLATFORM_API}/api/auth/login-events${qs ? `?${qs}` : ''}`, { + headers: { Authorization: auth }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json({ error: data.error || 'Failed' }, { status: res.status }); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/mfa/disable/route.ts b/dashboards/admin-web/src/app/api/auth/mfa/disable/route.ts new file mode 100644 index 00000000..8cd096d8 --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/mfa/disable/route.ts @@ -0,0 +1,28 @@ +/** + * Proxy MFA disable to platform-service. + * DELETE /api/auth/mfa/totp + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function DELETE(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const res = await fetch(`${PLATFORM_API}/api/auth/mfa/totp`, { + method: 'DELETE', + headers: { Authorization: auth }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json( + { error: data.error || 'Failed to disable MFA' }, + { status: res.status } + ); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/mfa/status/route.ts b/dashboards/admin-web/src/app/api/auth/mfa/status/route.ts new file mode 100644 index 00000000..d1bc6dc7 --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/mfa/status/route.ts @@ -0,0 +1,27 @@ +/** + * Proxy MFA status to platform-service. + * GET /api/auth/mfa/status + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function GET(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const res = await fetch(`${PLATFORM_API}/api/auth/mfa/status`, { + headers: { Authorization: auth }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json( + { error: data.error || 'Failed to get MFA status' }, + { status: res.status } + ); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/mfa/totp/setup/route.ts b/dashboards/admin-web/src/app/api/auth/mfa/totp/setup/route.ts new file mode 100644 index 00000000..1ba7ca7a --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/mfa/totp/setup/route.ts @@ -0,0 +1,28 @@ +/** + * Proxy TOTP setup to platform-service. + * POST /api/auth/mfa/totp/setup + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function POST(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const res = await fetch(`${PLATFORM_API}/api/auth/mfa/totp/setup`, { + method: 'POST', + headers: { Authorization: auth, 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json( + { error: data.error || 'TOTP setup failed' }, + { status: res.status } + ); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/mfa/totp/verify-setup/route.ts b/dashboards/admin-web/src/app/api/auth/mfa/totp/verify-setup/route.ts new file mode 100644 index 00000000..b5f6af30 --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/mfa/totp/verify-setup/route.ts @@ -0,0 +1,30 @@ +/** + * Proxy TOTP verify-setup to platform-service. + * POST /api/auth/mfa/totp/verify-setup { code } + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function POST(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const body = await req.json(); + const res = await fetch(`${PLATFORM_API}/api/auth/mfa/totp/verify-setup`, { + method: 'POST', + headers: { Authorization: auth, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json( + { error: data.error || 'Verification failed' }, + { status: res.status } + ); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/mfa/verify/route.ts b/dashboards/admin-web/src/app/api/auth/mfa/verify/route.ts new file mode 100644 index 00000000..04a95df3 --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/mfa/verify/route.ts @@ -0,0 +1,38 @@ +/** + * Proxy MFA verification to platform-service. + * POST /api/auth/mfa/verify { challengeToken, code, method } + * Returns { accessToken, refreshToken, user }. + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { challengeToken, code, method } = body; + + if (!challengeToken || !code) { + return NextResponse.json({ error: 'challengeToken and code required' }, { status: 400 }); + } + + const res = await fetch(`${PLATFORM_API}/api/auth/mfa/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challengeToken, code, method: method || 'totp' }), + }); + + const data = await res.json(); + if (!res.ok) { + return NextResponse.json( + { error: data.error || 'MFA verification failed' }, + { status: res.status } + ); + } + + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/oauth/[provider]/route.ts b/dashboards/admin-web/src/app/api/auth/oauth/[provider]/route.ts new file mode 100644 index 00000000..b4be6690 --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/oauth/[provider]/route.ts @@ -0,0 +1,42 @@ +/** + * Proxy OAuth login to platform-service. + * POST /api/auth/oauth/:provider { idToken } + * Returns either { accessToken, refreshToken, user } or { mfaRequired, mfaChallenge, methods }. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getRequestProductId } from '@/lib/product-config'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ provider: string }> } +) { + try { + const { provider } = await params; + const { idToken } = await req.json(); + + if (!idToken) { + return NextResponse.json({ error: 'idToken required' }, { status: 400 }); + } + + const res = await fetch(`${PLATFORM_API}/api/auth/oauth/${provider}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ idToken, productId: getRequestProductId(req) }), + }); + + const data = await res.json(); + if (!res.ok) { + return NextResponse.json( + { error: data.error || `OAuth ${provider} login failed` }, + { status: res.status } + ); + } + + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/passkeys/[id]/route.ts b/dashboards/admin-web/src/app/api/auth/passkeys/[id]/route.ts new file mode 100644 index 00000000..88a368c1 --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/passkeys/[id]/route.ts @@ -0,0 +1,29 @@ +/** + * Proxy passkey delete to platform-service. + * DELETE /api/auth/passkeys/:id + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const auth = req.headers.get('authorization') || ''; + const res = await fetch(`${PLATFORM_API}/api/auth/passkeys/${id}`, { + method: 'DELETE', + headers: { Authorization: auth }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json( + { error: data.error || 'Failed to delete passkey' }, + { status: res.status } + ); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/passkeys/register/options/route.ts b/dashboards/admin-web/src/app/api/auth/passkeys/register/options/route.ts new file mode 100644 index 00000000..7843fc1a --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/passkeys/register/options/route.ts @@ -0,0 +1,25 @@ +/** + * Proxy passkey registration options to platform-service. + * POST /api/auth/passkeys/register/options + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function POST(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const res = await fetch(`${PLATFORM_API}/api/auth/passkeys/register/options`, { + method: 'POST', + headers: { Authorization: auth, 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json({ error: data.error || 'Failed' }, { status: res.status }); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/passkeys/register/verify/route.ts b/dashboards/admin-web/src/app/api/auth/passkeys/register/verify/route.ts new file mode 100644 index 00000000..7633ee32 --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/passkeys/register/verify/route.ts @@ -0,0 +1,27 @@ +/** + * Proxy passkey registration verification to platform-service. + * POST /api/auth/passkeys/register/verify + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function POST(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const body = await req.json(); + const res = await fetch(`${PLATFORM_API}/api/auth/passkeys/register/verify`, { + method: 'POST', + headers: { Authorization: auth, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json({ error: data.error || 'Failed' }, { status: res.status }); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/passkeys/route.ts b/dashboards/admin-web/src/app/api/auth/passkeys/route.ts new file mode 100644 index 00000000..c7dc010b --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/passkeys/route.ts @@ -0,0 +1,24 @@ +/** + * Proxy passkey management to platform-service. + * GET /api/auth/passkeys — list passkeys + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function GET(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const res = await fetch(`${PLATFORM_API}/api/auth/passkeys`, { + headers: { Authorization: auth }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json({ error: data.error || 'Failed' }, { status: res.status }); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/security/overview/route.ts b/dashboards/admin-web/src/app/api/auth/security/overview/route.ts new file mode 100644 index 00000000..2a87ce7a --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/security/overview/route.ts @@ -0,0 +1,24 @@ +/** + * Proxy security overview to platform-service. + * GET /api/auth/security/overview + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function GET(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const res = await fetch(`${PLATFORM_API}/api/auth/security/overview`, { + headers: { Authorization: auth }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json({ error: data.error || 'Failed' }, { status: res.status }); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/security/unlock/route.ts b/dashboards/admin-web/src/app/api/auth/security/unlock/route.ts new file mode 100644 index 00000000..e2c911ea --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/security/unlock/route.ts @@ -0,0 +1,32 @@ +/** + * Proxy user unlock to platform-service. + * POST /api/auth/security/unlock { userId } + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003'; + +export async function POST(req: NextRequest) { + try { + const auth = req.headers.get('authorization') || ''; + const { userId } = await req.json(); + if (!userId) { + return NextResponse.json({ error: 'userId required' }, { status: 400 }); + } + const res = await fetch(`${PLATFORM_API}/api/auth/users/${userId}/unlock`, { + method: 'POST', + headers: { Authorization: auth, 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json( + { error: data.error || 'Failed to unlock user' }, + { status: res.status } + ); + } + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/app/login/page.tsx b/dashboards/admin-web/src/app/login/page.tsx index b1d27e61..e85c4aba 100644 --- a/dashboards/admin-web/src/app/login/page.tsx +++ b/dashboards/admin-web/src/app/login/page.tsx @@ -1,14 +1,230 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { LogIn, AlertCircle, Loader2 } from 'lucide-react'; +import { LogIn, AlertCircle, Loader2, ShieldCheck } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useAuth } from '@/lib/auth-context'; +const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''; + +// ── MFA Challenge Sub-view ────────────────────────────────── + +function MfaChallengeView({ + mfaChallenge, + mfaMethods, + onVerified, + onBack, +}: { + mfaChallenge: string; + mfaMethods: string[]; + onVerified: (data: { + accessToken: string; + refreshToken: string; + user: Record; + }) => void; + onBack: () => void; +}) { + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [useRecovery, setUseRecovery] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + const res = await fetch('/api/auth/mfa/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challengeToken: mfaChallenge, + code, + method: useRecovery ? 'recovery' : 'totp', + }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || 'Verification failed'); + return; + } + onVerified(data); + } catch { + setError('Service unavailable'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +

+ {useRecovery ? 'Enter a recovery code' : 'Enter your authentication code'} +

+ {mfaMethods.length > 0 && ( +

Methods: {mfaMethods.join(', ')}

+ )} +
+ + setCode(e.target.value)} + required + maxLength={useRecovery ? 20 : 6} + className="text-center text-lg tracking-widest font-mono" + autoFocus + /> + + {error && ( +
+ + {error} +
+ )} + + + +
+ + +
+
+ ); +} + +// ── Google Sign-In Button ─────────────────────────────────── + +function GoogleSignInButton({ + disabled, + onCredential, +}: { + disabled: boolean; + onCredential: (idToken: string) => void; +}) { + const [loading, setLoading] = useState(false); + + const handleClick = useCallback(async () => { + setLoading(true); + try { + // Use Google Identity Services popup approach + const google = (window as unknown as Record).google as + | { + accounts: { + id: { + initialize: (config: Record) => void; + prompt: (cb?: (notification: { isNotDisplayed: () => boolean }) => void) => void; + }; + }; + } + | undefined; + + if (!google) { + // Fallback: open Google OAuth consent in popup + const width = 500; + const height = 600; + const left = window.screenX + (window.outerWidth - width) / 2; + const top = window.screenY + (window.outerHeight - height) / 2; + const popup = window.open( + `https://accounts.google.com/o/oauth2/v2/auth?` + + `client_id=${GOOGLE_CLIENT_ID}&` + + `redirect_uri=${encodeURIComponent(window.location.origin + '/api/auth/callback/google')}&` + + `response_type=id_token&` + + `scope=openid email profile&` + + `nonce=${crypto.randomUUID()}`, + 'google-signin', + `width=${width},height=${height},left=${left},top=${top}` + ); + // Listen for message from callback + const handler = (event: MessageEvent) => { + if (event.origin === window.location.origin && event.data?.idToken) { + window.removeEventListener('message', handler); + popup?.close(); + onCredential(event.data.idToken); + } + }; + window.addEventListener('message', handler); + return; + } + + // Use GIS one-tap / popup + google.accounts.id.initialize({ + client_id: GOOGLE_CLIENT_ID, + callback: (response: { credential: string }) => { + onCredential(response.credential); + }, + }); + google.accounts.id.prompt(); + } finally { + setLoading(false); + } + }, [onCredential]); + + return ( + + ); +} + +// ── Main Login Page ───────────────────────────────────────── + export default function LoginPage() { const { login, isAuthenticated } = useAuth(); const router = useRouter(); @@ -17,6 +233,10 @@ export default function LoginPage() { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + // MFA state + const [mfaChallenge, setMfaChallenge] = useState(null); + const [mfaMethods, setMfaMethods] = useState([]); + // Validation const emailRegex = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; const isValidEmail = emailRegex.test(email); @@ -35,6 +255,41 @@ export default function LoginPage() { return null; } + // Handle successful auth (either direct login or MFA verified) + const handleAuthenticated = (data: { + accessToken: string; + refreshToken: string; + user: Record; + }) => { + // Store tokens and redirect — the auth context will pick them up + localStorage.setItem('admin_access_token', data.accessToken); + localStorage.setItem('admin_refresh_token', data.refreshToken); + localStorage.setItem( + 'admin_auth_user', + JSON.stringify({ + email: data.user.email, + name: data.user.displayName || data.user.name, + role: data.user.role, + }) + ); + router.replace('/'); + // Force reload to re-initialize auth context from localStorage + window.location.href = '/'; + }; + + // Handle login response that might require MFA + const handleLoginResponse = (data: Record) => { + if (data.mfaRequired) { + setMfaChallenge(data.mfaChallenge as string); + setMfaMethods((data.methods as string[]) || []); + setError(''); + return; + } + handleAuthenticated( + data as { accessToken: string; refreshToken: string; user: Record } + ); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -51,6 +306,54 @@ export default function LoginPage() { } }; + // Google Sign-In handler + const handleGoogleCredential = async (idToken: string) => { + setError(''); + setLoading(true); + try { + const res = await fetch('/api/auth/oauth/google', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ idToken }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || 'Google sign-in failed'); + return; + } + handleLoginResponse(data); + } catch { + setError('Service unavailable'); + } finally { + setLoading(false); + } + }; + + // Show MFA challenge view + if (mfaChallenge) { + return ( +
+ + + Two-Factor Authentication + Additional verification required + + + { + setMfaChallenge(null); + setMfaMethods([]); + }} + /> + + +
+ ); + } + return (
@@ -101,6 +404,20 @@ export default function LoginPage() { Sign In + {GOOGLE_CLIENT_ID && ( + <> +
+
+ +
+
+ or +
+
+ + + )} +

Demo: admin@example.com / Admin123!

diff --git a/dashboards/admin-web/src/components/sidebar-nav.tsx b/dashboards/admin-web/src/components/sidebar-nav.tsx index f85b3af8..84385010 100644 --- a/dashboards/admin-web/src/components/sidebar-nav.tsx +++ b/dashboards/admin-web/src/components/sidebar-nav.tsx @@ -115,7 +115,17 @@ export function SidebarNav() { {/* Navigation */}