feat: extract shared account components (Profile, Password, MFA, Passkey)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 14:53:05 +02:00
parent bf42f13afc
commit e563631efb
4 changed files with 565 additions and 0 deletions

View File

@@ -0,0 +1,259 @@
import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { errorMessage } from '../../api/client';
import {
Alert,
Badge,
Button,
Card,
FormField,
Input,
Spinner,
useToast,
} from '@cameleer/design-system';
import {
useAccountMfaStatus,
useAccountMfaSetup,
useAccountMfaVerify,
useAccountBackupCodes,
useAccountMfaRemove,
} from '../../api/account-hooks';
import styles from '../../styles/platform.module.css';
export function MfaSection() {
const { toast } = useToast();
const { data: mfaStatus, isLoading: statusLoading } = useAccountMfaStatus();
const setup = useAccountMfaSetup();
const verify = useAccountMfaVerify();
const backupCodes = useAccountBackupCodes();
const remove = useAccountMfaRemove();
const [setupData, setSetupData] = useState<{ secret: string; secretQrCode: string } | null>(null);
const [verifyCode, setVerifyCode] = useState('');
const [codes, setCodes] = useState<string[] | null>(null);
const [codesSaved, setCodesSaved] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
async function handleStartSetup() {
try {
const data = await setup.mutateAsync();
setSetupData(data);
setVerifyCode('');
} catch (err) {
toast({ title: 'Failed to start MFA setup', description: errorMessage(err), variant: 'error' });
}
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault();
if (!setupData) return;
try {
const result = await verify.mutateAsync({ secret: setupData.secret, code: verifyCode });
if (result.verified) {
const bc = await backupCodes.mutateAsync();
setCodes(bc.codes);
setSetupData(null);
setVerifyCode('');
setCodesSaved(false);
toast({ title: 'MFA enabled successfully', variant: 'success' });
} else {
toast({ title: 'Invalid code. Please try again.', variant: 'error' });
}
} catch (err) {
toast({ title: 'Verification failed', description: errorMessage(err), variant: 'error' });
}
}
async function handleRegenerateCodes() {
try {
const bc = await backupCodes.mutateAsync();
setCodes(bc.codes);
setCodesSaved(false);
toast({ title: 'Backup codes regenerated', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to regenerate backup codes', description: errorMessage(err), variant: 'error' });
}
}
async function handleRemove() {
try {
await remove.mutateAsync();
setConfirmRemove(false);
setCodes(null);
setSetupData(null);
toast({ title: 'MFA removed', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to remove MFA', description: errorMessage(err), variant: 'error' });
}
}
function handleCopyAll() {
if (!codes) return;
navigator.clipboard.writeText(codes.join('\n'));
toast({ title: 'Backup codes copied to clipboard', variant: 'success' });
}
function handleDownload() {
if (!codes) return;
const blob = new Blob([codes.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'cameleer-mfa-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
}
if (statusLoading) {
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spinner />
</div>
</Card>
);
}
// Backup codes display
if (codes) {
return (
<Card title="Multi-Factor Authentication">
<Alert variant="warning" title="Save your backup codes">
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
</Alert>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 24px',
marginTop: 16,
padding: '16px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.875rem',
}}>
{codes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<div style={{ marginTop: 12 }}>
<Button variant="primary" disabled={!codesSaved} onClick={() => setCodes(null)}>
Done
</Button>
</div>
</Card>
);
}
// Setup flow — QR code + verification
if (setupData) {
return (
<Card title="Multi-Factor Authentication">
<p className={styles.description} style={{ marginTop: 0 }}>
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
</p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<QRCodeSVG value={setupData.secretQrCode} size={200} />
</div>
<div style={{
textAlign: 'center',
padding: '8px 12px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.75rem',
wordBreak: 'break-all',
marginBottom: 16,
}}>
{setupData.secret}
</div>
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Verification code" htmlFor="mfa-code">
<Input
id="mfa-code"
type="text"
inputMode="numeric"
maxLength={6}
pattern="[0-9]{6}"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
required
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={() => { setSetupData(null); setVerifyCode(''); }}>
Cancel
</Button>
</div>
</form>
</Card>
);
}
// Main view — enrolled or not
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
{mfaStatus?.enrolled ? (
<Badge label="Enrolled" color="success" />
) : (
<Badge label="Not enrolled" color="auto" />
)}
</div>
{mfaStatus?.enrolled ? (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Your account is protected with a TOTP authenticator app.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
Regenerate backup codes
</Button>
{confirmRemove ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Alert variant="error" title="This will disable MFA on your account." />
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
Confirm removal
</Button>
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
Remove MFA
</Button>
)}
</div>
</>
) : (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
</p>
<div>
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
Set up authenticator app
</Button>
</div>
</>
)}
</Card>
);
}

View File

@@ -0,0 +1,136 @@
import { useState } from 'react';
import { errorMessage } from '../../api/client';
import {
Alert,
Button,
Card,
Input,
useToast,
} from '@cameleer/design-system';
import {
useAccountMfaStatus,
useAccountPasskeyList,
useAccountRenamePasskey,
useAccountDeletePasskey,
} from '../../api/account-hooks';
import styles from '../../styles/platform.module.css';
export function PasskeyNudgeBanner() {
const { data: status } = useAccountMfaStatus();
const [dismissed, setDismissed] = useState(false);
const lastDismissed = localStorage.getItem('passkey_nudge_dismissed');
const recentlyDismissed = lastDismissed && (Date.now() - Number(lastDismissed)) < 30 * 24 * 60 * 60 * 1000;
if (dismissed || recentlyDismissed || !status || status.passkeyEnrolled) return null;
function handleDismiss() {
localStorage.setItem('passkey_nudge_dismissed', String(Date.now()));
setDismissed(true);
}
return (
<Alert variant="info" title="Sign in faster with a passkey">
<p style={{ margin: '4px 0 12px' }}>
Use your fingerprint, face, or security key instead of typing a code every time.
</p>
<Button size="sm" variant="secondary" onClick={handleDismiss}>Not now</Button>
</Alert>
);
}
export function PasskeySection() {
const { toast } = useToast();
const { data: passkeys, isLoading } = useAccountPasskeyList();
const renamePasskey = useAccountRenamePasskey();
const deletePasskey = useAccountDeletePasskey();
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
function parseAgent(agent: string | null): string {
if (!agent) return 'Unknown device';
if (agent.includes('Chrome')) return agent.includes('Windows') ? 'Chrome on Windows' : agent.includes('Mac') ? 'Chrome on macOS' : agent.includes('Android') ? 'Chrome on Android' : 'Chrome';
if (agent.includes('Safari') && !agent.includes('Chrome')) return agent.includes('iPhone') ? 'Safari on iPhone' : 'Safari on macOS';
if (agent.includes('Firefox')) return 'Firefox';
if (agent.includes('Edge')) return 'Edge';
return 'Browser';
}
function startRename(id: string, currentName: string | null) {
setEditingId(id);
setEditName(currentName ?? '');
}
async function handleRename(id: string) {
try {
await renamePasskey.mutateAsync({ id, name: editName });
setEditingId(null);
toast({ title: 'Passkey renamed', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to rename passkey', description: errorMessage(err), variant: 'error' });
}
}
async function handleDelete(id: string) {
try {
await deletePasskey.mutateAsync(id);
setConfirmDeleteId(null);
toast({ title: 'Passkey removed', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to remove passkey', description: errorMessage(err), variant: 'error' });
}
}
if (isLoading) return null;
const credentials = passkeys ?? [];
return (
<Card title="Passkeys">
<p className={styles.description} style={{ marginTop: 0 }}>
Use your fingerprint, face, or security key to sign in faster.
</p>
{credentials.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
No passkeys registered. Passkeys can be registered during sign-in when prompted.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{credentials.map((pk) => (
<div key={pk.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
<div style={{ flex: 1 }}>
{editingId === pk.id ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} placeholder="Passkey name" style={{ maxWidth: 200 }} />
<Button size="sm" variant="primary" onClick={() => handleRename(pk.id)} loading={renamePasskey.isPending}>Save</Button>
<Button size="sm" variant="secondary" onClick={() => setEditingId(null)}>Cancel</Button>
</div>
) : (
<>
<div style={{ fontWeight: 500 }}>{pk.name || 'Unnamed passkey'}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{parseAgent(pk.agent)} &middot; Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'}
</div>
</>
)}
</div>
{editingId !== pk.id && (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" variant="secondary" onClick={() => startRename(pk.id, pk.name)}>Rename</Button>
{confirmDeleteId === pk.id ? (
<>
<Button size="sm" variant="danger" onClick={() => handleDelete(pk.id)} loading={deletePasskey.isPending}>Confirm</Button>
<Button size="sm" variant="secondary" onClick={() => setConfirmDeleteId(null)}>Cancel</Button>
</>
) : (
<Button size="sm" variant="danger" onClick={() => setConfirmDeleteId(pk.id)}>Remove</Button>
)}
</div>
)}
</div>
))}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
import { errorMessage } from '../../api/client';
import {
Button,
Card,
FormField,
Input,
useToast,
} from '@cameleer/design-system';
import { useChangePassword } from '../../api/account-hooks';
import styles from '../../styles/platform.module.css';
export function PasswordChangeSection() {
const { toast } = useToast();
const changePassword = useChangePassword();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
if (newPassword.length < 8) {
toast({ title: 'Password must be at least 8 characters', variant: 'error' });
return;
}
if (newPassword !== confirmPassword) {
toast({ title: 'Passwords do not match', variant: 'error' });
return;
}
try {
await changePassword.mutateAsync({ currentPassword, newPassword });
toast({ title: 'Password changed successfully', variant: 'success' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err) {
toast({ title: 'Failed to change password', description: errorMessage(err), variant: 'error' });
}
}
return (
<Card title="Change Password">
<p className={styles.description} style={{ marginTop: 0 }}>
Update your login password. Minimum 8 characters.
</p>
<form onSubmit={handleChangePassword} style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
<FormField label="Current password" htmlFor="current-pw">
<Input
id="current-pw"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
required
/>
</FormField>
<FormField label="New password" htmlFor="new-pw">
<Input
id="new-pw"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
required
minLength={8}
/>
</FormField>
<FormField label="Confirm password" htmlFor="confirm-pw">
<Input
id="confirm-pw"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
required
minLength={8}
/>
</FormField>
<div>
<Button type="submit" variant="primary" loading={changePassword.isPending}>
Change Password
</Button>
</div>
</form>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
import { useState, useEffect } from 'react';
import { errorMessage } from '../../api/client';
import {
Button,
Card,
FormField,
Input,
Spinner,
useToast,
} from '@cameleer/design-system';
import { useAccountProfile, useUpdateDisplayName } from '../../api/account-hooks';
export function ProfileSection() {
const { toast } = useToast();
const { data: profile, isLoading } = useAccountProfile();
const updateDisplayName = useUpdateDisplayName();
const [name, setName] = useState('');
useEffect(() => {
if (profile) {
setName(profile.name ?? '');
}
}, [profile]);
const isDirty = profile ? name !== (profile.name ?? '') : false;
async function handleSave(e: React.FormEvent) {
e.preventDefault();
try {
await updateDisplayName.mutateAsync(name);
toast({ title: 'Display name updated', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update display name', description: errorMessage(err), variant: 'error' });
}
}
if (isLoading) {
return (
<Card title="Profile">
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spinner />
</div>
</Card>
);
}
return (
<Card title="Profile">
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Email" htmlFor="profile-email">
<Input
id="profile-email"
type="email"
value={profile?.email ?? ''}
readOnly
disabled
/>
</FormField>
<FormField label="Display name" htmlFor="profile-name">
<Input
id="profile-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter display name"
/>
</FormField>
<div>
<Button
type="submit"
variant="primary"
loading={updateDisplayName.isPending}
disabled={!isDirty}
>
Save
</Button>
</div>
</form>
</Card>
);
}