shell-token-panel.tsx103 lines · main
1'use client';
2
3import { useState } from 'react';
4
5interface IssuedToken {
6 dsn: string;
7 role: string;
8 expiresAt: string;
9}
10
11export function ShellTokenPanel({ projectId }: { projectId: string }) {
12 const [token, setToken] = useState<IssuedToken | null>(null);
13 const [pending, setPending] = useState(false);
14 const [error, setError] = useState<string | null>(null);
15 const [copied, setCopied] = useState(false);
16
17 async function issue() {
18 setPending(true);
19 setError(null);
20 setCopied(false);
21 try {
22 const res = await fetch(`/api/v1/projects/${projectId}/db/shell-token`, {
23 method: 'POST',
24 });
25 if (!res.ok) {
26 const body = (await res.json().catch(() => ({}))) as { message?: string; code?: string };
27 throw new Error(body.message ?? body.code ?? `http ${res.status}`);
28 }
29 const data = (await res.json()) as IssuedToken;
30 setToken(data);
31 } catch (err) {
32 setError(err instanceof Error ? err.message : 'request failed');
33 } finally {
34 setPending(false);
35 }
36 }
37
38 async function copy() {
39 if (!token) return;
40 try {
41 await navigator.clipboard.writeText(token.dsn);
42 setCopied(true);
43 setTimeout(() => setCopied(false), 2000);
44 } catch {
45 // clipboard blocked — user can select manually
46 }
47 }
48
49 if (!token) {
50 return (
51 <div className="flex flex-col gap-2">
52 <button
53 type="button"
54 onClick={issue}
55 disabled={pending}
56 className="self-start rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-xs font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
57 >
58 {pending ? 'issuing…' : 'issue a connection string'}
59 </button>
60 {error ? (
61 <p className="rounded-md bg-red-400/10 px-3 py-2 font-mono text-xs text-red-400">
62 {error}
63 </p>
64 ) : null}
65 </div>
66 );
67 }
68
69 return (
70 <div className="flex flex-col gap-3 rounded-md border border-[var(--color-primary)]/30 bg-[var(--color-surface)] p-4">
71 <div className="flex items-center justify-between">
72 <span className="font-mono text-[10px] text-[var(--color-text-muted)]">
73 role: <code>{token.role}</code> · expires{' '}
74 {new Date(token.expiresAt).toISOString().slice(11, 19)} UTC
75 </span>
76 <div className="flex gap-2">
77 <button
78 type="button"
79 onClick={copy}
80 className="rounded-md border border-[var(--color-border)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
81 >
82 {copied ? 'copied!' : 'copy'}
83 </button>
84 <button
85 type="button"
86 onClick={issue}
87 disabled={pending}
88 className="rounded-md border border-[var(--color-border)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] disabled:opacity-50"
89 >
90 {pending ? 'rotating…' : 'rotate'}
91 </button>
92 </div>
93 </div>
94 <pre className="overflow-x-auto rounded-md bg-[var(--color-bg)] p-3 font-mono text-[11px]">
95 <code>{token.dsn}</code>
96 </pre>
97 <p className="font-mono text-[10px] text-[var(--color-text-subtle)]">
98 save this somewhere safe — closing this page won&apos;t bring it back. it expires
99 on its own; nothing to revoke.
100 </p>
101 </div>
102 );
103}