From e563631efbcb8523be0f721862f21983dd8aa5f0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:53:05 +0200 Subject: [PATCH] feat: extract shared account components (Profile, Password, MFA, Passkey) Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/account/MfaSection.tsx | 259 ++++++++++++++++++ ui/src/components/account/PasskeySection.tsx | 136 +++++++++ .../account/PasswordChangeSection.tsx | 88 ++++++ ui/src/components/account/ProfileSection.tsx | 82 ++++++ 4 files changed, 565 insertions(+) create mode 100644 ui/src/components/account/MfaSection.tsx create mode 100644 ui/src/components/account/PasskeySection.tsx create mode 100644 ui/src/components/account/PasswordChangeSection.tsx create mode 100644 ui/src/components/account/ProfileSection.tsx diff --git a/ui/src/components/account/MfaSection.tsx b/ui/src/components/account/MfaSection.tsx new file mode 100644 index 0000000..fb50592 --- /dev/null +++ b/ui/src/components/account/MfaSection.tsx @@ -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(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 ( + +
+ +
+
+ ); + } + + // Backup codes display + if (codes) { + return ( + + + 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. + +
+ {codes.map((code) => ( + {code} + ))} +
+
+ + +
+ +
+ +
+
+ ); + } + + // Setup flow — QR code + verification + if (setupData) { + return ( + +

+ Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below. +

+
+ +
+
+ {setupData.secret} +
+
+ + setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="Enter 6-digit code" + required + autoComplete="one-time-code" + /> + +
+ + +
+
+
+ ); + } + + // Main view — enrolled or not + return ( + +
+ Status: + {mfaStatus?.enrolled ? ( + + ) : ( + + )} +
+ {mfaStatus?.enrolled ? ( + <> +

+ Your account is protected with a TOTP authenticator app. +

+
+ + {confirmRemove ? ( +
+ + + +
+ ) : ( + + )} +
+ + ) : ( + <> +

+ Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app. +

+
+ +
+ + )} +
+ ); +} diff --git a/ui/src/components/account/PasskeySection.tsx b/ui/src/components/account/PasskeySection.tsx new file mode 100644 index 0000000..2c16441 --- /dev/null +++ b/ui/src/components/account/PasskeySection.tsx @@ -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 ( + +

+ Use your fingerprint, face, or security key instead of typing a code every time. +

+ +
+ ); +} + +export function PasskeySection() { + const { toast } = useToast(); + const { data: passkeys, isLoading } = useAccountPasskeyList(); + const renamePasskey = useAccountRenamePasskey(); + const deletePasskey = useAccountDeletePasskey(); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [confirmDeleteId, setConfirmDeleteId] = useState(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 ( + +

+ Use your fingerprint, face, or security key to sign in faster. +

+ {credentials.length === 0 ? ( +

+ No passkeys registered. Passkeys can be registered during sign-in when prompted. +

+ ) : ( +
+ {credentials.map((pk) => ( +
+
+ {editingId === pk.id ? ( +
+ setEditName(e.target.value)} placeholder="Passkey name" style={{ maxWidth: 200 }} /> + + +
+ ) : ( + <> +
{pk.name || 'Unnamed passkey'}
+
+ {parseAgent(pk.agent)} · Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'} +
+ + )} +
+ {editingId !== pk.id && ( +
+ + {confirmDeleteId === pk.id ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/ui/src/components/account/PasswordChangeSection.tsx b/ui/src/components/account/PasswordChangeSection.tsx new file mode 100644 index 0000000..622bf21 --- /dev/null +++ b/ui/src/components/account/PasswordChangeSection.tsx @@ -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 ( + +

+ Update your login password. Minimum 8 characters. +

+
+ + setCurrentPassword(e.target.value)} + placeholder="Enter current password" + required + /> + + + setNewPassword(e.target.value)} + placeholder="Enter new password" + required + minLength={8} + /> + + + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + required + minLength={8} + /> + +
+ +
+
+
+ ); +} diff --git a/ui/src/components/account/ProfileSection.tsx b/ui/src/components/account/ProfileSection.tsx new file mode 100644 index 0000000..a354602 --- /dev/null +++ b/ui/src/components/account/ProfileSection.tsx @@ -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 ( + +
+ +
+
+ ); + } + + return ( + +
+ + + + + setName(e.target.value)} + placeholder="Enter display name" + /> + +
+ +
+
+
+ ); +}