File
Blob: src/client/pages/pats.tsx
| 1 | import { type FormEvent, useState } from "react"; |
| 2 | import { useQueryClient } from "@tanstack/react-query"; |
| 3 | import { KeyRound, Plus, ShieldAlert, Trash2 } from "lucide-react"; |
| 4 | |
| 5 | import { $api } from "@/client/lib/api"; |
| 6 | import { type DavScope, type PatCreateResponse, type PatListItem } from "@/client/lib/types"; |
| 7 | import { Badge } from "@/client/components/ui/badge"; |
| 8 | import { Button } from "@/client/components/ui/button"; |
| 9 | import { Card } from "@/client/components/ui/card"; |
| 10 | import { ConfirmDialog } from "@/client/components/ui/confirm-dialog"; |
| 11 | import { Copyable } from "@/client/components/ui/copyable"; |
| 12 | import { Dialog } from "@/client/components/ui/dialog"; |
| 13 | import { EmptyState } from "@/client/components/empty-state"; |
| 14 | import { ErrorBanner } from "@/client/components/error-banner"; |
| 15 | import { Input } from "@/client/components/ui/input"; |
| 16 | import { Label } from "@/client/components/ui/label"; |
| 17 | import { Skeleton } from "@/client/components/loading-panel"; |
| 18 | import { PageHeader } from "@/client/components/ui/page-header"; |
| 19 | import { formatDate } from "@/client/lib/format"; |
| 20 | import { toast } from "@/client/lib/toast"; |
| 21 | import { useNow } from "@/client/hooks/use-now"; |
| 22 | |
| 23 | const 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 | |
| 32 | type ScopeArea = "files" | "caldav" | "carddav"; |
| 33 | type ScopeLevel = "none" | "read" | "write"; |
| 34 | |
| 35 | const 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 | |
| 41 | function 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 | |
| 50 | type Preset = { id: string; label: string; description: string; levels: Record<ScopeArea, ScopeLevel> }; |
| 51 | |
| 52 | const 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 | |
| 73 | export 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 | |
| 158 | function 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 | |
| 235 | function 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 | |
| 369 | function 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 | } |