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