feat(files): API tokens in Settings, the Hub folder in Files, a notice before a token runs out
Settings gains an API tokens tab: a row per token (name and prefix; each folder with its rights; last use and expiry, "expires soon" marked), revoked from its menu behind a confirm. A new token is the Hub exchange in one click, or folders and rights picked per folder (no Write where an upload cannot land); expiry 30/90 days, 1 year or never (warned). The token shows once in the same dialog, with Copy and a ready curl line. The Hub folder closes the Sources in the Files rail, takes uploads and moved-in files. A token about to run out warns its user once, a week before (the Files GC's sixth pass); the notice opens the tokens tab.
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
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 { copyText } from "../lib/clipboard";
|
||||
import { compareText } from "../lib/collate";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { notify } from "../lib/notifications";
|
||||
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";
|
||||
|
||||
// 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, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
if (await copyText(value)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
return;
|
||||
}
|
||||
// The value is on screen and selectable: a failed copy is a convenience that failed.
|
||||
notify({ level: "warning", message: t("common.copy.failed") });
|
||||
};
|
||||
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" />}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
{copied ? t("settings.tokens.copied") : t("settings.tokens.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user