import { useId, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query"; import { Check, Copy, KeyRound, Plus, SlidersHorizontal, Trash2 } from "lucide-react"; import { api, TOKEN_EXPIRY_DAYS, TOKEN_EXPIRY_DEFAULT, TOKEN_RIGHTS, type ApiToken, type FileFolder, type TokenGrant, type TokenRight, } from "../lib/api"; import { qk } from "../lib/queryKeys"; import { compareText } from "../lib/collate"; import { formatDate } from "../lib/format"; import { navItemAttrs } from "../lib/listNav"; import { useConfirm } from "./confirmContext"; import { folderIcon, folderLabel, takesUploads } from "./fileFolders"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { Badge } from "./ui/Badge"; import { Button } from "./ui/Button"; import { Chip } from "./ui/Chip"; import { ContextMenu } from "./ui/ContextMenu"; import InlineNotice from "./ui/InlineNotice"; import { Input } from "./ui/Input"; import Modal, { ModalFooter } from "./ui/Modal"; import { MoreButton } from "./ui/MoreButton"; import NavGrid from "./ui/NavGrid"; import { Pressable } from "./ui/Pressable"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { RelativeTime } from "./ui/RelativeTime"; import SegmentedToggle from "./ui/SegmentedToggle"; import { SURFACE } from "./ui/surfaces"; import { useContextMenu } from "./ui/useContextMenu"; import { useCopyFeedback } from "./useCopyFeedback"; // Settings → API tokens: a machine's key to the user's Files library (docs/DESIGN.md decision log, // "API tokens"). A row per token in three lines — what it is, what it opens, when it was used and // when it runs out — revoked from its "…" behind a confirm. A new token is made in a dialog that // asks the kind first (the one-click Hub exchange, or folders and rights of the user's own picking) // and, once made, shows the token in the same dialog: the one time it can be seen. /** The rights a token holds in a folder, in the reader's words: "list, read, write". */ function rightsText(rights: readonly TokenRight[], t: TFunction, lang: string): string { return TOKEN_RIGHTS.filter((r) => rights.includes(r)) .map((r) => t(`settings.tokens.rights.${r}`).toLocaleLowerCase(lang)) .join(", "); } /** A folder's glyph (`folderIcon`), drawn at a row's size. */ function Glyph({ icon: Icon }: { icon: ReturnType }) { return ; } function GrantLine({ grant }: { grant: TokenGrant }) { const { t, i18n } = useTranslation(); return ( {folderLabel(grant, t)} · {rightsText(grant.rights, t, i18n.language)} ); } function TokenRow({ token, index, rowProps, more, }: { token: ApiToken; index: number; rowProps: Record; more: ReactNode; }) { const { t, i18n } = useTranslation(); const expires = formatDate(token.expires_at, i18n.language); return (
{token.name} {token.prefix}… {token.expired ? ( {t("settings.tokens.expired")} ) : ( token.expiring && {t("settings.tokens.expiring")} )} {token.grants.map((g) => ( ))} {token.last_used_at ? ( <> {t("settings.tokens.lastUsed")} ) : ( t("settings.tokens.neverUsed") )} {" · "} {token.expires_at === null ? t("settings.tokens.neverExpires") : token.expired ? t("settings.tokens.expiredOn", { date: expires }) : t("settings.tokens.expiresOn", { date: expires })}
{more}
); } export default function ApiTokens() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const [making, setMaking] = useState(false); const tokensQ = useQuery({ queryKey: qk.apiTokens(), queryFn: api.apiTokens }); const revoke = useMutation({ mutationFn: (id: number) => api.revokeApiToken(id), onSuccess: () => qc.invalidateQueries({ queryKey: qk.apiTokens() }), meta: { failTitle: "errors.writeFailed.apiToken.revoke" }, }); const onRevoke = async (token: ApiToken) => { const ok = await confirm({ title: t("settings.tokens.revokeTitle", { name: token.name }), message: t("settings.tokens.revokeBody"), confirmLabel: t("settings.tokens.revokeAction"), danger: true, }); if (ok) revoke.mutate(token.id); }; const menu = useContextMenu({ keyOf: (tk) => tk.id }); const items = (token: ApiToken): ActionMenuItem[] => [ { id: "revoke", label: t("settings.tokens.revoke"), icon: , danger: true, onSelect: () => void onRevoke(token), }, ]; const tokens = tokensQ.data; return (

{t("settings.tokens.intro")}

{tokensQ.isError && !tokens ? ( void tokensQ.refetch()}> {t("common.loadError")} ) : !tokens ? ( ) : tokens.length === 0 ? (

{t("settings.tokens.empty")}

) : ( {tokens.map((token, i) => ( } /> ))} )} {making && setMaking(false)} />}
); } // --- making one ----------------------------------------------------------------------------------- type Kind = "hub" | "custom"; type Grants = Record; /** The folders a custom token may be given, as the Files rail shows them: the sources that hold * something, then the Hub folder, then the user's own. A right a folder cannot take (writing into a * site's folder) is not offered. */ function grantable(folders: readonly FileFolder[], t: TFunction, lang: string): FileFolder[] { const byName = (a: FileFolder, b: FileFolder) => compareText(folderLabel(a, t), folderLabel(b, t), lang); return [ ...folders.filter((f) => f.kind === "source" && f.count > 0).sort(byName), ...folders.filter((f) => f.kind === "hub"), ...folders.filter((f) => f.kind === "own").sort(byName), ]; } function RightsGrid({ foldersQ, grants, onChange, }: { foldersQ: UseQueryResult; grants: Grants; onChange: (g: Grants) => void; }) { const { t, i18n } = useTranslation(); if (foldersQ.isError && !foldersQ.data) return ( void foldersQ.refetch()}> {t("common.loadError")} ); if (!foldersQ.data) return ; const rows = grantable(foldersQ.data, t, i18n.language); if (rows.length === 0) return

