Files
siftlode/frontend/src/components/ApiTokens.tsx
T

505 lines
17 KiB
TypeScript

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<typeof folderIcon> }) {
return <Icon className="size-3.5 shrink-0" aria-hidden />;
}
function GrantLine({ grant }: { grant: TokenGrant }) {
const { t, i18n } = useTranslation();
return (
<span className="flex min-w-0 items-center gap-1.5">
<Glyph icon={folderIcon(grant)} />
<span className="text-fg">{folderLabel(grant, t)}</span>
<span>· {rightsText(grant.rights, t, i18n.language)}</span>
</span>
);
}
function TokenRow({
token,
index,
rowProps,
more,
}: {
token: ApiToken;
index: number;
rowProps: Record<string, unknown>;
more: ReactNode;
}) {
const { t, i18n } = useTranslation();
const expires = formatDate(token.expires_at, i18n.language);
return (
<div
{...navItemAttrs(index, token.name, { inset: true })}
{...rowProps}
data-testid="api-token-row"
className="flex items-start gap-3 py-2.5"
>
<KeyRound className="mt-0.5 size-4 shrink-0 text-muted" aria-hidden />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium break-all">{token.name}</span>
<code className="text-xs text-muted">{token.prefix}…</code>
{token.expired ? (
<Badge tone="warn">{t("settings.tokens.expired")}</Badge>
) : (
token.expiring && <Badge tone="warn">{t("settings.tokens.expiring")}</Badge>
)}
</span>
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
{token.grants.map((g) => (
<GrantLine key={g.folder_id} grant={g} />
))}
</span>
<span className="text-xs text-muted">
{token.last_used_at ? (
<>
{t("settings.tokens.lastUsed")} <RelativeTime at={token.last_used_at} />
</>
) : (
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 })}
</span>
</div>
{more}
</div>
);
}
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<ApiToken>({ keyOf: (tk) => tk.id });
const items = (token: ApiToken): ActionMenuItem[] => [
{
id: "revoke",
label: t("settings.tokens.revoke"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
onSelect: () => void onRevoke(token),
},
];
const tokens = tokensQ.data;
return (
<div className="flex flex-col gap-3">
<div className="flex items-start gap-3">
<p className="flex-1 text-xs leading-relaxed text-muted">{t("settings.tokens.intro")}</p>
<Button
variant="primary"
size="sm"
icon={<Plus className="w-4 h-4" />}
onClick={() => setMaking(true)}
>
{t("settings.tokens.new")}
</Button>
</div>
{tokensQ.isError && !tokens ? (
<StateMessage tone="error" onRetry={() => void tokensQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : !tokens ? (
<LoadingState shape="list" />
) : tokens.length === 0 ? (
<p className="py-4 text-sm text-muted">{t("settings.tokens.empty")}</p>
) : (
<NavGrid className="flex flex-col divide-y divide-border/60">
{tokens.map((token, i) => (
<TokenRow
key={token.id}
token={token}
index={i}
rowProps={menu.triggerProps(token)}
more={
<MoreButton
label={t("settings.tokens.rowMenu")}
expanded={menu.open && menu.target?.id === token.id}
menuProps={menu.buttonProps(token)}
/>
}
/>
))}
</NavGrid>
)}
<ContextMenu
menu={menu}
ariaLabel={t("settings.tokens.rowMenu")}
items={menu.target ? items(menu.target) : []}
/>
{making && <NewTokenDialog onClose={() => setMaking(false)} />}
</div>
);
}
// --- making one -----------------------------------------------------------------------------------
type Kind = "hub" | "custom";
type Grants = Record<number, readonly TokenRight[]>;
/** 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<FileFolder[]>;
grants: Grants;
onChange: (g: Grants) => void;
}) {
const { t, i18n } = useTranslation();
if (foldersQ.isError && !foldersQ.data)
return (
<StateMessage tone="error" onRetry={() => void foldersQ.refetch()}>
{t("common.loadError")}
</StateMessage>
);
if (!foldersQ.data) return <LoadingState />;
const rows = grantable(foldersQ.data, t, i18n.language);
if (rows.length === 0)
return <p className="text-sm text-muted">{t("settings.tokens.noFolders")}</p>;
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 (
<div className="flex flex-col divide-y divide-border/50 rounded-lg border border-border/60">
{rows.map((f) => {
const label = folderLabel(f, t);
return (
<div
key={f.id}
role="group"
aria-label={label}
className="flex flex-wrap items-center gap-2 px-2.5 py-2"
>
<span className="flex min-w-28 flex-1 items-center gap-1.5 text-sm">
<Glyph icon={folderIcon(f)} />
<span className="break-all">{label}</span>
</span>
<span className="flex flex-wrap gap-1">
{TOKEN_RIGHTS.map((r) =>
r === "write" && !takesUploads(f) ? null : (
<Chip
key={r}
pressed={(grants[f.id] ?? []).includes(r)}
onClick={() => toggle(f, r)}
>
{t(`settings.tokens.rights.${r}`)}
</Chip>
),
)}
</span>
</div>
);
})}
</div>
);
}
function KindTile({
on,
icon,
title,
text,
onPick,
}: {
on: boolean;
icon: ReactNode;
title: string;
text: string;
onPick: () => void;
}) {
const textId = useId();
return (
<Pressable
aria-pressed={on}
aria-describedby={textId}
onClick={onPick}
className={`${SURFACE.card} flex flex-col items-start gap-1 border p-3 text-left ${
on ? "border-accent" : "border-transparent hover:border-border"
}`}
>
<span className="flex items-center gap-1.5 text-sm font-medium">
{icon}
{title}
</span>
{/* Read after the name, not into it ("Custom" — then what it means). */}
<span id={textId} aria-hidden className="text-xs text-muted">
{text}
</span>
</Pressable>
);
}
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<Kind>("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<string | null>(null);
const name = nameDraft ?? (kind === "hub" ? t("settings.tokens.hubName") : "");
const [grants, setGrants] = useState<Grants>({});
const [expiry, setExpiry] = useState<number | null>(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 (
<Modal
initialFocus="action"
title={t("settings.tokens.madeTitle")}
onClose={onClose}
maxWidth="max-w-md"
>
<div className="flex flex-col gap-3">
<InlineNotice>{t("settings.tokens.once")}</InlineNotice>
<CopyField label={t("settings.tokens.token")} value={made.token} autoFocus />
<CopyField
label={t("settings.tokens.tryIt")}
value={`curl -H "Authorization: Bearer ${made.token}" ${window.location.origin}/api/files/folders`}
/>
</div>
<ModalFooter>
<Button variant="primary" onClick={onClose}>
{t("settings.tokens.done")}
</Button>
</ModalFooter>
</Modal>
);
return (
<Modal
initialFocus="field"
title={t("settings.tokens.newTitle")}
onClose={onClose}
maxWidth="max-w-md"
>
<form
id={`${nameId}-form`}
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
if (ready && !create.isPending) create.mutate();
}}
>
<div
role="group"
aria-label={t("settings.tokens.kind")}
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
>
<KindTile
on={kind === "hub"}
icon={<HubIcon className="size-4" aria-hidden />}
title={t("settings.tokens.hubTitle")}
text={t("settings.tokens.hubText")}
onPick={() => setKind("hub")}
/>
<KindTile
on={kind === "custom"}
icon={<SlidersHorizontal className="size-4" aria-hidden />}
title={t("settings.tokens.customTitle")}
text={t("settings.tokens.customText")}
onPick={() => setKind("custom")}
/>
</div>
<label htmlFor={nameId} className="flex flex-col gap-1">
<span className="text-xs text-muted">{t("settings.tokens.name")}</span>
<Input
id={nameId}
data-initial-focus=""
className="w-full"
maxLength={80}
value={name}
onChange={(e) => setNameDraft(e.target.value)}
/>
</label>
{kind === "custom" && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted">{t("settings.tokens.folders")}</span>
<RightsGrid foldersQ={foldersQ} grants={grants} onChange={setGrants} />
</div>
)}
<div className="flex flex-col gap-1">
<span className="text-xs text-muted" aria-hidden>
{t("settings.tokens.expiry")}
</span>
<SegmentedToggle
size="md"
ariaLabel={t("settings.tokens.expiry")}
className="self-start"
options={TOKEN_EXPIRY_DAYS.map((d) => ({
value: expiryValue(d),
label: t(`settings.tokens.expiryOption.${expiryValue(d)}`),
}))}
value={expiryValue(expiry)}
onChange={(v) => setExpiry(v === "never" ? null : Number(v))}
/>
{expiry === null && <InlineNotice>{t("settings.tokens.neverWarning")}</InlineNotice>}
</div>
</form>
<ModalFooter>
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
variant="primary"
type="submit"
form={`${nameId}-form`}
disabled={!ready || create.isPending}
>
{t("settings.tokens.create")}
</Button>
</ModalFooter>
</Modal>
);
}
/** 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 (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted">{label}</span>
<div className="flex items-start gap-1.5">
<code className="min-w-0 flex-1 break-all rounded-lg border border-border bg-card px-2.5 py-1.5 font-mono text-xs">
{value}
</code>
<Button
variant="outline"
size="sm"
autoFocus={autoFocus}
icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
// Two of these sit in one dialog: each is named by the value it copies, and by the word
// it shows, so a voice command saying what is on screen finds it.
aria-label={t(copied ? "settings.tokens.copiedField" : "settings.tokens.copyField", {
field: label,
})}
onClick={() => void copy(value)}
>
{copied ? t("settings.tokens.copied") : t("settings.tokens.copy")}
</Button>
</div>
</div>
);
}