Skip to content
File

Blob: src/client/pages/pats.tsx

typescript418 lines
1import { type FormEvent, useState } from "react";
2import { useQueryClient } from "@tanstack/react-query";
3import { KeyRound, Plus, ShieldAlert, Trash2 } from "lucide-react";
4 
5import { $api } from "@/client/lib/api";
6import { type DavScope, type PatCreateResponse, type PatListItem } from "@/client/lib/types";
7import { Badge } from "@/client/components/ui/badge";
8import { Button } from "@/client/components/ui/button";
9import { Card } from "@/client/components/ui/card";
10import { ConfirmDialog } from "@/client/components/ui/confirm-dialog";
11import { Copyable } from "@/client/components/ui/copyable";
12import { Dialog } from "@/client/components/ui/dialog";
13import { EmptyState } from "@/client/components/empty-state";
14import { ErrorBanner } from "@/client/components/error-banner";
15import { Input } from "@/client/components/ui/input";
16import { Label } from "@/client/components/ui/label";
17import { Skeleton } from "@/client/components/loading-panel";
18import { PageHeader } from "@/client/components/ui/page-header";
19import { formatDate } from "@/client/lib/format";
20import { toast } from "@/client/lib/toast";
21import { useNow } from "@/client/hooks/use-now";
22 
23const scopeLabels: Record<DavScope, string> = {
24 "dav:files:read": "Files (read)",
25 "dav:files:write": "Files (write)",
26 "dav:caldav:read": "CalDAV (read)",
27 "dav:caldav:write": "CalDAV (write)",
28 "dav:carddav:read": "CardDAV (read)",
29 "dav:carddav:write": "CardDAV (write)",
30};
31 
32type ScopeArea = "files" | "caldav" | "carddav";
33type ScopeLevel = "none" | "read" | "write";
34 
35const SCOPE_AREAS: { key: ScopeArea; label: string; hint: string }[] = [
36 { key: "files", label: "Files", hint: "WebDAV file tree" },
37 { key: "caldav", label: "Calendars", hint: "CalDAV calendars and events" },
38 { key: "carddav", label: "Contacts", hint: "CardDAV address books" },
39];
40 
41function levelsToScopes(levels: Record<ScopeArea, ScopeLevel>): DavScope[] {
42 const out: DavScope[] = [];
43 for (const area of ["files", "caldav", "carddav"] as const) {
44 if (levels[area] === "read") out.push(`dav:${area}:read` as DavScope);
45 if (levels[area] === "write") out.push(`dav:${area}:write` as DavScope);
46 }
47 return out;
48}
49 
50type Preset = { id: string; label: string; description: string; levels: Record<ScopeArea, ScopeLevel> };
51 
52const PRESETS: Preset[] = [
53 {
54 id: "full",
55 label: "Full access",
56 description: "Files, Calendars, Contacts — read/write.",
57 levels: { files: "write", caldav: "write", carddav: "write" },
58 },
59 {
60 id: "thunderbird",
61 label: "Thunderbird",
62 description: "Calendars + Contacts, read/write.",
63 levels: { files: "none", caldav: "write", carddav: "write" },
64 },
65 {
66 id: "readonly",
67 label: "Read-only audit",
68 description: "All areas, read only.",
69 levels: { files: "read", caldav: "read", carddav: "read" },
70 },
71];
72 
73export function PatsPage() {
74 const list = $api.useQuery("get", "/api/v1/pats", {});
75 const [creating, setCreating] = useState(false);
76 const [revealed, setRevealed] = useState<PatCreateResponse | null>(null);
77 
78 return (
79 <div className="space-y-8">
80 <PageHeader
81 icon={<KeyRound className="h-7 w-7" strokeWidth={2} aria-hidden="true" />}
82 title="Personal access tokens"
83 description="Credentials for DAV clients."
84 actions={
85 <Button
86 variant="primary"
87 leftIcon={<Plus className="h-4 w-4" aria-hidden="true" />}
88 onClick={() => setCreating(true)}
89 aria-label="New token"
90 >
91 <span className="hidden sm:inline">New token</span>
92 </Button>
93 }
94 />
95
96 {list.isPending ? (
97 <div className="space-y-2">
98 <Skeleton className="h-16 w-full" />
99 <Skeleton className="h-16 w-full" />
100 </div>
101 ) : list.error ? (
102 <ErrorBanner error={list.error} title="Could not load tokens" />
103 ) : !list.data || list.data.length === 0 ? (
104 <EmptyState
105 icon={<KeyRound className="h-7 w-7" aria-hidden="true" />}
106 title="No tokens yet"
107 description="Create a token to authenticate a DAV client without using the browser session cookie."
108 />
109 ) : (
110 <ul className="space-y-3">
111 {list.data.map((pat) => (
112 <li key={pat.id}>
113 <PatRow pat={pat} />
114 </li>
115 ))}
116 </ul>
117 )}
118
119 <CreatePatDialog
120 open={creating}
121 onClose={() => setCreating(false)}
122 onCreated={(pat) => {
123 setCreating(false);
124 setRevealed(pat);
125 }}
126 />
127
128 <Dialog
129 open={revealed != null}
130 onClose={() => setRevealed(null)}
131 title="Token created"
132 description="Copy this token now — you won't be able to see it again. Treat it like a password."
133 size="lg"
134 closeOnBackdrop={false}
135 footer={
136 <Button variant="primary" onClick={() => setRevealed(null)}>
137 I've stored it safely
138 </Button>
139 }
140 >
141 {revealed ? (
142 <div className="space-y-3">
143 <div className="flex items-start gap-3 rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
144 <ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" strokeWidth={2} aria-hidden="true" />
145 <p>This is the only time the plaintext token will be shown.</p>
146 </div>
147 <Copyable value={revealed.token} label="token" />
148 <p className="text-xs text-zinc-500">
149 Name: <span className="text-zinc-300">{revealed.name}</span>
150 </p>
151 </div>
152 ) : null}
153 </Dialog>
154 </div>
155 );
156}
157 
158function PatRow({ pat }: { pat: PatListItem }) {
159 const queryClient = useQueryClient();
160 const [confirming, setConfirming] = useState(false);
161 const revoke = $api.useMutation("delete", "/api/v1/pats/{pat_id}", {
162 onSuccess: () => {
163 toast.success(`Revoked ${pat.name}.`);
164 void queryClient.invalidateQueries({ queryKey: ["get", "/api/v1/pats"] });
165 setConfirming(false);
166 },
167 });
168 const now = useNow();
169 const isRevoked = Boolean(pat.revoked_at);
170 const isExpired = pat.expires_at != null && new Date(pat.expires_at).getTime() < now;
171 
172 return (
173 <Card className="!p-4">
174 <div className="flex flex-wrap items-start justify-between gap-4">
175 <div className="min-w-0 space-y-2">
176 <div className="flex flex-wrap items-center gap-2">
177 <h3 className="truncate font-medium text-zinc-100">{pat.name}</h3>
178 {isRevoked ? (
179 <Badge tone="danger">Revoked</Badge>
180 ) : isExpired ? (
181 <Badge tone="warning">Expired</Badge>
182 ) : (
183 <Badge tone="success">Active</Badge>
184 )}
185 </div>
186 <div className="flex flex-wrap gap-1">
187 {pat.scopes.map((scope) => (
188 <Badge key={scope} tone="neutral">
189 {scopeLabels[scope as DavScope] ?? scope}
190 </Badge>
191 ))}
192 </div>
193 <p className="text-xs text-zinc-500">
194 Created {formatDate(pat.created_at, { relative: true })}
195 {pat.expires_at ? ` · expires ${formatDate(pat.expires_at, { relative: true })}` : ""}
196 {pat.last_used_at ? ` · last used ${formatDate(pat.last_used_at, { relative: true })}` : ""}
197 </p>
198 </div>
199 {!isRevoked ? (
200 <Button
201 size="sm"
202 variant="danger"
203 leftIcon={<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />}
204 onClick={() => setConfirming(true)}
205 >
206 Revoke
207 </Button>
208 ) : null}
209 </div>
210 <ConfirmDialog
211 open={confirming}
212 onClose={() => setConfirming(false)}
213 onConfirm={() => revoke.mutate({ params: { path: { pat_id: pat.id } } })}
214 title={`Revoke ${pat.name}?`}
215 description="Any DAV client using this token will lose access."
216 confirmLabel="Revoke"
217 destructive
218 loading={revoke.isPending}
219 >
220 <dl className="space-y-2 text-xs">
221 <div className="grid gap-1 sm:grid-cols-[4.5rem_1fr]">
222 <dt className="text-zinc-500">Client</dt>
223 <dd className="break-words text-zinc-200">{pat.name}</dd>
224 </div>
225 <div className="grid gap-1 sm:grid-cols-[4.5rem_1fr]">
226 <dt className="text-zinc-500">PAT ID</dt>
227 <dd className="break-all font-mono text-zinc-300">{pat.id}</dd>
228 </div>
229 </dl>
230 </ConfirmDialog>
231 </Card>
232 );
233}
234 
235function CreatePatDialog({
236 open,
237 onClose,
238 onCreated,
239}: {
240 open: boolean;
241 onClose: () => void;
242 onCreated: (pat: PatCreateResponse) => void;
243}) {
244 const [name, setName] = useState("");
245 const [levels, setLevels] = useState<Record<ScopeArea, ScopeLevel>>({
246 files: "none",
247 caldav: "none",
248 carddav: "none",
249 });
250 const [expiresAt, setExpiresAt] = useState("");
251 const queryClient = useQueryClient();
252 const create = $api.useMutation("post", "/api/v1/pats", {
253 onSuccess: (pat) => {
254 void queryClient.invalidateQueries({ queryKey: ["get", "/api/v1/pats"] });
255 setName("");
256 setLevels({ files: "none", caldav: "none", carddav: "none" });
257 setExpiresAt("");
258 onCreated(pat);
259 },
260 });
261 
262 const scopes = levelsToScopes(levels);
263 
264 const submit = (event: FormEvent) => {
265 event.preventDefault();
266 if (!name.trim() || scopes.length === 0) return;
267 create.mutate({
268 body: {
269 name: name.trim(),
270 scopes,
271 expires_at: expiresAt ? new Date(expiresAt).toISOString() : undefined,
272 },
273 });
274 };
275 
276 const setLevel = (area: ScopeArea, next: ScopeLevel) => {
277 setLevels((prev) => ({ ...prev, [area]: next }));
278 };
279 
280 const applyPreset = (preset: Preset) => {
281 setLevels(preset.levels);
282 if (!name.trim()) setName(preset.label);
283 };
284 
285 return (
286 <Dialog
287 open={open}
288 onClose={() => {
289 if (!create.isPending) onClose();
290 }}
291 title="New personal access token"
292 description="Pick the minimum access this client needs. Write implies read for the same area."
293 size="md"
294 footer={
295 <>
296 <Button variant="ghost" onClick={onClose} disabled={create.isPending}>
297 Cancel
298 </Button>
299 <Button
300 variant="primary"
301 type="submit"
302 form="create-pat-form"
303 loading={create.isPending}
304 disabled={!name.trim() || scopes.length === 0}
305 >
306 Create token
307 </Button>
308 </>
309 }
310 >
311 <form id="create-pat-form" onSubmit={submit} className="space-y-5">
312 <Label hint="Pick something memorable like a client or device name.">
313 Name
314 <Input
315 autoFocus
316 value={name}
317 onChange={(e) => setName(e.target.value)}
318 placeholder="e.g. Thunderbird on laptop"
319 required
320 maxLength={128}
321 />
322 </Label>
323
324 <fieldset className="space-y-3">
325 <legend className="text-sm font-medium text-zinc-300">Access</legend>
326 <div className="space-y-2">
327 {SCOPE_AREAS.map(({ key, label, hint }) => (
328 <ScopeRow
329 key={key}
330 label={label}
331 hint={hint}
332 level={levels[key]}
333 onChange={(next) => setLevel(key, next)}
334 />
335 ))}
336 </div>
337 <div className="flex flex-wrap items-center gap-2 pt-1">
338 <span className="text-xs text-zinc-500">Common clients:</span>
339 {PRESETS.map((preset) => (
340 <button
341 key={preset.id}
342 type="button"
343 onClick={() => applyPreset(preset)}
344 title={preset.description}
345 className="rounded-full border border-zinc-700/60 bg-zinc-800/60 px-2.5 py-0.5 text-xs text-zinc-300 transition-colors hover:border-zinc-600 hover:text-zinc-100"
346 >
347 {preset.label}
348 </button>
349 ))}
350 </div>
351 </fieldset>
352
353 <Label hint="Leave empty for a non-expiring token.">
354 Expires
355 <Input
356 type="datetime-local"
357 value={expiresAt}
358 onChange={(e) => setExpiresAt(e.target.value)}
359 min={new Date().toISOString().slice(0, 16)}
360 />
361 </Label>
362
363 {create.error ? <ErrorBanner error={create.error} title="Could not create token" /> : null}
364 </form>
365 </Dialog>
366 );
367}
368 
369function ScopeRow({
370 label,
371 hint,
372 level,
373 onChange,
374}: {
375 label: string;
376 hint: string;
377 level: ScopeLevel;
378 onChange: (next: ScopeLevel) => void;
379}) {
380 const options: { value: ScopeLevel; label: string }[] = [
381 { value: "none", label: "Off" },
382 { value: "read", label: "Read" },
383 { value: "write", label: "Read+Write" },
384 ];
385 
386 return (
387 <div className="flex flex-col gap-2 rounded-xl border border-zinc-700/60 bg-zinc-800/40 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
388 <div className="min-w-0">
389 <p className="text-sm font-medium text-zinc-200">{label}</p>
390 <p className="text-xs text-zinc-500">{hint}</p>
391 </div>
392 <div
393 role="radiogroup"
394 aria-label={`${label} access`}
395 className="grid grid-cols-3 rounded-lg border border-zinc-700/60 bg-zinc-900/60 p-0.5 text-xs sm:inline-flex"
396 >
397 {options.map(({ value, label: optLabel }) => {
398 const selected = level === value;
399 return (
400 <button
401 key={value}
402 type="button"
403 role="radio"
404 aria-checked={selected}
405 onClick={() => onChange(value)}
406 className={`rounded-md px-2.5 py-1 text-center transition-colors ${
407 selected ? "bg-accent-500/15 text-accent-200" : "text-zinc-400 hover:bg-zinc-800/70 hover:text-zinc-100"
408 }`}
409 >
410 {optLabel}
411 </button>
412 );
413 })}
414 </div>
415 </div>
416 );
417}