{t("settings.tokens.noFolders")}

; const toggle = (folder: FileFolder, right: TokenRight) => { const had = grants[folder.id] ?? []; const next = had.includes(right) ? had.filter((r) => r !== right) : [...had, right]; onChange({ ...grants, [folder.id]: next }); }; return (
{rows.map((f) => { const label = folderLabel(f, t); return (
{label} {TOKEN_RIGHTS.map((r) => r === "write" && !takesUploads(f) ? null : ( toggle(f, r)} > {t(`settings.tokens.rights.${r}`)} ), )}
); })}
); } function KindTile({ on, icon, title, text, onPick, }: { on: boolean; icon: ReactNode; title: string; text: string; onPick: () => void; }) { const textId = useId(); return ( {icon} {title} {/* Read after the name, not into it ("Custom" — then what it means). */} {text} ); } const HubIcon = folderIcon({ kind: "hub", source_key: null, name: null }); const expiryValue = (days: number | null) => (days === null ? "never" : String(days)); function NewTokenDialog({ onClose }: { onClose: () => void }) { const { t } = useTranslation(); const qc = useQueryClient(); const nameId = useId(); const [kind, setKind] = useState("hub"); // The name as typed; untouched, the Hub exchange is named for what it is and a custom one is blank. const [nameDraft, setNameDraft] = useState(null); const name = nameDraft ?? (kind === "hub" ? t("settings.tokens.hubName") : ""); const [grants, setGrants] = useState({}); const [expiry, setExpiry] = useState(TOKEN_EXPIRY_DEFAULT); // Read as the dialog opens, not when Custom is picked: the grid is there by the time it is wanted. const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders }); const picked = Object.entries(grants) .filter(([, rights]) => rights.length > 0) .map(([id, rights]) => ({ folder_id: Number(id), rights: [...rights] })); const create = useMutation({ mutationFn: () => api.createApiToken( kind === "hub" ? { name: name.trim(), expires_days: expiry, preset: "hub" } : { name: name.trim(), expires_days: expiry, grants: picked }, ), onSuccess: () => { void qc.invalidateQueries({ queryKey: qk.apiTokens() }); // The Hub exchange makes the Hub folder the first time. void qc.invalidateQueries({ queryKey: qk.fileFolders() }); }, meta: { failTitle: "errors.writeFailed.apiToken.create" }, }); const made = create.data; const ready = name.trim() !== "" && (kind === "hub" || picked.length > 0); if (made) return (
{t("settings.tokens.once")}
); return (
{ e.preventDefault(); if (ready && !create.isPending) create.mutate(); }} >
} title={t("settings.tokens.hubTitle")} text={t("settings.tokens.hubText")} onPick={() => setKind("hub")} /> } title={t("settings.tokens.customTitle")} text={t("settings.tokens.customText")} onPick={() => setKind("custom")} />
{kind === "custom" && (
{t("settings.tokens.folders")}
)}
{t("settings.tokens.expiry")} ({ value: expiryValue(d), label: t(`settings.tokens.expiryOption.${expiryValue(d)}`), }))} value={expiryValue(expiry)} onChange={(v) => setExpiry(v === "never" ? null : Number(v))} /> {expiry === null && {t("settings.tokens.neverWarning")}}
); } /** A value to copy, shown whole (it wraps), with its Copy button. */ function CopyField({ label, value, autoFocus = false, }: { label: string; value: string; autoFocus?: boolean; }) { const { t } = useTranslation(); const { copied, copy } = useCopyFeedback(); return (
{label}
{value}
); }