new-key-dialog.tsx131 lines · main
1'use client';
2
3import { useState, type FormEvent } from 'react';
4
5import { CopyField } from '../../../../../../components/copy-field';
6
7interface Props {
8 action: (formData: FormData) => Promise<{ plaintext: string }>;
9}
10
11export function NewKeyDialog({ action }: Props) {
12 const [open, setOpen] = useState(false);
13 const [pending, setPending] = useState(false);
14 const [plaintext, setPlaintext] = useState<string | null>(null);
15 const [error, setError] = useState<string | null>(null);
16
17 async function onSubmit(e: FormEvent<HTMLFormElement>) {
18 e.preventDefault();
19 setPending(true);
20 setError(null);
21 try {
22 const result = await action(new FormData(e.currentTarget));
23 setPlaintext(result.plaintext);
24 } catch (err) {
25 setError(err instanceof Error ? err.message : 'create failed');
26 } finally {
27 setPending(false);
28 }
29 }
30
31 function close() {
32 setOpen(false);
33 setPlaintext(null);
34 setError(null);
35 }
36
37 return (
38 <>
39 <button
40 type="button"
41 onClick={() => setOpen(true)}
42 className="rounded-md bg-[var(--color-primary)] px-3 py-2 font-mono text-xs font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)]"
43 >
44 new key
45 </button>
46
47 {open ? (
48 <div
49 className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6"
50 onClick={close}
51 >
52 <div
53 className="w-full max-w-md rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-6"
54 onClick={(e) => e.stopPropagation()}
55 >
56 {plaintext ? (
57 <div>
58 <h3 className="font-mono text-sm">copy this key now</h3>
59 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
60 this is the only time the plaintext will be shown. store it in a secret manager.
61 </p>
62 <div className="mt-4">
63 <CopyField value={plaintext} label="api key plaintext" />
64 </div>
65 <div className="mt-4 flex justify-end">
66 <button
67 type="button"
68 onClick={close}
69 className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-xs font-medium text-[var(--color-text-inverse)]"
70 >
71 done
72 </button>
73 </div>
74 </div>
75 ) : (
76 <form onSubmit={onSubmit} className="flex flex-col gap-4" aria-busy={pending}>
77 <h3 className="font-mono text-sm">new api key</h3>
78 <label className="flex flex-col gap-2">
79 <span className="font-mono text-xs text-[var(--color-text-muted)]">name</span>
80 <input
81 name="name"
82 required
83 maxLength={80}
84 placeholder="ci/cd"
85 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
86 />
87 </label>
88 <label className="flex flex-col gap-2">
89 <span className="font-mono text-xs text-[var(--color-text-muted)]">expires</span>
90 <select
91 name="expiresInDays"
92 defaultValue="never"
93 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm"
94 >
95 <option value="never">never</option>
96 <option value="30">30 days</option>
97 <option value="90">90 days</option>
98 <option value="365">1 year</option>
99 </select>
100 </label>
101
102 {error ? (
103 <p role="alert" className="font-mono text-xs text-red-400">
104 {error}
105 </p>
106 ) : null}
107
108 <div className="flex justify-end gap-2">
109 <button
110 type="button"
111 onClick={close}
112 className="rounded-md border border-[var(--color-border)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)]"
113 >
114 cancel
115 </button>
116 <button
117 type="submit"
118 disabled={pending}
119 className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-xs font-medium text-[var(--color-text-inverse)] disabled:opacity-50"
120 >
121 {pending ? 'creating...' : 'create'}
122 </button>
123 </div>
124 </form>
125 )}
126 </div>
127 </div>
128 ) : null}
129 </>
130 );
131}