step-up-card.tsx147 lines · main
1'use client';
2
3import { motion } from 'motion/react';
4import { useCallback, useEffect, useState } from 'react';
5
6import { StepUpPrompt } from '@/components/step-up-prompt';
7
8interface LaunchStatusLite {
9 stepUpFresh: boolean;
10 stepUpExpiresAt: string | null;
11}
12
13/**
14 * Step-up freshness card. Admin mutations (kill-switches, key issuing,
15 * maintenance mode…) require a password re-entry inside a 10-minute window
16 * — this card shows whether that window is currently open, and lets the
17 * operator re-attest ahead of time so the next mutation doesn't interrupt
18 * them with a prompt.
19 *
20 * Polls the existing /v1/admin/launch-status endpoint (which carries
21 * stepUpFresh + stepUpExpiresAt) every 30s — same source of truth the old
22 * dashboard admin pill used. Honest states only: fresh, expired, or
23 * "couldn't reach the api".
24 */
25export function StepUpCard({ apiOrigin }: { apiOrigin: string }) {
26 const [status, setStatus] = useState<LaunchStatusLite | null>(null);
27 const [failed, setFailed] = useState(false);
28 const [prompting, setPrompting] = useState(false);
29 // Ticks each poll so minutes-left re-derives even without new data.
30 const [now, setNow] = useState(() => Date.now());
31
32 const refresh = useCallback(async () => {
33 try {
34 const res = await fetch(`${apiOrigin}/v1/admin/launch-status`, {
35 credentials: 'include',
36 headers: { accept: 'application/json' },
37 });
38 if (!res.ok) throw new Error(`launch-status failed: ${res.status}`);
39 const json = (await res.json()) as LaunchStatusLite;
40 setStatus(json);
41 setFailed(false);
42 } catch {
43 setFailed(true);
44 } finally {
45 setNow(Date.now());
46 }
47 }, [apiOrigin]);
48
49 useEffect(() => {
50 void refresh();
51 const handle = setInterval(() => {
52 if (document.visibilityState === 'visible') void refresh();
53 }, 30_000);
54 return () => clearInterval(handle);
55 }, [refresh]);
56
57 const minutesLeft =
58 status?.stepUpExpiresAt != null
59 ? Math.max(0, Math.floor((new Date(status.stepUpExpiresAt).getTime() - now) / 60_000))
60 : null;
61 const fresh = Boolean(status?.stepUpFresh && minutesLeft !== null && minutesLeft > 0);
62
63 return (
64 <motion.div
65 initial={{ opacity: 0, y: 8 }}
66 animate={{ opacity: 1, y: 0 }}
67 transition={{ duration: 0.4, ease: 'easeOut' }}
68 className="flex flex-col gap-6 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 sm:p-8"
69 >
70 <div className="flex flex-wrap items-center justify-between gap-6">
71 <div className="flex items-start gap-5">
72 <span className="mt-2.5">
73 <PulseDot fresh={fresh} unknown={status === null} />
74 </span>
75 <div className="flex flex-col gap-1.5">
76 <span className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
77 step-up auth
78 </span>
79 <span
80 className={`font-mono text-3xl tracking-tight ${
81 status === null
82 ? 'text-[var(--color-text-subtle)]'
83 : fresh
84 ? 'text-[var(--color-success)]'
85 : 'text-[var(--color-warning)]'
86 }`}
87 >
88 {status === null
89 ? failed
90 ? '—'
91 : 'checking…'
92 : fresh
93 ? `fresh · ${minutesLeft}m left`
94 : 'expired'}
95 </span>
96 <span className="max-w-prose font-mono text-xs leading-relaxed text-[var(--color-text-muted)]">
97 {status === null && failed
98 ? "couldn't reach the api to check — the state shows as soon as it answers."
99 : 'sensitive admin actions (kill-switches, key issuing, maintenance mode) ask for your password again and stay unlocked for 10 minutes. re-attest here to open that window before you start flipping switches.'}
100 </span>
101 </div>
102 </div>
103
104 {!prompting && status !== null && !fresh ? (
105 <button
106 type="button"
107 onClick={() => setPrompting(true)}
108 className="rounded-md border border-[var(--color-warning)] px-5 py-2.5 font-mono text-xs text-[var(--color-warning)] transition hover:bg-[var(--color-warning)] hover:text-[var(--color-text-inverse)]"
109 >
110 re-attest now
111 </button>
112 ) : null}
113 </div>
114
115 {prompting ? (
116 <StepUpPrompt
117 apiOrigin={apiOrigin}
118 reason="re-attesting opens the 10-minute window during which sensitive admin actions run without extra prompts."
119 onSuccess={async () => {
120 setPrompting(false);
121 await refresh();
122 }}
123 onCancel={() => setPrompting(false)}
124 />
125 ) : null}
126 </motion.div>
127 );
128}
129
130/** Green pulse while fresh, static amber when expired, muted while unknown. */
131function PulseDot({ fresh, unknown }: { fresh: boolean; unknown: boolean }) {
132 const color = unknown
133 ? 'bg-[var(--color-text-subtle)]'
134 : fresh
135 ? 'bg-[var(--color-success)]'
136 : 'bg-[var(--color-warning)]';
137 return (
138 <span className="relative flex h-3.5 w-3.5 shrink-0" aria-hidden>
139 {fresh ? (
140 <span
141 className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${color}`}
142 />
143 ) : null}
144 <span className={`relative inline-flex h-3.5 w-3.5 rounded-full ${color}`} />
145 </span>
146 );
147}