step-up-prompt.tsx112 lines · main
1'use client';
2
3import { useState } from 'react';
4
5interface Props {
6 apiOrigin: string;
7 /** Copy describing what action will run after a successful step-up. */
8 reason: string;
9 /** Called after /v1/me/step-up returns 200 — the caller retries the gated action here. */
10 onSuccess: () => void | Promise<void>;
11 onCancel: () => void;
12}
13
14/**
15 * Re-auth prompt for actions gated by `requireRecentMfa` on the api.
16 *
17 * Flow: user clicks a destructive button → API returns 403 with
18 * code='step_up_required' → caller mounts this component → user types
19 * their password → POST /v1/me/step-up bumps users.last_mfa_at → on
20 * success, caller's onSuccess re-runs the original action which the
21 * api now accepts.
22 *
23 * Keep this dialog-shaped (not a separate route): the action context
24 * stays in view, and the caller doesn't have to thread state through
25 * navigation. The api enforces the actual security; this is just the
26 * affordance that lets the user complete it.
27 */
28export function StepUpPrompt({ apiOrigin, reason, onSuccess, onCancel }: Props) {
29 const [password, setPassword] = useState('');
30 const [error, setError] = useState<string | null>(null);
31 const [busy, setBusy] = useState(false);
32
33 async function submit(e: React.FormEvent) {
34 e.preventDefault();
35 setBusy(true);
36 setError(null);
37 try {
38 const res = await fetch(`${apiOrigin}/v1/me/step-up`, {
39 method: 'POST',
40 credentials: 'include',
41 headers: { 'content-type': 'application/json' },
42 body: JSON.stringify({ password }),
43 });
44 if (res.status === 401) {
45 setError('password incorrect');
46 return;
47 }
48 if (!res.ok) {
49 const body = await res.text().catch(() => '');
50 throw new Error(body || `step-up failed: ${res.status}`);
51 }
52 // Auth bumped; let the caller retry. The caller decides whether
53 // to clear our state or unmount us — we don't navigate ourselves.
54 await onSuccess();
55 } catch (err) {
56 setError(err instanceof Error ? err.message : 'step-up failed');
57 } finally {
58 setBusy(false);
59 }
60 }
61
62 return (
63 <div
64 role="dialog"
65 aria-modal="true"
66 aria-label="confirm with password"
67 className="mt-3 flex flex-col gap-3 rounded-md border border-[var(--color-warning)] bg-[var(--color-surface)] p-4 font-mono text-xs"
68 >
69 <p className="text-[var(--color-text)]">
70 confirm with your password to continue.
71 </p>
72 <p className="text-[var(--color-text-muted)]">{reason}</p>
73 <form onSubmit={submit} className="flex flex-col gap-2">
74 <input
75 autoFocus
76 type="password"
77 autoComplete="current-password"
78 value={password}
79 onChange={(e) => setPassword(e.target.value)}
80 required
81 minLength={1}
82 disabled={busy}
83 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
84 />
85 <div className="flex flex-wrap items-center gap-2">
86 <button
87 type="submit"
88 disabled={busy || password.length === 0}
89 className="inline-flex h-8 items-center justify-center rounded-md bg-[var(--color-primary)] px-3 font-sans text-xs text-[var(--color-text-inverse)] disabled:opacity-50"
90 >
91 {busy ? 'verifying…' : 'confirm'}
92 </button>
93 <button
94 type="button"
95 onClick={onCancel}
96 disabled={busy}
97 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
98 >
99 cancel
100 </button>
101 {error ? (
102 <span className="font-mono text-[10px] text-[var(--color-error)]">{error}</span>
103 ) : null}
104 </div>
105 </form>
106 <p className="font-mono text-[10px] text-[var(--color-text-subtle)]">
107 per CLAUDE.md §5.4, destructive actions require a fresh password attestation within 10
108 minutes. you won&apos;t be re-prompted for the rest of that window.
109 </p>
110 </div>
111 );
112}