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:
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
};
|
||||
Reference in New Issue
Block a user