rotate-secret-button.tsx120 lines · main
1'use client';
2
3import { useState } from 'react';
4
5interface Props {
6 projectId: string;
7 endpointId: string;
8 endpointName: string;
9 apiOrigin: string;
10}
11
12export function RotateSecretButton({ projectId, endpointId, endpointName, apiOrigin }: Props) {
13 const [phase, setPhase] = useState<'idle' | 'confirming' | 'rotating' | 'revealed' | 'error'>(
14 'idle',
15 );
16 const [secret, setSecret] = useState<string | null>(null);
17 const [error, setError] = useState<string | null>(null);
18 const [revealed, setRevealed] = useState(false);
19
20 async function rotate() {
21 setPhase('rotating');
22 setError(null);
23 try {
24 const res = await fetch(
25 `${apiOrigin}/v1/projects/${projectId}/webhooks/${endpointId}/rotate-secret`,
26 { method: 'POST', credentials: 'include' },
27 );
28 if (!res.ok) {
29 const body = await res.text().catch(() => '');
30 throw new Error(body || `rotate failed: ${res.status}`);
31 }
32 const json = (await res.json()) as { plaintextSecret: string };
33 setSecret(json.plaintextSecret);
34 setPhase('revealed');
35 } catch (err) {
36 setError(err instanceof Error ? err.message : 'rotate failed');
37 setPhase('error');
38 }
39 }
40
41 if (phase === 'revealed' && secret) {
42 return (
43 <div className="flex flex-col gap-2 rounded-md border border-[var(--color-primary)] bg-[var(--color-primary-subtle)] p-3 md:col-span-2">
44 <p className="font-mono text-xs text-[var(--color-primary)]">
45 new signing secret for &quot;{endpointName}&quot; — copy now, shown once
46 </p>
47 <div className="flex items-center gap-2">
48 <code className="flex-1 truncate rounded-md bg-[var(--color-code-bg)] px-3 py-2 font-mono text-xs text-[var(--color-code-text)]">
49 {revealed ? secret : '•'.repeat(48)}
50 </code>
51 <button
52 type="button"
53 onClick={() => setRevealed((r) => !r)}
54 className="rounded-md border border-[var(--color-border-subtle)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
55 >
56 {revealed ? 'hide' : 'show'}
57 </button>
58 <button
59 type="button"
60 onClick={() => void navigator.clipboard.writeText(secret)}
61 className="rounded-md border border-[var(--color-border-subtle)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
62 >
63 copy
64 </button>
65 </div>
66 <button
67 type="button"
68 onClick={() => {
69 setSecret(null);
70 setPhase('idle');
71 setRevealed(false);
72 }}
73 className="self-start font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
74 >
75 dismiss
76 </button>
77 </div>
78 );
79 }
80
81 if (phase === 'confirming') {
82 return (
83 <div className="flex items-center gap-2">
84 <span className="font-mono text-[10px] text-[var(--color-warning)]">
85 rotating invalidates the old secret immediately —
86 </span>
87 <button
88 type="button"
89 onClick={rotate}
90 className="rounded-md border border-[var(--color-warning)] px-2 py-1 font-mono text-[10px] text-[var(--color-warning)] hover:bg-[var(--color-warning)] hover:text-[var(--color-text-inverse)]"
91 >
92 rotate now
93 </button>
94 <button
95 type="button"
96 onClick={() => setPhase('idle')}
97 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
98 >
99 cancel
100 </button>
101 </div>
102 );
103 }
104
105 return (
106 <>
107 <button
108 type="button"
109 disabled={phase === 'rotating'}
110 onClick={() => setPhase('confirming')}
111 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)] disabled:opacity-50"
112 >
113 {phase === 'rotating' ? 'rotating…' : 'rotate secret'}
114 </button>
115 {phase === 'error' && error ? (
116 <span className="font-mono text-[10px] text-[var(--color-error)]">{error}</span>
117 ) : null}
118 </>
119 );
120}