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:
2026-10-14 23:33:34 +02:00
parent 4f58d15d3f
commit db56f25387
39 changed files with 1759 additions and 26 deletions
+3 -2
View File
@@ -16,7 +16,7 @@ import {
type PanelLayout,
} from "./lib/panelLayout";
import { notify, removeByMetaKind } from "./lib/notifications";
import { LS, setTabRaw } from "./lib/storage";
import { LS } from "./lib/storage";
import { usePref } from "./lib/usePref";
import { useVerifyEntry } from "./lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "./components/navigationContext";
@@ -46,6 +46,7 @@ import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import VerifyBanner from "./components/VerifyBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
import { focusSettingsTab } from "./lib/settingsTab";
// The built app version, NOT imported from releaseNotes — importing anything from that module
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
@@ -261,7 +262,7 @@ export default function App() {
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void refetchMe();
setTabRaw(LS.settingsTab, "account");
focusSettingsTab("account");
setPage("settings");
} else {
const key = result === "conflict" || result === "mismatch" ? result : "error";
@@ -0,0 +1,257 @@
// @vitest-environment jsdom
//
// Settings → API tokens: a row per token says what it is, what it opens with which rights, when it was
// last used and when it runs out (marked when that is soon); revoking asks first. A new token is the
// Hub exchange in one click, or folders and rights picked one by one — never Write where an upload
// cannot land — and its value is shown once, with a curl line to try it. i18n is key-passthrough
// (a key's options ride along in brackets, so a test can read what was named).
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type ApiToken, type FileFolder } from "../lib/api";
const mocks = vi.hoisted(() => ({ confirm: vi.fn(async (_o?: unknown) => true) }));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && Object.keys(o).length ? `${key}(${Object.values(o).join(",")})` : key,
}),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
import ApiTokens from "./ApiTokens";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
});
const token = (id: number, extra: Partial<ApiToken> = {}): ApiToken => ({
id,
name: `Token ${id}`,
prefix: `sfl_${id}abcdefg`,
created_at: "2026-10-01T00:00:00Z",
expires_at: "2027-10-01T00:00:00Z",
expired: false,
expiring: false,
last_used_at: null,
grants: [],
...extra,
});
const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
id,
kind: "source",
source_key: null,
name: null,
retention_days: 30,
count: 1,
size_bytes: 0,
created_at: null,
...extra,
});
function mount(tokens: ApiToken[] | Error, folders: FileFolder[] = []) {
if (tokens instanceof Error) vi.spyOn(api, "apiTokens").mockRejectedValue(tokens);
else vi.spyOn(api, "apiTokens").mockResolvedValue(tokens);
vi.spyOn(api, "fileFolders").mockResolvedValue(folders);
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ApiTokens />
</QueryClientProvider>,
);
}
const rows = () => screen.getAllByTestId("api-token-row");
const dialog = () => screen.getByRole("dialog");
const pressed = (b: HTMLElement) => b.getAttribute("aria-pressed") === "true";
const inRow = (group: string, name: string) =>
[...screen.getByRole("group", { name: group }).querySelectorAll("button")].find(
(b) => b.textContent === name,
);
describe("API tokens · the list", () => {
it("reads what a token opens, with which rights, when it was used and when it runs out", async () => {
mount([
token(1, {
name: "Hub exchange",
last_used_at: new Date().toISOString(),
grants: [
{
folder_id: 7,
kind: "hub",
source_key: null,
name: null,
rights: ["write", "list", "read", "delete"],
},
],
}),
token(2, {
expiring: true,
expires_at: null,
grants: [
{ folder_id: 3, kind: "own", source_key: null, name: "Receptek", rights: ["read"] },
],
}),
token(3, { expired: true }),
]);
await waitFor(() => expect(rows()).toHaveLength(3));
const [hub, soon, gone] = rows();
expect(hub!.textContent).toContain("sfl_1abcdefg…");
// Rights in the server's order, whatever order they came in.
expect(hub!.textContent).toContain(
"files.folder.hub· settings.tokens.rights.list, settings.tokens.rights.read, settings.tokens.rights.write, settings.tokens.rights.delete",
);
expect(hub!.textContent).toContain("settings.tokens.lastUsed");
expect(hub!.textContent).not.toContain("settings.tokens.expiring");
expect(soon!.textContent).toContain("Receptek· settings.tokens.rights.read");
expect(soon!.textContent).toContain("settings.tokens.expiring");
expect(soon!.textContent).toContain("settings.tokens.neverUsed");
expect(soon!.textContent).toContain("settings.tokens.neverExpires");
expect(gone!.textContent).toContain("settings.tokens.expired");
expect(gone!.textContent).toContain("settings.tokens.expiredOn(");
});
it("says when there is none, and when the list could not be read", async () => {
mount([]);
expect(await screen.findByText("settings.tokens.empty")).toBeTruthy();
cleanup();
mount(new Error("down"));
expect(await screen.findByText("common.loadError")).toBeTruthy();
expect(screen.queryByText("settings.tokens.empty")).toBeNull();
});
it("revokes from the row's menu, only once the confirm says yes", async () => {
mount([token(5, { name: "Phone" })]);
const revoke = vi.spyOn(api, "revokeApiToken").mockResolvedValue({ ok: true, deleted: 5 });
await waitFor(() => expect(rows()).toHaveLength(1));
mocks.confirm.mockResolvedValueOnce(false);
const open = async () => {
act(() => {
fireEvent.contextMenu(rows()[0]!);
});
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "settings.tokens.revoke")!,
);
};
await open();
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(mocks.confirm.mock.calls[0]![0]).toMatchObject({
title: "settings.tokens.revokeTitle(Phone)",
danger: true,
});
expect(revoke).not.toHaveBeenCalled();
await open();
await waitFor(() => expect(revoke).toHaveBeenCalledWith(5));
});
});
describe("API tokens · making one", () => {
const made = (extra: Partial<ApiToken> = {}) => ({
ok: true as const,
...token(9, extra),
token: "sfl_SECRETVALUE",
});
it("makes the Hub exchange in one click, a year long, and shows the token once with a curl line", async () => {
mount([]);
const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made());
fireEvent.click(await screen.findByText("settings.tokens.new"));
expect(pressed(screen.getByRole("button", { name: "settings.tokens.hubTitle" }))).toBe(true);
expect((screen.getByLabelText("settings.tokens.name") as HTMLInputElement).value).toBe(
"settings.tokens.hubName",
);
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.create" }));
await waitFor(() =>
expect(create).toHaveBeenCalledWith({
name: "settings.tokens.hubName",
expires_days: 365,
preset: "hub",
}),
);
await screen.findByText("settings.tokens.madeTitle");
const codes = [...dialog().querySelectorAll("code")].map((c) => c.textContent);
expect(codes[0]).toBe("sfl_SECRETVALUE");
expect(codes[1]).toBe(
`curl -H "Authorization: Bearer sfl_SECRETVALUE" ${window.location.origin}/api/files/folders`,
);
// The keyboard lands on copying it, not on the closed form's button.
expect(document.activeElement?.textContent).toBe("settings.tokens.copy");
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" }));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("a custom token takes the folders and rights picked; no Write where nothing can be uploaded", async () => {
mount(
[],
[
folder(1, { source_key: "youtube" }),
folder(2, { source_key: "upload" }),
folder(3, { source_key: "site:x.com", count: 0 }), // empty: not offered, as the rail hides it
folder(4, { kind: "hub", count: 0 }),
folder(5, { kind: "own", name: "Receptek" }),
],
);
const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made());
fireEvent.click(await screen.findByText("settings.tokens.new"));
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.customTitle" }));
const name = screen.getByLabelText("settings.tokens.name") as HTMLInputElement;
expect(name.value).toBe("");
await screen.findByRole("group", { name: "Receptek" });
expect(
screen
.getAllByRole("group")
.map((g) => g.getAttribute("aria-label"))
.filter((l) => l !== "settings.tokens.kind" && l !== "settings.tokens.expiry"),
).toEqual(["files.source.upload", "files.source.youtube", "files.folder.hub", "Receptek"]);
expect(inRow("files.source.youtube", "settings.tokens.rights.write")).toBeUndefined();
expect(inRow("files.source.upload", "settings.tokens.rights.write")).toBeTruthy();
expect(inRow("files.folder.hub", "settings.tokens.rights.write")).toBeTruthy();
const createBtn = screen.getByRole("button", { name: "settings.tokens.create" });
fireEvent.change(name, { target: { value: " Phone " } });
expect(createBtn.hasAttribute("disabled")).toBe(true); // a name, but no right yet
fireEvent.click(inRow("Receptek", "settings.tokens.rights.list")!);
fireEvent.click(inRow("Receptek", "settings.tokens.rights.write")!);
fireEvent.click(inRow("files.source.youtube", "settings.tokens.rights.read")!);
// Picked and dropped again: no grant for that folder.
fireEvent.click(inRow("files.folder.hub", "settings.tokens.rights.read")!);
fireEvent.click(inRow("files.folder.hub", "settings.tokens.rights.read")!);
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.expiryOption.30" }));
expect(createBtn.hasAttribute("disabled")).toBe(false);
fireEvent.click(createBtn);
await waitFor(() => expect(create).toHaveBeenCalledTimes(1));
const body = create.mock.calls[0]![0];
expect(body).toMatchObject({ name: "Phone", expires_days: 30 });
expect(body.preset).toBeUndefined();
expect(body.grants).toEqual(
expect.arrayContaining([
{ folder_id: 5, rights: ["list", "write"] },
{ folder_id: 1, rights: ["read"] },
]),
);
expect(body.grants).toHaveLength(2);
});
it("a token that never runs out says what that means, and is sent without an expiry", async () => {
mount([]);
const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made());
fireEvent.click(await screen.findByText("settings.tokens.new"));
expect(screen.queryByText("settings.tokens.neverWarning")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.expiryOption.never" }));
expect(screen.getByText("settings.tokens.neverWarning")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.create" }));
await waitFor(() =>
expect(create).toHaveBeenCalledWith(expect.objectContaining({ expires_days: null })),
);
});
});
+509
View File
@@ -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>
);
}
@@ -155,6 +155,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
});
const YT = folder(1, { source_key: "youtube" });
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 });
afterEach(() => {
cleanup();
@@ -377,6 +378,18 @@ describe("Files · my file's row", () => {
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 1 }));
});
it("moves into the Hub folder too, the one its hub picks files up from", async () => {
mocks.files = [file(2, { folder_id: 1 })];
mocks.folders = [YT, HUB, OWN];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
await waitFor(() => expect(menuRows()).toContain("files.folder.hub"));
fireEvent.click(menuItem("files.folder.hub"));
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 4 }));
});
it("offers no move home while it is home, and says so when there is nowhere to go", async () => {
mocks.files = [file(2, { folder_id: 1 })];
mocks.folders = [YT];
@@ -1043,6 +1056,14 @@ describe("Files · dropping a file on the page", () => {
expect(await screen.findByText("recept.pdf")).toBeTruthy();
});
it("lands in the Hub folder when dropped there", () => {
mocks.files = [];
mocks.folders = [YT, HUB];
mount({ kind: "folder", id: 4 });
drag("dragenter");
expect(screen.getByText("files.drop.here:files.folder.hub")).toBeTruthy();
});
it("is no target where nothing can be uploaded — but the browser never gets the file", () => {
mocks.files = [];
mocks.folders = [YT];
+4 -4
View File
@@ -26,7 +26,7 @@ import { MetaEditModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle";
import { FileCard } from "./FileCard";
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
import { folderLabel } from "./fileFolders";
import { folderLabel, takesUploads } from "./fileFolders";
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
import { useFileActions } from "./useFileActions";
import { useFolderActions } from "./useFolderActions";
@@ -109,12 +109,12 @@ function matches(file: LibraryFile, q: string, lang: string): boolean {
return hay.toLocaleLowerCase(lang).includes(q.toLocaleLowerCase(lang));
}
/** Where an upload from this view lands: the folder itself when it may take one (an own folder, the
* Uploads folder — `folders.upload_target`), else the Uploads folder. */
/** Where an upload from this view lands: the folder itself when it may take one (`takesUploads`),
* else the Uploads folder. */
function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number | null {
if (view.kind !== "folder") return null;
const f = folders.find((x) => x.id === view.id);
return f && (f.kind === "own" || f.source_key === "upload") ? f.id : null;
return f && takesUploads(f) ? f.id : null;
}
/** The files the viewer pages through: the list's, and the open one even when the list moved on
@@ -136,6 +136,19 @@ describe("FilesRail · folders", () => {
await waitFor(() => expect(menuRows()).toEqual(["files.folder.retention"]));
});
it("the Hub folder closes the sources, shown while empty, its menu only its retention", async () => {
const hub = folder(4, { kind: "hub", count: 0, retention_days: null });
await mount([hub, folder(3, { source_key: "site:tiktok.com" }), YT, OWN]);
const sources = entry("files.source.youtube").parentElement!;
const names = [...sources.querySelectorAll("button")].map((b) => b.textContent);
// Last, though its name sorts first: the sources by name, then the Hub folder.
expect(names).toEqual(["files.source.youtube1", "tiktok.com1", "files.folder.hub0"]);
act(() => {
fireEvent.contextMenu(entry("files.folder.hub"));
});
await waitFor(() => expect(menuRows()).toEqual(["files.folder.retention"]));
});
it("renames in the entry, and Enter hands the keyboard back to it", async () => {
await mount();
const update = vi.spyOn(api, "updateFolder").mockResolvedValue(OWN);
+5 -1
View File
@@ -124,6 +124,9 @@ export default function FilesRail({
compareText(folderLabel(a, t), folderLabel(b, t), i18n.language);
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
const own = folders.filter((f) => f.kind === "own").sort(byName);
// The Hub folder closes the sources, shown even while empty: it exists only once a hub token made
// it, and an empty one is where the hub's files will turn up.
const hub = folders.filter((f) => f.kind === "hub");
const [renamingId, setRenamingId] = useState<number | null>(null);
const [creating, setCreating] = useState(false);
@@ -202,8 +205,9 @@ export default function FilesRail({
<div className="flex flex-col gap-0.5">
{failed}
{sources.map(entry)}
{hub.map(entry)}
{/* Only when there is nothing at all: files moved into own folders were still downloaded. */}
{foldersQ.data && sources.length === 0 && total === 0 && (
{foldersQ.data && sources.length + hub.length === 0 && total === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noSources")}</span>
)}
</div>
@@ -16,6 +16,7 @@ const h = vi.hoisted(() => ({
client: [] as unknown[],
setPage: vi.fn(),
openFile: vi.fn(),
focusSettingsTab: vi.fn(),
setFilters: vi.fn(),
remove: vi.fn(),
clearAll: vi.fn(),
@@ -44,6 +45,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
remove: (id: number) => h.remove(id),
clearAll: () => h.clearAll(),
}));
vi.mock("../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab }));
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) }));
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) }));
@@ -83,7 +85,15 @@ import PageShell from "./ui/PageShell";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
for (const f of [h.setPage, h.openFile, h.setFilters, h.remove, h.clearAll, h.confirm])
for (const f of [
h.setPage,
h.openFile,
h.focusSettingsTab,
h.setFilters,
h.remove,
h.clearAll,
h.confirm,
])
f.mockClear();
});
@@ -171,6 +181,26 @@ describe("NotificationsPanel · a server row", () => {
expect(h.openFile).toHaveBeenCalledTimes(1);
});
it("a token about to run out is named in the reader's words and opens Settings on its tab", async () => {
h.server = [
server(1, {
type: "api_token_expiring",
title: "API token Phone expires soon",
data: { token_id: 4, name: "Phone", expires_at: "2026-10-21T10:00:00+00:00" },
}),
];
h.client = [];
mount();
const row = rowOf("inbox.apiTokenExpiring.title");
expect(row.textContent).toContain("inbox.apiTokenExpiring.body");
expect(row.textContent).not.toContain("API token Phone expires soon");
fireEvent.click(within(row).getByText("inbox.openTokens"));
expect(h.focusSettingsTab).toHaveBeenCalledWith("tokens");
expect(h.setPage).toHaveBeenCalledWith("settings");
await openMenu(() => fireEvent.click(moreOf("inbox.apiTokenExpiring.title")));
expect(menuRows()).toEqual(["inbox.openTokens", "inbox.markRead", "inbox.dismiss"]);
});
it("holds the link, mark read and dismiss", async () => {
h.server = [server(1)];
h.client = [];
+39 -3
View File
@@ -11,6 +11,7 @@ import {
ExternalLink,
Eye,
FolderOpen,
KeyRound,
RotateCcw,
Search,
Trash2,
@@ -23,6 +24,7 @@ import { jobRunText } from "../lib/jobResult";
import { notifyActionError } from "../lib/actionErrors";
import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
import { focusSettingsTab } from "../lib/settingsTab";
import {
clearAll as clearClient,
dismiss as dismissClient,
@@ -37,7 +39,7 @@ import {
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../lib/notifications";
import { channelYouTubeUrl } from "../lib/format";
import { channelYouTubeUrl, formatDate } from "../lib/format";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LEVEL_STYLE } from "./ui/toastLevels";
@@ -186,6 +188,10 @@ export default function NotificationsPanel() {
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
const openScheduler = () => setPage("scheduler");
const openTokens = () => {
focusSettingsTab("tokens");
setPage("settings");
};
// A finished download opens on its file, in its folder; a notice from before it named one, on Files.
const openDone = (n: AppNotification) => {
const { job_id: id, folder_id: folder } = n.data ?? {};
@@ -225,7 +231,16 @@ export default function NotificationsPanel() {
onSelect: () => openDone(n),
},
]
: []),
: kind === "token"
? [
{
id: "open",
label: t("inbox.openTokens"),
icon: icon(KeyRound),
onSelect: openTokens,
},
]
: []),
...(!n.read
? [
{
@@ -397,6 +412,7 @@ export default function NotificationsPanel() {
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={openScheduler}
onOpenFile={() => openDone(n)}
onOpenTokens={openTokens}
/>
);
})}
@@ -462,9 +478,10 @@ const DONE_TITLE: Record<string, string | undefined> = {
upload: "inbox.uploadDone.title",
};
function serverKind(n: AppNotification): "scheduler" | "download" | null {
function serverKind(n: AppNotification): "scheduler" | "download" | "token" | null {
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
if (n.type === "download_done") return "download";
if (n.type === "api_token_expiring" && typeof n.data?.name === "string") return "token";
return null;
}
@@ -511,6 +528,7 @@ function NotificationRow({
onDismiss,
onOpenScheduler,
onOpenFile,
onOpenTokens,
}: {
n: AppNotification;
t: TFunction;
@@ -521,6 +539,7 @@ function NotificationRow({
onDismiss: () => void;
onOpenScheduler: () => void;
onOpenFile: () => void;
onOpenTokens: () => void;
}) {
const lang = useTranslation().i18n.language;
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
@@ -532,6 +551,7 @@ function NotificationRow({
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
const isScheduler = serverKind(n) === "scheduler";
const isDownloadDone = serverKind(n) === "download";
const isTokenExpiring = serverKind(n) === "token";
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
// A download's life after it is done: shared with you, about to expire, expired, evicted.
const lifecycle = DOWNLOAD_LIFECYCLE[n.type];
@@ -566,6 +586,11 @@ function NotificationRow({
const d = n.data as { title?: string | null; from?: string };
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
body = d.title || null;
} else if (isTokenExpiring) {
// A machine's key to the library runs out within a week: which one, and when.
const d = n.data as { name: string; expires_at?: string | null };
title = t("inbox.apiTokenExpiring.title");
body = t("inbox.apiTokenExpiring.body", { name: d.name, date: formatDate(d.expires_at, lang) });
} else if (isDownloadDone) {
// "Download ready" / "Edit ready" / "Upload ready" per the producer's kind, with the video's title
// as the body (shipped in data.title). Same notification type; the copy differs so an edit or an
@@ -614,6 +639,17 @@ function NotificationRow({
{t("inbox.openScheduler")}
</Button>
)}
{isTokenExpiring && (
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenTokens}
icon={<KeyRound className="w-3.5 h-3.5" />}
>
{t("inbox.openTokens")}
</Button>
)}
{isDownloadDone && (
<Button
variant="link"
+6 -1
View File
@@ -9,6 +9,7 @@ import {
Bell,
Check,
Download,
KeyRound,
Loader2,
Monitor,
RefreshCw,
@@ -17,6 +18,8 @@ import {
} from "lucide-react";
import { DownloadFormats } from "./ProfileEditor";
import DiagnosticsSettings from "./DiagnosticsSettings";
import ApiTokens from "./ApiTokens";
import { type SettingsTabId } from "../lib/settingsTab";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { displayName, roleKey } from "../lib/api/account";
@@ -61,11 +64,12 @@ export interface PrefsController {
saveState: PrefsSaveState;
}
type TabId = "appearance" | "notifications" | "downloads" | "account" | "diagnostics";
type TabId = SettingsTabId;
const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor },
{ id: "notifications", icon: Bell },
{ id: "downloads", icon: Download },
{ id: "tokens", icon: KeyRound },
{ id: "account", icon: User },
{ id: "diagnostics", icon: Activity },
];
@@ -114,6 +118,7 @@ export default function SettingsPanel() {
{tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "downloads" && <Downloads />}
{tab === "tokens" && <ApiTokens />}
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
{tab === "diagnostics" && <DiagnosticsSettings />}
</div>
+15 -4
View File
@@ -1,10 +1,11 @@
import { Film, Folder, Globe, Upload, type LucideIcon } from "lucide-react";
import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import type { FileFolder } from "../lib/api";
// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
// (`youtube`, `plex`, `upload`, `site:<host>`, see backend downloads/folders.py) and leaves
// its name to the client, in the reader's language; an own folder carries the name its user gave it.
// its name to the client, in the reader's language; an own folder carries the name its user gave it;
// the Hub folder (one per user, made by a hub token) is named by the client too.
const SITE_PREFIX = "site:";
@@ -24,10 +25,20 @@ export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
return Globe;
}
export function folderLabel(folder: FileFolder, t: T): string {
type Folderish = Pick<FileFolder, "kind" | "source_key" | "name">;
export function folderLabel(folder: Folderish, t: T): string {
if (folder.kind === "hub") return t("files.folder.hub");
return folder.kind === "own" ? (folder.name ?? "") : sourceLabel(folder.source_key ?? "", t);
}
export function folderIcon(folder: FileFolder): LucideIcon | typeof YouTubeIcon {
export function folderIcon(folder: Folderish): LucideIcon | typeof YouTubeIcon {
if (folder.kind === "hub") return Server;
return folder.kind === "own" ? Folder : sourceIcon(folder.source_key ?? "");
}
/** Whether an upload may land in `folder` (the backend's `folders.upload_target`): an own folder,
* the Hub folder, or Uploads — a file put in a site's folder would claim an origin it does not have. */
export function takesUploads(folder: Folderish): boolean {
return folder.kind !== "source" || folder.source_key === "upload";
}
@@ -17,6 +17,10 @@ const files = readSources(__dirname, [
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record<string, [number, string]> = {
"ApiTokens.tsx": [
1,
"a token kind's tile in the new-token dialog (an option: its text is its label)",
],
"DownloadTray.tsx": [
2,
"the folded pill (its summary is its label); a failure's reason, which opens its details",
+5 -1
View File
@@ -186,7 +186,11 @@ export function useFileActions({
},
],
};
const own = folders.filter((f) => f.kind === "own");
// The user's own folders, then the Hub folder: the ones a file may be moved into from anywhere.
const own = [
...folders.filter((f) => f.kind === "own"),
...folders.filter((f) => f.kind === "hub"),
];
const home = folders.find((f) => f.kind === "source" && f.source_key === file.source_key);
const to = (f: FileFolder) => () => update.mutate({ id: file.id, patch: { folder_id: f.id } });
const items: ActionMenuItem[] = own.map((f) => ({
+12
View File
@@ -5,6 +5,7 @@ import { BatchB } from "./batchB";
import { BatchC } from "./batchC";
import { BatchD } from "./batchD";
import { FilesRound } from "./filesRound";
import { TokensRound } from "./tokensRound";
import { Chip } from "../components/ui/Chip";
import { Slider } from "../components/ui/NativeControls";
import { SURFACE } from "../components/ui/surfaces";
@@ -75,6 +76,9 @@ export default function DesignLab() {
/>
</label>
<nav className="flex flex-wrap gap-1 text-xs">
<a href="#open-tokens" className="rounded-md px-1.5 py-0.5 text-accent state-layer">
Tokens
</a>
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-accent state-layer">
Files
</a>
@@ -101,6 +105,14 @@ export default function DesignLab() {
Resize the window to tablet size to see them there too.
</p>
</header>
<header id="open-tokens" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · API tokens, a machine&apos;s key to Files</h2>
<p className="text-sm text-muted">
Settings → API tokens. Picked: A, A, A — the variants with the accent edge (built; the
expiry is a segmented control, DESIGN.md "A choice between a few values").
</p>
</header>
<TokensRound />
<header id="open-files" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Open · Files, the one library</h2>
<p className="text-sm text-muted">
+515
View File
@@ -0,0 +1,515 @@
import clsx from "clsx";
import type { ReactNode } from "react";
import {
AlertTriangle,
Check,
Copy,
Folder,
KeyRound,
MoreHorizontal,
Plus,
Server,
SlidersHorizontal,
X,
} from "lucide-react";
import { Decision, Sample, Variant } from "./labKit";
import { Badge } from "../components/ui/Badge";
import { Chip } from "../components/ui/Chip";
import { SURFACE } from "../components/ui/surfaces";
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
import { fieldClass } from "../components/ui/fieldStyles";
// The API tokens round: how a machine (the hub) gets a key to the Files library. Static markup on
// purpose (the lab's rule); the sample tokens are the owner's own kind (the hub, a phone script).
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
const noop = () => {};
type Right = "list" | "read" | "write" | "delete";
const RIGHT_LABEL: Record<Right, string> = {
list: "Lista",
read: "Olvasás",
write: "Írás",
delete: "Törlés",
};
type Token = {
name: string;
prefix: string;
grants: { folder: string; hub?: boolean; rights: Right[] }[];
used: string;
expires: string;
soon?: boolean;
};
const TOKENS: Token[] = [
{
name: "Hub csere",
prefix: "sfl_7Qm2xK9a",
grants: [{ folder: "Hub", hub: true, rights: ["list", "read", "write", "delete"] }],
used: "12 perce",
expires: "2027. okt. 14.",
},
{
name: "Telefon – receptek",
prefix: "sfl_Bw41LdZr",
grants: [
{ folder: "Receptek", rights: ["list", "read"] },
{ folder: "Cimboráknak", rights: ["list", "read", "write"] },
],
used: "soha",
expires: "6 nap múlva",
soon: true,
},
];
/** The Settings page frame the tab lives in: the tab rail on the left, the tab on the right. */
function SettingsFrame({ children }: { children: ReactNode }) {
return (
<div className={clsx(SURFACE.panel, "flex max-w-3xl overflow-hidden")}>
<div className="hidden w-36 shrink-0 flex-col gap-1 border-r border-border/60 p-2 text-sm sm:flex">
{["Megjelenés", "Értesítések", "Letöltések", "API-tokenek", "Fiók"].map((l) => (
<span
key={l}
className={clsx(
"rounded-lg px-2.5 py-1.5",
l === "API-tokenek" ? "bg-accent/15 text-accent font-medium" : "text-muted",
)}
>
{l}
</span>
))}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3 p-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<h3 className="text-sm font-semibold">API-tokenek</h3>
<p className="text-xs leading-relaxed text-muted">
Egy token egy gépnek ad kulcsot a Fájlok könyvtár kijelölt mappáihoz — semmi máshoz.
</p>
</div>
<span className={buttonClass({ variant: "primary", size: "sm" })}>
<Plus className="size-4" />
Új token
</span>
</div>
{children}
</div>
</div>
);
}
function RightsLine({ grants }: { grants: Token["grants"] }) {
return (
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
{grants.map((g) => (
<span key={g.folder} className="flex items-center gap-1.5">
{g.hub ? <Server className="size-3.5" /> : <Folder className="size-3.5" />}
<span className="text-fg">{g.folder}</span>
<span>· {g.rights.map((r) => RIGHT_LABEL[r].toLowerCase()).join(", ")}</span>
</span>
))}
</span>
);
}
// ── T1 · the list ─────────────────────────────────────────────────────────────────────────────
function ListRows() {
return (
<div className="flex flex-col divide-y divide-border/60">
{TOKENS.map((tk) => (
<div key={tk.prefix} className="flex items-start gap-3 py-2.5">
<KeyRound className="mt-0.5 size-4 shrink-0 text-muted" />
<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">{tk.name}</span>
<code className="text-xs text-muted">{tk.prefix}…</code>
{tk.soon && <Badge tone="warn">Hamarosan lejár</Badge>}
</span>
<span className="text-xs text-muted">
<RightsLine grants={tk.grants} />
</span>
<span className="text-xs text-muted">
Utoljára használva: {tk.used} · Lejár: {tk.expires}
</span>
</div>
<span className={ICON} aria-label="Műveletek">
<MoreHorizontal className="size-4" />
</span>
</div>
))}
</div>
);
}
function ListTable() {
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="text-muted">
<tr className="border-b border-border/60">
<th className="py-1.5 pr-3 font-medium">Név</th>
<th className="py-1.5 pr-3 font-medium">Mappák</th>
<th className="py-1.5 pr-3 font-medium">Használva</th>
<th className="py-1.5 pr-3 font-medium">Lejár</th>
<th />
</tr>
</thead>
<tbody>
{TOKENS.map((tk) => (
<tr key={tk.prefix} className="border-b border-border/40 align-top">
<td className="py-2 pr-3">
<div className="text-sm font-medium">{tk.name}</div>
<code className="text-muted">{tk.prefix}…</code>
</td>
<td className="py-2 pr-3 text-muted">{tk.grants.map((g) => g.folder).join(", ")}</td>
<td className="py-2 pr-3 text-muted whitespace-nowrap">{tk.used}</td>
<td
className={clsx(
"py-2 pr-3 whitespace-nowrap",
tk.soon ? "text-warn" : "text-muted",
)}
>
{tk.expires}
</td>
<td className="py-1">
<span className={ICON} aria-label="Műveletek">
<MoreHorizontal className="size-4" />
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── T2 · the new-token dialog ───────────────────────────────────────────────────────────────────
function Dialog({
title,
children,
foot,
}: {
title: string;
children: ReactNode;
foot: ReactNode;
}) {
return (
<div className={clsx(SURFACE.panel, "flex w-full max-w-md flex-col shadow-float")}>
<div className="flex items-center gap-2 border-b border-border/60 px-4 py-3">
<h4 className="flex-1 text-sm font-semibold">{title}</h4>
<span className={ICON} aria-label="Bezárás">
<X className="size-4" />
</span>
</div>
<div className="flex flex-col gap-3 p-4 text-sm">{children}</div>
<div className="flex justify-end gap-2 border-t border-border/60 px-4 py-3">{foot}</div>
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">{label}</span>
{children}
</label>
);
}
function Expiry({ never = false }: { never?: boolean }) {
return (
<Field label="Lejárat">
<span className="flex flex-wrap gap-1">
{["30 nap", "90 nap", "1 év", "Soha"].map((l) => (
<Chip key={l} pressed={never ? l === "Soha" : l === "1 év"} onClick={noop}>
{l}
</Chip>
))}
</span>
{never && (
<span className="flex items-start gap-1.5 text-xs text-warn">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
Egy soha le nem járó token addig nyitja a mappát, amíg vissza nem vonod.
</span>
)}
</Field>
);
}
const FOLDERS: { name: string; kind: "hub" | "own" | "source"; on: Right[] }[] = [
{ name: "Hub", kind: "hub", on: [] },
{ name: "Receptek", kind: "own", on: ["list", "read"] },
{ name: "Cimboráknak", kind: "own", on: ["list", "read", "write"] },
{ name: "YouTube", kind: "source", on: [] },
];
function RightsMatrix() {
return (
<div className="flex flex-col divide-y divide-border/50 rounded-lg border border-border/60">
{FOLDERS.map((f) => (
<div key={f.name} 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">
{f.kind === "hub" ? <Server className="size-3.5" /> : <Folder className="size-3.5" />}
{f.name}
</span>
<span className="flex gap-1">
{(Object.keys(RIGHT_LABEL) as Right[]).map((r) =>
// A source folder fills itself: nothing can be uploaded into it, so no Write.
f.kind === "source" && r === "write" ? null : (
<Chip key={r} pressed={f.on.includes(r)} onClick={noop}>
{RIGHT_LABEL[r]}
</Chip>
),
)}
</span>
</div>
))}
</div>
);
}
function PresetFirst({ custom }: { custom: boolean }) {
return (
<Dialog
title="Új API-token"
foot={
<>
<span className={buttonClass({ variant: "outline", size: "sm" })}>Mégse</span>
<span className={buttonClass({ variant: "primary", size: "sm" })}>Létrehozás</span>
</>
}
>
<div className="grid grid-cols-2 gap-2">
{[
{
on: !custom,
icon: Server,
title: "Hub csere",
text: "Egy közös Hub mappa; a token mindent tehet benne.",
},
{
on: custom,
icon: SlidersHorizontal,
title: "Egyedi",
text: "Te választod a mappákat és a jogokat.",
},
].map((c) => (
<span
key={c.title}
className={clsx(
SURFACE.card,
"flex flex-col gap-1 border p-3",
c.on ? "border-accent" : "border-transparent",
)}
>
<span className="flex items-center gap-1.5 font-medium">
<c.icon className="size-4" />
{c.title}
</span>
<span className="text-xs text-muted">{c.text}</span>
</span>
))}
</div>
<Field label="Név">
<span className={clsx(fieldClass({ size: "sm" }), "flex items-center")}>
{custom ? "Telefon – receptek" : "Hub csere"}
</span>
</Field>
{custom && <RightsMatrix />}
<Expiry />
</Dialog>
);
}
function OneForm() {
return (
<Dialog
title="Új API-token"
foot={
<>
<span className={buttonClass({ variant: "outline", size: "sm" })}>Mégse</span>
<span className={buttonClass({ variant: "primary", size: "sm" })}>Létrehozás</span>
</>
}
>
<Field label="Név">
<span className={clsx(fieldClass({ size: "sm" }), "flex items-center")}>Hub csere</span>
</Field>
<span className="flex items-center gap-2 text-xs text-muted">
Mappák és jogok
<span className={buttonClass({ variant: "link", size: "none" })}>Hub csere kitöltése</span>
</span>
<RightsMatrix />
<Expiry never />
</Dialog>
);
}
// ── T3 · the token, shown once ──────────────────────────────────────────────────────────────────
const RAW = "sfl_7Qm2xK9aVt3pLc8RzN0eWq5uYb1HdJ6gFs4kMx2oTi9a3F7kQe";
function CopyRow({ children, mono = true }: { children: ReactNode; mono?: boolean }) {
return (
<span className="flex items-stretch gap-1.5">
<code
className={clsx(
fieldClass({ size: "sm" }),
"flex min-w-0 flex-1 items-center break-all h-auto py-1.5",
mono && "font-mono text-xs",
)}
>
{children}
</code>
<span className={buttonClass({ variant: "outline", size: "sm" })}>
<Copy className="size-4" />
Másolás
</span>
</span>
);
}
function ShownInDialog() {
return (
<Dialog
title="A token elkészült"
foot={<span className={buttonClass({ variant: "primary", size: "sm" })}>Kész</span>}
>
<span className="flex items-start gap-1.5 text-xs text-warn">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
Most látod utoljára: mi csak a lenyomatát őrizzük. Ha elveszik, csinálj újat.
</span>
<Field label="Token">
<CopyRow>{RAW}</CopyRow>
</Field>
<Field label="Kipróbálás (curl)">
<CopyRow>
curl -H &quot;Authorization: Bearer {RAW.slice(0, 12)}…&quot;
https://siftlode.example/api/files/folders
</CopyRow>
</Field>
</Dialog>
);
}
function ShownAsBanner() {
return (
<SettingsFrame>
<div className="flex flex-col gap-2 rounded-lg border border-accent/50 bg-accent/6 p-3">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Check className="size-4 text-success" />
„Hub csere” elkészült — másold ki most, később nem látod.
</span>
<CopyRow>{RAW}</CopyRow>
<span className={clsx(buttonClass({ variant: "link", size: "none" }), "self-start")}>
curl példa másolása
</span>
</div>
<ListRows />
</SettingsFrame>
);
}
// ── The round ───────────────────────────────────────────────────────────────────────────────────
export function TokensRound() {
return (
<div className="flex flex-col gap-6">
<Decision
id="open-tokens-t1"
code="T1"
title="The token list (Settings → API tokens)"
question="How does the tab list the tokens: what each opens, when it was last used, when it runs out? Revoke is in each row's “…” menu, behind a confirm."
now="There is no token UI; tokens exist only through the API."
>
<div className="grid gap-4 xl:grid-cols-2">
<Variant
letter="A"
name="Rows, three lines each"
recommended
why="The Settings column is ~36rem: a row reads name + prefix, then every folder with its rights, then last use and expiry — nothing truncated, and the “soon expires” badge sits beside the name. Fits a phone as is."
>
<SettingsFrame>
<ListRows />
</SettingsFrame>
</Variant>
<Variant
letter="B"
name="A table"
why="Scans in columns like the Download formats table, but at this width the folders column truncates to names (rights drop out) and a phone needs the card fallback anyway."
>
<SettingsFrame>
<ListTable />
</SettingsFrame>
</Variant>
</div>
</Decision>
<Decision
id="open-tokens-t2"
code="T2"
title="Making a token"
question="How do you make one: the one-click Hub csere, or a token of your own folders and rights? Expiry: 30 days, 90 days, 1 year (default) or never (with a warning)."
now="Nothing in the app."
>
<div className="grid gap-4 xl:grid-cols-3">
<Variant
letter="A"
name="Pick the kind first — Hub csere"
recommended
why="The hub is the case you have: one tile, the name filled in, Create. The rights grid only appears for “Egyedi”, so the common token never shows a matrix to get wrong."
>
<PresetFirst custom={false} />
</Variant>
<Variant
letter="A"
name="…the same, Egyedi"
recommended
why="Each folder a row, its rights as chips. A source folder has no Write: nothing can be uploaded into it (the server refuses it too)."
>
<PresetFirst custom />
</Variant>
<Variant
letter="B"
name="One form, a preset link"
why="Everything at once, with “Hub csere kitöltése” filling the grid. Fewer clicks for a custom token, but the hub case starts from a matrix. Shown with “never” picked, and its warning."
>
<OneForm />
</Variant>
</div>
</Decision>
<Decision
id="open-tokens-t3"
code="T3"
title="The token, shown once"
question="Only its fingerprint is stored, so the token can be copied once. Where does it show, with a ready curl line to try it?"
now="Nothing in the app."
>
<div className="grid gap-4 xl:grid-cols-2">
<Variant
letter="A"
name="The dialog's last step"
recommended
why="You are already looking at the dialog: the token, a Copy, a curl line to try it, and “Kész”. Closing is the moment it is gone, and it is plain that it is."
>
<Sample label="after Létrehozás">
<ShownInDialog />
</Sample>
</Variant>
<Variant
letter="B"
name="A banner above the list"
why="The dialog closes and the token waits above the list until you leave the tab. The new row is right under it, but a banner is easy to scroll past — and leaving the tab loses it for good."
>
<ShownAsBanner />
</Variant>
</div>
</Decision>
</div>
);
}
+4
View File
@@ -110,6 +110,10 @@
"rename": "Couldn't rename the folder",
"retention": "Couldn't change how long the folder keeps its files",
"delete": "Couldn't delete the folder"
},
"apiToken": {
"create": "Couldn't create the token",
"revoke": "Couldn't revoke the token"
}
},
"ok": "OK",
+1
View File
@@ -75,6 +75,7 @@
"menu": "Folder actions",
"new": "New folder",
"newName": "Folder name",
"hub": "Hub",
"rename": "Rename",
"retention": "Keeps files",
"forever": "Until I delete them",
+5
View File
@@ -15,6 +15,7 @@
"andMore_other": "and {{count}} more",
"openScheduler": "Open Scheduler",
"openFile": "Open the file",
"openTokens": "Open API tokens",
"revertFailed": "Couldn't undo that — please try again",
"downloadDone": {
"title": "Download ready"
@@ -46,6 +47,10 @@
"downloadExpiring": {
"title": "A download expires soon"
},
"apiTokenExpiring": {
"title": "An API token expires soon",
"body": "“{{name}}” stops working on {{date}}."
},
"downloadExpired": {
"title": "A download expired and was removed"
},
@@ -145,6 +145,7 @@
"evicted": "evicted",
"reaped": "cleaned up",
"swept": "swept",
"tokens_warned": "tokens warned",
"libraries": "libraries",
"movies": "movies",
"shows": "shows",
+51 -1
View File
@@ -5,7 +5,8 @@
"notifications": "Notifications",
"account": "Account",
"diagnostics": "Diagnostics",
"downloads": "Downloads"
"downloads": "Downloads",
"tokens": "API tokens"
},
"plexSync": {
"title": "Plex watch sync",
@@ -235,5 +236,54 @@
"downloads": {
"formats": "Download formats",
"formatsIntro": "The choices the download dialog offers. The built-in ones are fixed; duplicate one to make your own."
},
"tokens": {
"intro": "A token gives one machine a key to the folders you pick in your Files library — and to nothing else.",
"new": "New token",
"empty": "No tokens yet. Make one for your hub or for a script of yours.",
"rowMenu": "Token actions",
"expiring": "Expires soon",
"expired": "Expired",
"lastUsed": "Last used",
"neverUsed": "Never used",
"expiresOn": "Expires {{date}}",
"expiredOn": "Expired {{date}}",
"neverExpires": "Never expires",
"revoke": "Revoke…",
"revokeTitle": "Revoke “{{name}}”?",
"revokeBody": "The machine that uses it loses access at its next request. This is final: a new token has to be made in its place.",
"revokeAction": "Revoke",
"rights": {
"list": "List",
"read": "Read",
"write": "Write",
"delete": "Delete"
},
"newTitle": "New API token",
"kind": "Kind of token",
"hubTitle": "Hub exchange",
"hubText": "One shared Hub folder; the token may do anything in it.",
"hubName": "Hub exchange",
"customTitle": "Custom",
"customText": "You pick the folders and the rights.",
"name": "Name",
"folders": "Folders and rights",
"noFolders": "You have no folders yet.",
"expiry": "Expires",
"expiryOption": {
"30": "30 days",
"90": "90 days",
"365": "1 year",
"never": "Never"
},
"neverWarning": "A token that never expires opens its folders until you revoke it.",
"create": "Create",
"madeTitle": "Your token is ready",
"once": "This is the only time you see it: only its fingerprint is kept. If you lose it, make a new one.",
"token": "Token",
"tryIt": "Try it (curl)",
"copy": "Copy",
"copied": "Copied",
"done": "Done"
}
}
+4
View File
@@ -110,6 +110,10 @@
"rename": "Nem sikerült átnevezni a mappát",
"retention": "Nem sikerült módosítani a mappa megőrzési idejét",
"delete": "Nem sikerült törölni a mappát"
},
"apiToken": {
"create": "Nem sikerült létrehozni a tokent",
"revoke": "Nem sikerült visszavonni a tokent"
}
},
"ok": "OK",
+1
View File
@@ -75,6 +75,7 @@
"menu": "Mappa műveletei",
"new": "Új mappa",
"newName": "Mappa neve",
"hub": "Hub",
"rename": "Átnevezés",
"retention": "Fájlok megőrzése",
"forever": "Amíg ki nem törlöm",
+5
View File
@@ -15,6 +15,7 @@
"andMore_other": "és még {{count}}",
"openScheduler": "Ütemező megnyitása",
"openFile": "Fájl megnyitása",
"openTokens": "API-tokenek megnyitása",
"revertFailed": "Nem sikerült visszavonni — próbáld újra",
"downloadDone": {
"title": "Letöltés kész"
@@ -46,6 +47,10 @@
"downloadExpiring": {
"title": "Hamarosan lejár egy letöltés"
},
"apiTokenExpiring": {
"title": "Hamarosan lejár egy API-token",
"body": "„{{name}}” lejár: {{date}}."
},
"downloadExpired": {
"title": "Egy letöltés lejárt, és törlődött"
},
@@ -145,6 +145,7 @@
"evicted": "helyhiány miatt törölve",
"reaped": "eltakarítva",
"swept": "kisöpörve",
"tokens_warned": "token figyelmeztetve",
"libraries": "könyvtár",
"movies": "film",
"shows": "sorozat",
+51 -1
View File
@@ -5,7 +5,8 @@
"notifications": "Értesítések",
"account": "Fiók",
"diagnostics": "Diagnosztika",
"downloads": "Letöltések"
"downloads": "Letöltések",
"tokens": "API-tokenek"
},
"plexSync": {
"title": "Plex nézettség-szinkron",
@@ -235,5 +236,54 @@
"downloads": {
"formats": "Letöltési formátumok",
"formatsIntro": "Ezek közül választhatsz a letöltés ablakában. A beépítettek nem módosíthatók; másold le valamelyiket, ha sajátot szeretnél."
},
"tokens": {
"intro": "Egy token egy gépnek ad kulcsot a Fájlok könyvtár általad kijelölt mappáihoz — semmi máshoz.",
"new": "Új token",
"empty": "Még nincs tokened. Csinálj egyet a hubodnak vagy egy saját szkriptnek.",
"rowMenu": "Token műveletei",
"expiring": "Hamarosan lejár",
"expired": "Lejárt",
"lastUsed": "Utoljára használva:",
"neverUsed": "Még nem használták",
"expiresOn": "Lejár: {{date}}",
"expiredOn": "Lejárt: {{date}}",
"neverExpires": "Nem jár le",
"revoke": "Visszavonás…",
"revokeTitle": "Visszavonod: „{{name}}”?",
"revokeBody": "Az a gép, amelyik használja, a következő kérésénél elveszíti a hozzáférést. Ez végleges: helyette új tokent kell csinálni.",
"revokeAction": "Visszavonás",
"rights": {
"list": "Lista",
"read": "Olvasás",
"write": "Írás",
"delete": "Törlés"
},
"newTitle": "Új API-token",
"kind": "A token fajtája",
"hubTitle": "Hub csere",
"hubText": "Egy közös Hub mappa; a token mindent tehet benne.",
"hubName": "Hub csere",
"customTitle": "Egyedi",
"customText": "Te választod a mappákat és a jogokat.",
"name": "Név",
"folders": "Mappák és jogok",
"noFolders": "Még nincs mappád.",
"expiry": "Lejárat",
"expiryOption": {
"30": "30 nap",
"90": "90 nap",
"365": "1 év",
"never": "Soha"
},
"neverWarning": "Egy soha le nem járó token addig nyitja a mappáit, amíg vissza nem vonod.",
"create": "Létrehozás",
"madeTitle": "A token elkészült",
"once": "Most látod utoljára: csak a lenyomatát őrizzük. Ha elveszik, csinálj újat.",
"token": "Token",
"tryIt": "Kipróbálás (curl)",
"copy": "Másolás",
"copied": "Másolva",
"done": "Kész"
}
}
+4
View File
@@ -63,6 +63,9 @@ export type * from "./api/downloads";
import { filesApi } from "./api/files";
export type * from "./api/files";
export { FILES_LIST_CAP, FILE_SORTS } from "./api/files";
import { tokensApi } from "./api/tokens";
export type * from "./api/tokens";
export { TOKEN_EXPIRY_DAYS, TOKEN_EXPIRY_DEFAULT, TOKEN_RIGHTS } from "./api/tokens";
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
export type * from "./api/diagnostics";
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
@@ -84,6 +87,7 @@ export const api = {
...plexApi,
...downloadsApi,
...filesApi,
...tokensApi,
...diagnosticsApi,
...debugSessionsApi,
};
+1
View File
@@ -33,6 +33,7 @@ const SLICE_MODULES = [
["plexApi", () => import("./plex")],
["downloadsApi", () => import("./downloads")],
["filesApi", () => import("./files")],
["tokensApi", () => import("./tokens")],
["diagnosticsApi", () => import("./diagnostics")],
["debugSessionsApi", () => import("./diagnostics")],
] as const;
+3 -2
View File
@@ -3,8 +3,9 @@
import { getActiveAccount, req, uploadReq } from "./core";
import type { DownloadJob } from "./downloads";
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user.
export type FolderKind = "source" | "own";
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user;
// "hub": the one folder the user swaps files with their hub machine through (made by its token).
export type FolderKind = "source" | "own" | "hub";
export interface FileFolder {
id: number;
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { TOKEN_EXPIRY_DAYS, TOKEN_RIGHTS } from "./tokens";
// The token dialog's choices, held to what the backend takes (apitokens.py, routes/tokens.py), read
// from their source as text — the backend cannot be imported here. A right the server does not know
// is a 422 for the whole token; an expiry past its ceiling likewise.
const backend = (file: string) =>
readFileSync(resolve(__dirname, `../../../../backend/app/${file}`), "utf-8");
describe("the token dialog's choices", () => {
it("are the backend's rights, in its order", () => {
const body = backend("apitokens.py").match(/^class Right\(StrEnum\):\n([\s\S]*?)\n\n/m)?.[1];
expect(body, "apitokens.py no longer defines Right").toBeTruthy();
const rights = [...body!.matchAll(/=\s*"(\w+)"/g)].map((m) => m[1]);
expect(TOKEN_RIGHTS).toEqual(rights);
});
it("last no longer than the backend allows", () => {
const max = Number(backend("routes/tokens.py").match(/^_MAX_EXPIRY_DAYS = (\d+)$/m)?.[1]);
expect(max).toBeGreaterThan(0);
for (const days of TOKEN_EXPIRY_DAYS) if (days !== null) expect(days).toBeLessThanOrEqual(max);
});
});
+52
View File
@@ -0,0 +1,52 @@
// A user's API tokens: a machine's key to their Files library, folder by folder (backend
// `routes/tokens.py`, `apitokens.py`). Made, listed and revoked by the signed-in user only; a new
// token's value is in the answer that made it and nowhere after.
import { req } from "./core";
import type { FolderKind } from "./files";
// What a token may do in a folder. The backend's `Right` enum, in its order (held equal by
// tokens.contract.test.ts).
export type TokenRight = "list" | "read" | "write" | "delete";
export const TOKEN_RIGHTS: readonly TokenRight[] = ["list", "read", "write", "delete"];
// How long a new token lasts, in days; null = never. Within the backend's `_MAX_EXPIRY_DAYS` (the
// contract test), a year the default.
export const TOKEN_EXPIRY_DAYS: readonly (number | null)[] = [30, 90, 365, null];
export const TOKEN_EXPIRY_DEFAULT = 365;
export interface TokenGrant {
folder_id: number;
kind: FolderKind;
source_key: string | null;
name: string | null;
rights: TokenRight[];
}
export interface ApiToken {
id: number;
name: string;
prefix: string; // the token's first characters, to tell it apart by
created_at: string;
expires_at: string | null; // null = never
expired: boolean;
expiring: boolean; // runs out within the span its user is warned in (the server's)
last_used_at: string | null;
grants: TokenGrant[];
}
export interface NewToken {
name: string;
expires_days: number | null; // null = never
// `hub`: the one Hub folder, every right (the server makes the folder on first use). Else `grants`.
preset?: "hub";
grants?: { folder_id: number; rights: TokenRight[] }[];
}
export const tokensApi = {
apiTokens: (): Promise<ApiToken[]> => req("/api/tokens"),
// The answer carries the token's value, once.
createApiToken: (body: NewToken): Promise<ApiToken & { ok: true; token: string }> =>
req("/api/tokens", { method: "POST", body: JSON.stringify(body) }),
revokeApiToken: (id: number): Promise<{ ok: true; deleted: number }> =>
req(`/api/tokens/${id}`, { method: "DELETE" }),
};
+1
View File
@@ -70,6 +70,7 @@ export const qk = {
fileFolders: () => ["file-folders"] as const,
filePreview: (id: number) => ["file-preview", id] as const,
storageUsage: () => ["storage-usage"] as const,
apiTokens: () => ["api-tokens"] as const,
// --- downloads ---
downloads: () => ["downloads"] as const,
downloadIndex: () => ["download-index"] as const,
+16
View File
@@ -0,0 +1,16 @@
import { LS, setTabRaw } from "./storage";
// The Settings page's tabs, and a helper to pre-select one before navigating there (a notice's link,
// a return from Google). useTabPersistedState reads the key when the page mounts, and the page mounts
// fresh on navigation. Kept out of SettingsPanel.tsx so a caller does not pull the page in with it.
export type SettingsTabId =
| "appearance"
| "notifications"
| "downloads"
| "tokens"
| "account"
| "diagnostics";
export function focusSettingsTab(tab: SettingsTabId): void {
setTabRaw(LS.settingsTab, tab);
}