Skip to content
File

Blob: src/client/components/ui/secret-reveal-card.tsx

typescript90 lines
1import { Check, Copy } from "lucide-react";
2import { useEffect, useState } from "react";
3 
4import { Button } from "./button";
5import { Card } from "./card";
6 
7interface SecretLine {
8 label: string;
9 value: string;
10}
11 
12interface SecretRevealCardProps {
13 // Eyebrow text — typically "<thing> — copy now, this is the only time it's shown".
14 eyebrow: string;
15 // Lines to render as <code> blocks; the last is the one the Copy button copies.
16 lines: SecretLine[];
17 // Label for the Copy button. Defaults to copying the final line's value.
18 copyLabel?: string;
19 onDismiss?: () => void;
20}
21 
22// Ceremonial reveal for a one-time secret (client_secret rotation, freshly
23// minted invite URL). Single primitive so the visual + Copy behaviour stays
24// in lockstep across admin/clients and admin/invites.
25export const SecretRevealCard = ({ eyebrow, lines, copyLabel = "Copy", onDismiss }: SecretRevealCardProps) => {
26 const [copied, setCopied] = useState(false);
27 const [copyError, setCopyError] = useState<string | null>(null);
28 const target = lines[lines.length - 1]?.value ?? "";
29 
30 useEffect(() => {
31 if (!copied) return;
32 const t = window.setTimeout(() => setCopied(false), 1200);
33 return () => window.clearTimeout(t);
34 }, [copied]);
35 
36 const handleCopy = async () => {
37 setCopyError(null);
38 try {
39 await navigator.clipboard.writeText(target);
40 setCopied(true);
41 } catch {
42 // Clipboard API can reject on permission denial, focus loss, or
43 // when the page is not in a secure context. The secret is the
44 // ONLY-shown-once value here, so a silent failure would lose it
45 // when the admin clicks Dismiss assuming the value is in the
46 // clipboard. Surface the failure and tell the user how to copy
47 // manually.
48 setCopyError("Copy failed. Select the value above and use Ctrl/Cmd+C.");
49 }
50 };
51 
52 return (
53 <Card variant="accent">
54 <p className="mb-2 text-xs font-medium uppercase tracking-widest text-accent-400">{eyebrow}</p>
55 <div className="flex flex-col gap-2">
56 {lines.map((line) => (
57 <code key={line.label} className="break-all rounded bg-zinc-900/80 px-3 py-2 text-xs text-zinc-300">
58 {line.label}: {line.value}
59 </code>
60 ))}
61 <div className="flex gap-2">
62 <Button variant="secondary" size="sm" onClick={handleCopy} aria-live="polite">
63 {copied ? (
64 <>
65 <Check className="h-3.5 w-3.5" aria-hidden="true" />
66 Copied
67 </>
68 ) : (
69 <>
70 <Copy className="h-3.5 w-3.5" aria-hidden="true" />
71 {copyLabel}
72 </>
73 )}
74 </Button>
75 {onDismiss ? (
76 <Button variant="ghost" size="sm" onClick={onDismiss}>
77 Dismiss
78 </Button>
79 ) : null}
80 </div>
81 {copyError ? (
82 <p role="alert" className="text-xs text-red-400">
83 {copyError}
84 </p>
85 ) : null}
86 </div>
87 </Card>
88 );
89};