137 lines
5.5 KiB
TypeScript
137 lines
5.5 KiB
TypeScript
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)} · 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>
|
|
);
|
|
}
|