505 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|