Skip to content
File

Blob: ui/client/DomainTokens.svelte

10.7 KB
1<script lang="ts">
2 import { onMount, onDestroy, tick } from "svelte";
3 import Button from "../shared/Button.svelte";
4 import ScrollRegion from "../shared/ScrollRegion.svelte";
5 import { INPUT_CLASSES } from "../shared/styles";
6 import { createAPI, errorMessage, type DomainToken, type MintedToken, type RevokeOutcome } from "./api";
7 
8 let { hostnames, loadingHostnames, hostnameError, onRefreshHostnames }: {
9 hostnames: string[] | null;
10 loadingHostnames: boolean;
11 hostnameError: string;
12 onRefreshHostnames: () => Promise<void>;
13 } = $props();
14 let hostname = $state("");
15 let hostnameNotice = $state("");
16 let expiresAt = $state("");
17 let grants = $state<DomainToken[] | null>(null);
18 let minted = $state<MintedToken | null>(null);
19 let refreshing = $state(true);
20 let pending = $state("");
21 let error = $state("");
22 let notice = $state("");
23 let copyNotice = $state("");
24 let confirmation = $state<{ grant: DomainToken; trigger: HTMLButtonElement } | null>(null);
25 let cancelButton = $state<HTMLButtonElement>();
26 let tokenInput = $state<HTMLInputElement>();
27 let heading = $state<HTMLHeadingElement>();
28 const choices = $derived([...(hostnames ?? [])].sort((a, b) => a.localeCompare(b)));
29 const hostnamePlaceholder = $derived(hostnames === null
30 ? hostnameError && !loadingHostnames ? "Hostnames unavailable" : "Loading hostnames…"
31 : choices.length ? "Select hostname…" : "No registered hostnames");
32 const showHostnameHelp = $derived(Boolean(hostnameError || hostnameNotice || (hostnames !== null && !choices.length)));
33 const busy = $derived(refreshing || Boolean(pending));
34 const canMint = $derived(!busy && choices.includes(hostname));
35 const api = createAPI();
36 
37 $effect(() => {
38 if (hostname && hostnames !== null && !hostnames.includes(hostname)) {
39 hostname = "";
40 hostnameNotice = "Selected hostname is no longer registered.";
41 }
42 });
43 
44 async function loadGrants() {
45 error = "";
46 try {
47 grants = await api.request<DomainToken[]>("/api/tokens");
48 if (confirmation && !grants.some((grant) => grant.id === confirmation?.grant.id)) {
49 confirmation = null;
50 await tick();
51 heading?.focus();
52 }
53 } catch (cause) {
54 error = `${grants ? "Showing previous token list. " : ""}${errorMessage(cause)}`;
55 }
56 }
57 
58 async function refresh() {
59 if (busy) return;
60 refreshing = true;
61 await Promise.allSettled([loadGrants(), onRefreshHostnames()]);
62 refreshing = false;
63 }
64 
65 async function mint() {
66 if (!canMint) return;
67 pending = "mint";
68 error = "";
69 notice = "";
70 try {
71 const expiry = expiresAt ? new Date(expiresAt).toISOString() : undefined;
72 minted = await api.request<MintedToken>("/api/tokens", {
73 method: "POST",
74 headers: { "Content-Type": "application/json" },
75 body: JSON.stringify({ hostname: hostname.trim(), expiresAt: expiry }),
76 });
77 if (grants) grants = [...grants, minted.grant];
78 copyNotice = "";
79 await tick();
80 selectToken();
81 } catch (cause) {
82 error = errorMessage(cause);
83 } finally {
84 pending = "";
85 }
86 }
87 
88 function selectToken() {
89 tokenInput?.focus();
90 tokenInput?.select();
91 }
92 
93 async function copy() {
94 if (!minted) return;
95 try {
96 await navigator.clipboard.writeText(minted.token);
97 copyNotice = "Token copied.";
98 } catch {
99 selectToken();
100 copyNotice = "Token selected. Copy it using your browser or keyboard.";
101 }
102 }
103 
104 async function askRevoke(grant: DomainToken, trigger: HTMLButtonElement) {
105 if (busy) return;
106 confirmation = { grant, trigger };
107 await tick();
108 cancelButton?.focus();
109 }
110 
111 function cancelRevoke() {
112 if (pending) return;
113 const trigger = confirmation?.trigger;
114 confirmation = null;
115 trigger?.focus();
116 }
117 
118 async function revoke() {
119 if (!confirmation || busy) return;
120 const grant = confirmation.grant;
121 pending = grant.id;
122 error = "";
123 notice = "";
124 try {
125 const result = await api.request<RevokeOutcome>(`/api/tokens/${encodeURIComponent(grant.id)}/revoke`, { method: "POST" });
126 if (result.revoked) {
127 if (minted?.grant.id === grant.id) minted = null;
128 if (result.indexError) {
129 error = `Access revoked; list cleanup failed. Revoke again to finish: ${result.indexError}`;
130 grants = grants?.map((item) => item.id === grant.id ? { ...item, incomplete: true } : item) ?? null;
131 } else {
132 grants = grants?.filter((item) => item.id !== grant.id) ?? null;
133 notice = "Token revoked.";
134 }
135 confirmation = null;
136 await tick();
137 heading?.focus();
138 }
139 } catch (cause) {
140 error = errorMessage(cause);
141 } finally {
142 pending = "";
143 }
144 }
145 
146 onMount(() => { void loadGrants().finally(() => { refreshing = false; }); });
147 onDestroy(api.abort);
148</script>
149 
150<svelte:window onkeydown={(event) => { if (event.key === "Escape" && confirmation) cancelRevoke(); }} />
151 
152<section class="mt-6 min-w-0 border-t border-line pt-5" aria-labelledby="tokens-heading">
153 <div class="mb-3.5 flex items-center justify-between gap-3">
154 <h2 id="tokens-heading" bind:this={heading} tabindex="-1" class="text-lg leading-snug font-semibold tracking-tight">Domain tokens</h2>
155 <Button size="sm" disabled={busy} onclick={refresh}>{refreshing ? "Refreshing…" : "Refresh"}</Button>
156 </div>
157 <form class={['flex flex-wrap items-end gap-3', showHostnameHelp ? 'mb-1.5' : 'mb-3.5']} onsubmit={(event) => { event.preventDefault(); void mint(); }}>
158 <div class="min-w-0 grow basis-64">
159 <label for="token-hostname" class="mb-1.5 block font-medium">Hostname</label>
160 <select id="token-hostname" class={INPUT_CLASSES} bind:value={hostname} onchange={() => { hostnameNotice = ""; }} disabled={Boolean(pending) || !choices.length} aria-describedby="token-hostname-help" aria-busy={loadingHostnames} required>
161 <option value="" disabled>{hostnamePlaceholder}</option>
162 {#each choices as host (host)}<option value={host}>{host}</option>{/each}
163 </select>
164 </div>
165 <div class="min-w-0 grow basis-56">
166 <label for="token-expiry" class="mb-1.5 block font-medium">Expiry <span class="font-normal text-muted">(optional)</span></label>
167 <input id="token-expiry" type="datetime-local" class={INPUT_CLASSES} bind:value={expiresAt} disabled={Boolean(pending)} />
168 </div>
169 <Button type="submit" variant="primary" disabled={!canMint}>{pending === "mint" ? "Minting…" : "Mint token"}</Button>
170 </form>
171 <p id="token-hostname-help" class={['text-[0.8125rem] text-muted', showHostnameHelp && 'mb-3.5']} aria-live="polite">
172 {#if hostnameError}
173 {hostnames === null ? "Refresh to retry." : "Using previous hostname list."}
174 {:else if hostnameNotice}
175 {hostnameNotice}
176 {:else if hostnames !== null && !choices.length}
177 <a href="#configuration" class="text-accent-300 underline underline-offset-4 hover:text-accent-200">Add a tunnel</a> to register a hostname.
178 {/if}
179 </p>
180 
181 {#if minted}
182 <div class="my-3.5">
183 <label for="minted-token" class="mb-1.5 block font-medium wrap-anywhere">New token for <span class="font-mono text-[0.8125rem]">{minted.grant.hostname}</span></label>
184 <div class="flex min-w-0 gap-2">
185 <input id="minted-token" bind:this={tokenInput} class={[INPUT_CLASSES, 'min-w-0 font-mono text-[0.8125rem]']} readonly value={minted.token} aria-describedby="token-once" onfocus={(event) => event.currentTarget.select()} onclick={(event) => event.currentTarget.select()} />
186 <Button disabled={pending === "mint"} onclick={copy}>Copy</Button>
187 </div>
188 <p id="token-once" class="mt-1.5 text-[0.8125rem] text-muted">Copy now; you cannot retrieve it later.</p>
189 <p class={['text-[0.8125rem] text-muted', copyNotice && 'mt-1.5']} aria-live="polite">{copyNotice}</p>
190 </div>
191 {/if}
192 
193 <div aria-live="polite" aria-busy={busy}>
194 {#if error}
195 <div class="my-2.5 rounded-md border border-danger/40 bg-chrome px-3 py-2 text-danger wrap-anywhere">
196 {#if error.length > 180}<details open><summary class="cursor-pointer font-medium">Token request needs attention</summary><p class="mt-2">{error}</p></details>{:else}{error}{/if}
197 </div>
198 {/if}
199 {#if notice}<p class="my-2.5 text-success">{notice}</p>{/if}
200 </div>
201 
202 {#if grants?.length}
203 <ScrollRegion label="Domain tokens" class="bg-chrome">
204 <table class="w-full min-w-[640px] table-fixed border-collapse text-start [&_td]:px-3.5 [&_td]:py-2.5 [&_td]:align-top [&_th]:px-3.5 [&_th]:py-2.5 [&_th]:text-start">
205 <thead class="text-xs text-muted [&_th]:font-medium"><tr><th scope="col" class="w-[28%]">Hostname</th><th scope="col" class="w-[30%]">Grant ID</th><th scope="col" class="w-[26%]">Expiry</th><th scope="col" class="w-[16%]">Action</th></tr></thead>
206 <tbody>
207 {#each grants as grant (grant.id)}
208 <tr class={['border-t border-line hover:bg-surface/50', confirmation?.grant.id === grant.id && 'bg-surface']}>
209 <th scope="row" class="font-mono text-[0.8125rem] font-medium wrap-anywhere">{grant.hostname || "Unknown"}</th>
210 <td><span class="block truncate font-mono text-[0.8125rem]" title={grant.id}>{grant.id}</span></td>
211 <td class="text-[0.8125rem]">{#if grant.incomplete}<span class="text-warning">Incomplete</span>{:else}{grant.expiresAt ? new Date(grant.expiresAt).toLocaleString() : "No expiry"}{/if}</td>
212 <td><Button size="sm" variant="danger" disabled={busy} aria-label={`Revoke ${grant.id}`} aria-expanded={confirmation?.grant.id === grant.id} onclick={(event) => askRevoke(grant, event.currentTarget)}>Revoke</Button></td>
213 </tr>
214 {/each}
215 </tbody>
216 </table>
217 </ScrollRegion>
218 {:else}
219 <p class="rounded-md border border-line bg-chrome px-3.5 py-3 text-muted">{grants ? "No domain tokens." : refreshing ? "Loading tokens…" : "Token list unavailable."}</p>
220 {/if}
221 {#if confirmation}
222 <div class="mt-2.5 flex min-w-0 flex-wrap items-center justify-between gap-3 rounded-md border border-line bg-surface px-3.5 py-3" role="group" aria-label="Confirm token revocation">
223 <div class="min-w-0 max-w-[68ch] text-zinc-300">
224 <p class="wrap-anywhere">Revoke token{#if confirmation.grant.hostname} for <span class="font-mono text-[0.8125rem]">{confirmation.grant.hostname}</span>{/if}?</p>
225 <p class="mt-1 text-[0.8125rem] text-muted">Ends this token's access; running sidecars may take up to 2 minutes to disconnect.</p>
226 </div>
227 <div class="flex flex-wrap gap-2"><Button bind:element={cancelButton} disabled={Boolean(pending)} onclick={cancelRevoke}>Cancel</Button><Button variant="danger" disabled={busy} onclick={revoke}>{pending === confirmation.grant.id ? "Revoking…" : "Confirm revoke"}</Button></div>
228 </div>
229 {/if}
230</section>