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
+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" }),
};