feat(auth): SmartAuth admin-web — OAuth proxy, MFA settings, devices, passkeys, security dashboard

- Add 15 API proxy routes for SmartAuth endpoints (OAuth, MFA, devices, passkeys, security)
- Add MFA Settings page (/settings/security) with TOTP setup/verify/disable flow
- Add Device Management page (/settings/devices) with trust badges and revoke actions
- Add Passkey Management page (/settings/passkeys) with WebAuthn registration
- Add Admin Security Dashboard (/ops/security) with stats, provider distribution, login events
- Update login page with Google Sign-In button (env-gated) and MFA challenge flow
- Add sidebar nav links for new security pages
- Fix sidebar nav highlighting for nested routes (exact match for parent items)
- Add NEXT_PUBLIC_GOOGLE_CLIENT_ID to .env.example
This commit is contained in:
saravanakumardb1 2026-03-12 11:13:14 -07:00
parent bdb3e95e00
commit 067a23449f
26 changed files with 1929 additions and 34 deletions

View File

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

View File

@ -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<string, number>;
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<string, string> {
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<SecurityOverview | null>(null);
const [events, setEvents] = useState<LoginEvent[]>([]);
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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Security Dashboard</h1>
<p className="text-muted-foreground">
Authentication overview and login event monitoring
</p>
</div>
<Button variant="outline" size="sm" onClick={fetchData}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{/* Stats Cards */}
{overview && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overview.totalUsers}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">MFA Adoption</CardTitle>
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overview.mfaAdoptionPercent}%</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Active Sessions</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overview.activeSessions}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Suspicious (24h)</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div
className={`text-2xl font-bold ${overview.suspiciousEvents24h > 0 ? 'text-red-600' : ''}`}
>
{overview.suspiciousEvents24h}
</div>
</CardContent>
</Card>
</div>
)}
{/* Provider Distribution */}
{overview && Object.keys(overview.providerDistribution).length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Auth Provider Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
{Object.entries(overview.providerDistribution).map(([provider, count]) => (
<div
key={provider}
className="flex items-center gap-2 rounded-full bg-muted px-3 py-1 text-sm"
>
<Lock className="h-3 w-3" />
<span className="font-medium capitalize">{provider}</span>
<span className="text-muted-foreground">{count}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Login Events */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Recent Login Events</CardTitle>
<Button
variant={showSuspiciousOnly ? 'default' : 'outline'}
size="sm"
onClick={() => setShowSuspiciousOnly(!showSuspiciousOnly)}
>
<Eye className="mr-2 h-4 w-4" />
{showSuspiciousOnly ? 'Showing suspicious' : 'Show suspicious only'}
</Button>
</div>
</CardHeader>
<CardContent>
{events.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No login events found</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4">Time</th>
<th className="pb-2 pr-4">Type</th>
<th className="pb-2 pr-4">Method</th>
<th className="pb-2 pr-4">IP</th>
<th className="pb-2 pr-4">Risk</th>
<th className="pb-2">Actions</th>
</tr>
</thead>
<tbody>
{events.map(evt => (
<tr key={evt.id} className="border-b last:border-0">
<td className="py-2 pr-4 text-xs text-muted-foreground">
{new Date(evt.createdAt).toLocaleString()}
</td>
<td className="py-2 pr-4">{evt.eventType}</td>
<td className="py-2 pr-4 capitalize">{evt.method}</td>
<td className="py-2 pr-4 font-mono text-xs">{evt.ip}</td>
<td className="py-2 pr-4">
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${riskColor(evt.riskScore)}`}
>
{evt.riskScore}
</span>
{evt.riskFactors.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
{evt.riskFactors.join(', ')}
</span>
)}
</td>
<td className="py-2">
{evt.userId && evt.eventType === 'lockout' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleUnlock(evt.userId!)}
>
<Unlock className="mr-1 h-3 w-3" />
Unlock
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -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<string, string> {
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 (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">
<ShieldCheck className="h-3 w-3" />
Trusted
</span>
);
case 'remembered':
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
<ShieldCheck className="h-3 w-3" />
Remembered
</span>
);
default:
return (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
<ShieldOff className="h-3 w-3" />
Unknown
</span>
);
}
}
export default function DeviceManagementPage() {
const [devices, setDevices] = useState<Device[]>([]);
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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Device Management</h1>
<p className="text-muted-foreground">View and manage trusted devices for your account</p>
</div>
{devices.length > 1 && (
<Button variant="destructive" size="sm" onClick={handleRevokeAll}>
<Trash2 className="mr-2 h-4 w-4" />
Revoke all
</Button>
)}
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{devices.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No devices found
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{devices.map(device => {
const Icon = platformIcon(device.platform);
return (
<Card key={device.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<CardTitle className="text-base">
{device.name || 'Unknown Device'}
</CardTitle>
<CardDescription>
{device.platform} · {device.lastIp}
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
{trustBadge(device.trustLevel)}
<Button variant="ghost" size="sm" onClick={() => handleRevoke(device.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-6 text-xs text-muted-foreground">
<div>Last login: {new Date(device.lastLoginAt).toLocaleString()}</div>
<div>First seen: {new Date(device.createdAt).toLocaleString()}</div>
{device.trustExpiresAt && (
<div>Trust expires: {new Date(device.trustExpiresAt).toLocaleString()}</div>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@ -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<string, string> {
const t = getToken();
return t ? { Authorization: `Bearer ${t}` } : {};
}
export default function PasskeyManagementPage() {
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Passkeys</h1>
<p className="text-muted-foreground">
Sign in with biometrics or a security key no password needed
</p>
</div>
<Button onClick={handleRegister} disabled={registering}>
{registering ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Add passkey
</Button>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{passkeys.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Fingerprint className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">No passkeys registered yet</p>
<p className="text-xs text-muted-foreground mt-1">
Passkeys use biometrics (Face ID, Touch ID, Windows Hello) or a security key for
passwordless login.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{passkeys.map(pk => (
<Card key={pk.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
{pk.deviceType === 'platform' ? (
<Laptop className="h-5 w-5 text-muted-foreground" />
) : (
<Usb className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div>
<CardTitle className="text-base">{pk.friendlyName || 'Passkey'}</CardTitle>
<CardDescription>
{pk.deviceType === 'platform' ? 'Built-in authenticator' : 'Security key'}
{pk.backedUp && ' · Synced'}
</CardDescription>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => handleDelete(pk.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-6 text-xs text-muted-foreground">
<div>Created: {new Date(pk.createdAt).toLocaleDateString()}</div>
{pk.lastUsedAt && (
<div>Last used: {new Date(pk.lastUsedAt).toLocaleDateString()}</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
// ── 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(/=+$/, '');
}

View File

@ -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<string, string> {
const t = getToken();
return t ? { Authorization: `Bearer ${t}` } : {};
}
export default function SecuritySettingsPage() {
const [status, setStatus] = useState<MfaStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// TOTP setup flow
const [setupData, setSetupData] = useState<TotpSetupData | null>(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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Security Settings</h1>
<p className="text-muted-foreground">
Manage two-factor authentication and account security
</p>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{/* MFA Status Card */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
{status?.mfaEnabled ? (
<ShieldCheck className="h-6 w-6 text-green-600" />
) : (
<ShieldOff className="h-6 w-6 text-amber-500" />
)}
<div>
<CardTitle>Two-Factor Authentication</CardTitle>
<CardDescription>
{status?.mfaEnabled
? `Enabled — ${status.methods.join(', ')} · ${status.recoveryCodesRemaining} recovery codes remaining`
: 'Not enabled — add an extra layer of security to your account'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{!status?.mfaEnabled && !setupData && (
<Button onClick={handleSetupTotp} disabled={setupLoading}>
{setupLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<KeyRound className="mr-2 h-4 w-4" />
Set up authenticator app
</Button>
)}
{status?.mfaEnabled && (
<Button variant="destructive" onClick={handleDisableMfa} disabled={setupLoading}>
{setupLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Disable two-factor authentication
</Button>
)}
</CardContent>
</Card>
{/* TOTP Setup Flow */}
{setupData && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="h-5 w-5" />
Set up authenticator
</CardTitle>
<CardDescription>
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password,
etc.)
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* QR Code */}
<div className="flex justify-center">
{setupData.qrDataUrl ? (
<img
src={setupData.qrDataUrl}
alt="TOTP QR Code"
className="h-48 w-48 rounded-lg border"
/>
) : (
<div className="flex h-48 w-48 items-center justify-center rounded-lg border bg-muted text-xs text-muted-foreground">
QR code unavailable
</div>
)}
</div>
{/* Manual entry URI */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Manual entry key</Label>
<code className="block break-all rounded bg-muted p-2 text-xs">
{setupData.otpauthUri}
</code>
</div>
{/* Verify code */}
<div className="space-y-2">
<Label htmlFor="verify-code">Enter the 6-digit code from your app</Label>
<div className="flex gap-2">
<Input
id="verify-code"
type="text"
inputMode="numeric"
placeholder="000000"
value={verifyCode}
onChange={e => setVerifyCode(e.target.value)}
maxLength={6}
className="max-w-[160px] text-center font-mono text-lg tracking-widest"
/>
<Button
onClick={handleVerifySetup}
disabled={setupLoading || verifyCode.length < 6}
>
{setupLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Verify & Enable
</Button>
</div>
</div>
{/* Recovery codes */}
{setupData.recoveryCodes.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Recovery Codes</Label>
<Button variant="ghost" size="sm" onClick={copyRecoveryCodes}>
{copied ? (
<Check className="mr-1 h-3 w-3" />
) : (
<Copy className="mr-1 h-3 w-3" />
)}
{copied ? 'Copied' : 'Copy all'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Save these codes in a safe place. Each code can only be used once.
</p>
<div className="grid grid-cols-2 gap-1 rounded-lg border bg-muted p-3">
{setupData.recoveryCodes.map((code, i) => (
<code key={i} className="text-xs font-mono">
{code}
</code>
))}
</div>
</div>
)}
<Button
variant="ghost"
onClick={() => {
setSetupData(null);
setVerifyCode('');
}}
>
Cancel
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, unknown>;
}) => 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 (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="text-center">
<ShieldCheck className="mx-auto h-8 w-8 text-primary mb-2" />
<p className="text-sm text-muted-foreground">
{useRecovery ? 'Enter a recovery code' : 'Enter your authentication code'}
</p>
{mfaMethods.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">Methods: {mfaMethods.join(', ')}</p>
)}
</div>
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder={useRecovery ? 'Recovery code' : '000000'}
value={code}
onChange={e => setCode(e.target.value)}
required
maxLength={useRecovery ? 20 : 6}
className="text-center text-lg tracking-widest font-mono"
autoFocus
/>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={loading || code.length < (useRecovery ? 6 : 6)}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Verify
</Button>
<div className="flex justify-between text-xs">
<button type="button" onClick={onBack} className="text-muted-foreground underline">
Back to login
</button>
<button
type="button"
onClick={() => {
setUseRecovery(!useRecovery);
setCode('');
setError('');
}}
className="text-primary underline"
>
{useRecovery ? 'Use authenticator' : 'Use recovery code'}
</button>
</div>
</form>
);
}
// ── 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<string, unknown>).google as
| {
accounts: {
id: {
initialize: (config: Record<string, unknown>) => 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 (
<Button
type="button"
variant="outline"
className="w-full"
disabled={disabled || loading}
onClick={handleClick}
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)}
Sign in with Google
</Button>
);
}
// ── 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<string | null>(null);
const [mfaMethods, setMfaMethods] = useState<string[]>([]);
// 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<string, unknown>;
}) => {
// 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<string, unknown>) => {
if (data.mfaRequired) {
setMfaChallenge(data.mfaChallenge as string);
setMfaMethods((data.methods as string[]) || []);
setError('');
return;
}
handleAuthenticated(
data as { accessToken: string; refreshToken: string; user: Record<string, unknown> }
);
};
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 (
<div className="flex min-h-screen items-center justify-center bg-muted/40 p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-xl">Two-Factor Authentication</CardTitle>
<CardDescription>Additional verification required</CardDescription>
</CardHeader>
<CardContent>
<MfaChallengeView
mfaChallenge={mfaChallenge}
mfaMethods={mfaMethods}
onVerified={handleAuthenticated}
onBack={() => {
setMfaChallenge(null);
setMfaMethods([]);
}}
/>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/40 p-4">
<Card className="w-full max-w-sm">
@ -101,6 +404,20 @@ export default function LoginPage() {
Sign In
</Button>
{GOOGLE_CLIENT_ID && (
<>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
<GoogleSignInButton disabled={loading} onCredential={handleGoogleCredential} />
</>
)}
<p className="text-center text-xs text-muted-foreground">
Demo: admin@example.com / Admin123!
</p>

View File

@ -115,7 +115,17 @@ export function SidebarNav() {
{/* Navigation */}
<nav className="flex-1 space-y-1 px-3 py-4">
{navItems.map(item => {
const isActive = item.href === '/' ? pathname === '/' : pathname.startsWith(item.href);
const isActive =
item.href === '/'
? pathname === '/'
: pathname === item.href ||
(pathname.startsWith(item.href + '/') &&
!navItems.some(
other =>
other.href !== item.href &&
other.href.startsWith(item.href + '/') &&
pathname.startsWith(other.href)
));
return (
<Link
key={item.href}

View File

@ -391,7 +391,7 @@ class BLAuthClient(
private suspend fun socialLogin(provider: String, idToken: String): AuthUser {
_state.value = AuthState.Loading
val body = encodeMap(mapOf("idToken" to idToken))
val body = encodeMap(mapOf("idToken" to idToken, "productId" to config.productId))
val response = client.request("POST", "/api/auth/oauth/$provider", body, skipAuth = true)
// Check for MFA challenge
try {
@ -511,6 +511,39 @@ class BLAuthClient(
return client.json.decodeFromString<List<LoginEvent>>(response)
}
// ── Session restore ─────────────────────────────────────
/**
* Restore session from stored tokens. Call on app launch.
* Attempts to fetch current user; falls back to token refresh.
* Mirrors Swift BLAuthClient.restoreSession().
*/
suspend fun restoreSession() {
val token = getAccessToken()
if (token.isNullOrBlank()) {
_state.value = AuthState.LoggedOut
return
}
_state.value = AuthState.Loading
try {
val user = getMe()
_state.value = AuthState.LoggedIn(user)
} catch (_: Exception) {
// Access token may be expired — try refresh
val refreshed = refreshAccessToken()
if (refreshed) {
try {
val user = getMe()
_state.value = AuthState.LoggedIn(user)
} catch (_: Exception) {
_state.value = AuthState.LoggedOut
}
} else {
_state.value = AuthState.LoggedOut
}
}
}
// ── Private ──────────────────────────────────────────────
private fun handleAuthResult(result: TokenResponse) {

View File

@ -6,7 +6,6 @@ import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.PublicKeyCredential
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
@ -31,7 +30,7 @@ class BLPasskeyManager(
private val authClient: BLAuthClient,
) {
private val credentialManager = CredentialManager.create(context)
private val json = Json { ignoreUnknownKeys = true }
private val json get() = authClient.client.json
/**
* Register a new passkey for the current user.

View File

@ -206,6 +206,7 @@ public final class BLAuthClient {
let result = try JSONDecoder().decode(TokenResponse.self, from: data)
saveTokens(access: result.accessToken, refresh: result.refreshToken)
startRefreshTimer()
onAuthStateChanged?(.loggedIn(result.user))
return result.user
}
@ -221,6 +222,7 @@ public final class BLAuthClient {
let result = try JSONDecoder().decode(TokenResponse.self, from: data)
saveTokens(access: result.accessToken, refresh: result.refreshToken)
startRefreshTimer()
onAuthStateChanged?(.loggedIn(result.user))
return result.user
}
@ -311,7 +313,7 @@ public final class BLAuthClient {
/// Generic social login sends id_token to /auth/oauth/{provider}.
private func socialLogin(provider: String, idToken: String) async throws -> BLAuthUser {
let body: [String: String] = ["idToken": idToken]
let body: [String: String] = ["idToken": idToken, "productId": config.productId]
let (data, _) = try await client.rawRequest(
path: "/api/auth/oauth/\(provider)",
method: "POST",
@ -326,6 +328,7 @@ public final class BLAuthClient {
let result = try JSONDecoder().decode(TokenResponse.self, from: data)
saveTokens(access: result.accessToken, refresh: result.refreshToken)
startRefreshTimer()
onAuthStateChanged?(.loggedIn(result.user))
return result.user
}
@ -410,20 +413,13 @@ public final class BLAuthClient {
public func verifyPasskeyRegistration(attestation: [String: Any], friendlyName: String) async throws {
var payload = attestation
payload["friendlyName"] = friendlyName
let data = try JSONSerialization.data(withJSONObject: payload)
var request = URLRequest(url: URL(string: "\(config.baseURL)/api/auth/passkeys/register/verify")!)
request.httpMethod = "POST"
request.httpBody = data
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id")
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id")
if let token = accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw BLNetworkError.httpError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? 500, message: "Passkey registration failed")
}
let jsonData = try JSONSerialization.data(withJSONObject: payload)
// Wrap raw JSON data in a Codable struct for BLPlatformClient
_ = try await client.rawRequest(
path: "/api/auth/passkeys/register/verify",
method: "POST",
rawBody: jsonData
)
}
/// Get passkey authentication options from server.
@ -437,20 +433,16 @@ public final class BLAuthClient {
/// Verify passkey authentication with assertion response.
public func verifyPasskeyAuthentication(assertion: [String: Any]) async throws -> BLAuthUser {
let data = try JSONSerialization.data(withJSONObject: assertion)
var request = URLRequest(url: URL(string: "\(config.baseURL)/api/auth/passkeys/authenticate/verify")!)
request.httpMethod = "POST"
request.httpBody = data
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id")
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id")
let (responseData, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw BLNetworkError.httpError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? 500, message: "Passkey authentication failed")
}
let jsonData = try JSONSerialization.data(withJSONObject: assertion)
let (responseData, _) = try await client.rawRequest(
path: "/api/auth/passkeys/authenticate/verify",
method: "POST",
rawBody: jsonData
)
let result = try JSONDecoder().decode(TokenResponse.self, from: responseData)
saveTokens(access: result.accessToken, refresh: result.refreshToken)
startRefreshTimer()
onAuthStateChanged?(.loggedIn(result.user))
return result.user
}
@ -559,8 +551,8 @@ public final class BLAuthClient {
private func startRefreshTimer() {
stopRefreshTimer()
// Refresh every 45 minutes (tokens typically expire in 1 hour)
refreshTimer = Timer.scheduledTimer(withTimeInterval: 45 * 60, repeats: true) { [weak self] _ in
// Refresh every 12 minutes (access tokens expire in 15 minutes per PRD)
refreshTimer = Timer.scheduledTimer(withTimeInterval: 12 * 60, repeats: true) { [weak self] _ in
guard let self else { return }
Task { await self.refreshAccessToken() }
}

View File

@ -91,6 +91,47 @@ public final class BLPlatformClient: @unchecked Sendable {
return (data, http)
}
/// Perform an authenticated request with pre-encoded JSON body data.
/// Used by passkey methods that need to send raw JSON (e.g. from JSONSerialization).
public func rawRequest(
path: String,
method: String = "GET",
rawBody: Data? = nil
) async throws -> (Data, HTTPURLResponse) {
guard let url = URL(string: "\(config.baseURL)\(path)") else {
throw BLNetworkError.invalidURL(path)
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id")
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id")
if let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
request.httpBody = rawBody
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw BLNetworkError.invalidResponse
}
guard (200...299).contains(http.statusCode) else {
let message: String? = {
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let msg = json["message"] as? String { return msg }
return nil
}()
throw BLNetworkError.httpError(statusCode: http.statusCode, message: message)
}
return (data, http)
}
/// Fire-and-forget POST (used by telemetry errors silently ignored).
public func fireAndForget(path: String, body: Data) {
guard let url = URL(string: "\(config.baseURL)\(path)") else { return }