import { type FormEvent, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { KeyRound, Plus, ShieldAlert, Trash2 } from "lucide-react"; import { $api } from "@/client/lib/api"; import { type DavScope, type PatCreateResponse, type PatListItem } from "@/client/lib/types"; import { Badge } from "@/client/components/ui/badge"; import { Button } from "@/client/components/ui/button"; import { Card } from "@/client/components/ui/card"; import { ConfirmDialog } from "@/client/components/ui/confirm-dialog"; import { Copyable } from "@/client/components/ui/copyable"; import { Dialog } from "@/client/components/ui/dialog"; import { EmptyState } from "@/client/components/empty-state"; import { ErrorBanner } from "@/client/components/error-banner"; import { Input } from "@/client/components/ui/input"; import { Label } from "@/client/components/ui/label"; import { Skeleton } from "@/client/components/loading-panel"; import { PageHeader } from "@/client/components/ui/page-header"; import { formatDate } from "@/client/lib/format"; import { toast } from "@/client/lib/toast"; import { useNow } from "@/client/hooks/use-now"; const scopeLabels: Record = { "dav:files:read": "Files (read)", "dav:files:write": "Files (write)", "dav:caldav:read": "CalDAV (read)", "dav:caldav:write": "CalDAV (write)", "dav:carddav:read": "CardDAV (read)", "dav:carddav:write": "CardDAV (write)", }; type ScopeArea = "files" | "caldav" | "carddav"; type ScopeLevel = "none" | "read" | "write"; const SCOPE_AREAS: { key: ScopeArea; label: string; hint: string }[] = [ { key: "files", label: "Files", hint: "WebDAV file tree" }, { key: "caldav", label: "Calendars", hint: "CalDAV calendars and events" }, { key: "carddav", label: "Contacts", hint: "CardDAV address books" }, ]; function levelsToScopes(levels: Record): DavScope[] { const out: DavScope[] = []; for (const area of ["files", "caldav", "carddav"] as const) { if (levels[area] === "read") out.push(`dav:${area}:read` as DavScope); if (levels[area] === "write") out.push(`dav:${area}:write` as DavScope); } return out; } type Preset = { id: string; label: string; description: string; levels: Record }; const PRESETS: Preset[] = [ { id: "full", label: "Full access", description: "Files, Calendars, Contacts — read/write.", levels: { files: "write", caldav: "write", carddav: "write" }, }, { id: "thunderbird", label: "Thunderbird", description: "Calendars + Contacts, read/write.", levels: { files: "none", caldav: "write", carddav: "write" }, }, { id: "readonly", label: "Read-only audit", description: "All areas, read only.", levels: { files: "read", caldav: "read", carddav: "read" }, }, ]; export function PatsPage() { const list = $api.useQuery("get", "/api/v1/pats", {}); const [creating, setCreating] = useState(false); const [revealed, setRevealed] = useState(null); return (
); } function PatRow({ pat }: { pat: PatListItem }) { const queryClient = useQueryClient(); const [confirming, setConfirming] = useState(false); const revoke = $api.useMutation("delete", "/api/v1/pats/{pat_id}", { onSuccess: () => { toast.success(`Revoked ${pat.name}.`); void queryClient.invalidateQueries({ queryKey: ["get", "/api/v1/pats"] }); setConfirming(false); }, }); const now = useNow(); const isRevoked = Boolean(pat.revoked_at); const isExpired = pat.expires_at != null && new Date(pat.expires_at).getTime() < now; return (

{pat.name}

{isRevoked ? ( Revoked ) : isExpired ? ( Expired ) : ( Active )}
{pat.scopes.map((scope) => ( {scopeLabels[scope as DavScope] ?? scope} ))}

Created {formatDate(pat.created_at, { relative: true })} {pat.expires_at ? ` · expires ${formatDate(pat.expires_at, { relative: true })}` : ""} {pat.last_used_at ? ` · last used ${formatDate(pat.last_used_at, { relative: true })}` : ""}

{!isRevoked ? ( ) : null}
setConfirming(false)} onConfirm={() => revoke.mutate({ params: { path: { pat_id: pat.id } } })} title={`Revoke ${pat.name}?`} description="Any DAV client using this token will lose access." confirmLabel="Revoke" destructive loading={revoke.isPending} >
Client
{pat.name}
PAT ID
{pat.id}
); } function CreatePatDialog({ open, onClose, onCreated, }: { open: boolean; onClose: () => void; onCreated: (pat: PatCreateResponse) => void; }) { const [name, setName] = useState(""); const [levels, setLevels] = useState>({ files: "none", caldav: "none", carddav: "none", }); const [expiresAt, setExpiresAt] = useState(""); const queryClient = useQueryClient(); const create = $api.useMutation("post", "/api/v1/pats", { onSuccess: (pat) => { void queryClient.invalidateQueries({ queryKey: ["get", "/api/v1/pats"] }); setName(""); setLevels({ files: "none", caldav: "none", carddav: "none" }); setExpiresAt(""); onCreated(pat); }, }); const scopes = levelsToScopes(levels); const submit = (event: FormEvent) => { event.preventDefault(); if (!name.trim() || scopes.length === 0) return; create.mutate({ body: { name: name.trim(), scopes, expires_at: expiresAt ? new Date(expiresAt).toISOString() : undefined, }, }); }; const setLevel = (area: ScopeArea, next: ScopeLevel) => { setLevels((prev) => ({ ...prev, [area]: next })); }; const applyPreset = (preset: Preset) => { setLevels(preset.levels); if (!name.trim()) setName(preset.label); }; return ( { if (!create.isPending) onClose(); }} title="New personal access token" description="Pick the minimum access this client needs. Write implies read for the same area." size="md" footer={ <> } >
Access
{SCOPE_AREAS.map(({ key, label, hint }) => ( setLevel(key, next)} /> ))}
Common clients: {PRESETS.map((preset) => ( ))}
{create.error ? : null}
); } function ScopeRow({ label, hint, level, onChange, }: { label: string; hint: string; level: ScopeLevel; onChange: (next: ScopeLevel) => void; }) { const options: { value: ScopeLevel; label: string }[] = [ { value: "none", label: "Off" }, { value: "read", label: "Read" }, { value: "write", label: "Read+Write" }, ]; return (

{label}

{hint}

{options.map(({ value, label: optLabel }) => { const selected = level === value; return ( ); })}
); }