diff --git a/backend/alembic/versions/0106_api_tokens.py b/backend/alembic/versions/0106_api_tokens.py
index 019872a4..11151462 100644
--- a/backend/alembic/versions/0106_api_tokens.py
+++ b/backend/alembic/versions/0106_api_tokens.py
@@ -5,7 +5,7 @@ Revises: 0105_job_source_key
Create Date: 2026-10-14
`api_tokens` is a user's token for a machine (a script, the hub): only its SHA-256 is kept, plus the
-first characters to recognise it by. `api_token_grants` says what it may do in which folder — list,
+first characters to recognise it by, and whether its user was told it is about to run out. `api_token_grants` says what it may do in which folder — list,
read, write, delete — so a token reaches nothing it was not given, folder by folder.
A third folder kind, `hub`: the one folder a user exchanges files with their hub machine through.
@@ -53,6 +53,7 @@ def upgrade() -> None:
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
sa.Column("expires_at", sa.DateTime(timezone=True)),
sa.Column("last_used_at", sa.DateTime(timezone=True)),
+ sa.Column("expiry_warned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
diff --git a/backend/app/apitokens.py b/backend/app/apitokens.py
index 12e43b52..6514399c 100644
--- a/backend/app/apitokens.py
+++ b/backend/app/apitokens.py
@@ -11,7 +11,8 @@ The token is `sfl_` + 32 random bytes in base62 + a CRC32 of them: a mistyped or
refused without a database read, and a secret scanner can tell one by its shape. Only its SHA-256 is
kept (`security.hash_token`) — the user sees it once, when it is made — with its first characters to
tell tokens apart by. It is looked up by that hash on every request: no signed claims, so a deleted
-token stops working at once.
+token stops working at once. A token about to run out warns its user once, a week before
+(`warn_expiring`), so a machine does not lose its key unannounced.
"""
import secrets
@@ -29,9 +30,11 @@ from app import debugsession, logs
from app.auth import current_user
from app.db import get_db
from app.models import ApiToken, ApiTokenGrant, User
+from app.notifications import create_notification
from app.ratelimit import RateLimiter, client_key
from app.refusal import refused
from app.security import hash_token
+from app.utils import iso
log = logs.get_logger(__name__)
@@ -52,6 +55,9 @@ _bad = RateLimiter(20, 300)
# How stale `last_used_at` may get before a request writes it again: a busy script costs one write a
# minute, not one per request.
_TOUCH_EVERY = timedelta(minutes=1)
+# How long before a token runs out its user is told (`warn_expiring`); the token list marks it
+# "expires soon" for the same span.
+EXPIRY_WARNING = timedelta(days=7)
class Right(StrEnum):
@@ -207,3 +213,37 @@ def _token_caller(request: Request, db: Session, raw: str) -> FilesCaller:
logs.set_user(user.id, *debugsession.level_for(user, None))
request.state.user_id = user.id
return caller
+
+
+def expiring(token: ApiToken, now: datetime) -> bool:
+ """Whether `token` runs out within the warning span (and has not yet)."""
+ return token.expires_at is not None and now < token.expires_at <= now + EXPIRY_WARNING
+
+
+def warn_expiring(db: Session) -> int:
+ """Tell each user whose token runs out within `EXPIRY_WARNING` — once per token: the mark and the
+ notice commit together, and a token made again (a new row) warns afresh. Returns how many."""
+ now = datetime.now(timezone.utc)
+ hits = db.execute(
+ update(ApiToken)
+ .where(
+ ApiToken.expiry_warned.is_(False),
+ ApiToken.expires_at.is_not(None),
+ ApiToken.expires_at > now,
+ ApiToken.expires_at <= now + EXPIRY_WARNING,
+ )
+ .values(expiry_warned=True)
+ .returning(ApiToken.id, ApiToken.user_id, ApiToken.name, ApiToken.expires_at)
+ .execution_options(synchronize_session=False)
+ ).all()
+ for token_id, user_id, name, expires_at in hits:
+ create_notification(
+ db,
+ user_id,
+ "api_token_expiring",
+ title=f"API token {name} expires soon", # English fallback; the client names it from data
+ data={"token_id": token_id, "name": name, "expires_at": iso(expires_at)},
+ commit=False,
+ )
+ db.commit()
+ return len(hits)
diff --git a/backend/app/downloads/gc.py b/backend/app/downloads/gc.py
index 32d9a83a..50f6bb42 100644
--- a/backend/app/downloads/gc.py
+++ b/backend/app/downloads/gc.py
@@ -1,6 +1,6 @@
"""Download retention garbage collector (a scheduler job).
-Five passes each run:
+Six passes each run:
1. Pre-expiry warning — a done file whose `expires_at` (its folder's retention, `folders`) falls
within the grace window, and that its user did not keep, warns its user once (`expiry_warned`).
2. Expiry — such a file past its `expires_at` leaves the library: the job is deleted and its hold
@@ -14,6 +14,8 @@ Five passes each run:
re-download renames an asset, e.g. old accented channel dirs, or an upload's stranded file).
It skips the dot-prefixed system trees (all but `.uploads`) and anything newer than an hour,
so an in-flight download is never touched.
+ 6. Token expiry warning — a machine's token for the library about to run out warns its user once
+ (`apitokens.warn_expiring`): the library's other clock.
Runs in the API process (which mounts FILES_ROOT). Pure disk/DB work — no YouTube quota.
"""
@@ -25,7 +27,7 @@ from pathlib import Path
from sqlalchemy import func, or_, select, update
from sqlalchemy.orm import Session
-from app import logs, sysconfig
+from app import apitokens, logs, sysconfig
from app.config import settings
from app.downloads import quota, service, storage
from app.downloads.status import JobStatus
@@ -366,12 +368,16 @@ def run_download_gc(db: Session) -> dict:
# posters). Runs last so the keep-set reflects the assets that survived passes 2-4.
swept = _sweep_orphan_files(db, settings.files_root)
+ # 6. A machine's token about to run out.
+ tokens_warned = apitokens.warn_expiring(db)
+
result = {
"warned": warned,
"expired": expired,
"evicted": evicted,
"reaped": reaped,
"swept": swept,
+ "tokens_warned": tokens_warned,
}
log.info("download_gc %s", result)
return result
diff --git a/backend/app/models.py b/backend/app/models.py
index af2224ac..5c520e68 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -968,6 +968,8 @@ class ApiToken(Base, TimestampMixin):
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL = never
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ # The one notice its user gets before it runs out (`apitokens.warn_expiring`).
+ expiry_warned: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
class ApiTokenGrant(Base):
diff --git a/backend/app/routes/tokens.py b/backend/app/routes/tokens.py
index 6e0bbfee..616a0e3f 100644
--- a/backend/app/routes/tokens.py
+++ b/backend/app/routes/tokens.py
@@ -64,6 +64,7 @@ def _serialize(db: Session, token: ApiToken) -> dict:
"created_at": iso(token.created_at),
"expires_at": iso(token.expires_at),
"expired": token.expires_at is not None and token.expires_at <= now,
+ "expiring": apitokens.expiring(token, now),
"last_used_at": iso(token.last_used_at),
"grants": [
{
diff --git a/backend/tests/test_api_tokens.py b/backend/tests/test_api_tokens.py
index 4ff8e29c..878c4859 100644
--- a/backend/tests/test_api_tokens.py
+++ b/backend/tests/test_api_tokens.py
@@ -16,11 +16,20 @@ from sqlalchemy import select
from app import apitokens
from app.apitokens import files_caller
from app.downloads import folders, service
+from app.downloads import gc as gcmod
from app.downloads import upload as uploadmod
from app.downloads.status import JobStatus
from app.config import settings
from app.main import app
-from app.models import ApiToken, ApiTokenGrant, AuditLog, DownloadJob, FileFolder, MediaAsset
+from app.models import (
+ ApiToken,
+ ApiTokenGrant,
+ AuditLog,
+ DownloadJob,
+ FileFolder,
+ MediaAsset,
+ Notification,
+)
from app.ratelimit import RateLimiter
from app.routes import tokens as tokens_routes
@@ -329,6 +338,34 @@ def test_a_malformed_unknown_expired_revoked_or_suspended_token_holds_nothing(
assert _refused(_bearer(raw)) == 401
+def test_a_token_about_to_run_out_warns_its_user_once_and_is_marked_in_the_list(
+ db, make_user, as_user
+):
+ alice, bob = make_user(), make_user()
+ c = as_user(alice)
+ soon = _make(c, preset="hub", expires_days=3)
+ _make(c, preset="hub", expires_days=30) # outside the span
+ _make(c, preset="hub") # never runs out
+ gone = _make(c, preset="hub", expires_days=1)
+ db.get(ApiToken, gone["id"]).expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
+ _make(as_user(bob), preset="hub", expires_days=2)
+ db.commit()
+ assert [t["expiring"] for t in c.get("/api/tokens").json()] == [True, False, False, False]
+
+ assert apitokens.warn_expiring(db) == 2 # alice's and bob's; the run-out one is not "soon"
+ notes = db.execute(select(Notification).where(Notification.user_id == alice.id)).scalars()
+ [note] = notes.all()
+ assert note.type == "api_token_expiring"
+ assert note.data["token_id"] == soon["id"] and note.data["name"] == "a machine"
+ assert note.data["expires_at"] == soon["expires_at"]
+ assert apitokens.warn_expiring(db) == 0 # once per token
+
+
+def test_the_files_gc_runs_the_token_warning(db, make_user, as_user, _root):
+ _make(as_user(make_user()), preset="hub", expires_days=3)
+ assert gcmod.run_download_gc(db)["tokens_warned"] == 1
+
+
def test_another_users_token_cannot_be_revoked(db, make_user, as_user):
alice, bob = make_user(), make_user()
made = _make(as_user(alice), preset="hub")
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index f2becf4d..524d7f88 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -781,3 +781,4 @@ of a full-width panel and nothing on the right.
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
+| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d773ab27..548faf26 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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";
diff --git a/frontend/src/components/ApiTokens.dom.test.tsx b/frontend/src/components/ApiTokens.dom.test.tsx
new file mode 100644
index 00000000..d2cdaaf0
--- /dev/null
+++ b/frontend/src/components/ApiTokens.dom.test.tsx
@@ -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()),
+ useTranslation: () => ({
+ i18n: { language: "en" },
+ t: (key: string, o?: Record) =>
+ 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 => ({
+ 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 => ({
+ 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(
+
+
+ ,
+ );
+}
+
+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 = {}) => ({
+ 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 })),
+ );
+ });
+});
diff --git a/frontend/src/components/ApiTokens.tsx b/frontend/src/components/ApiTokens.tsx
new file mode 100644
index 00000000..d9ccf06c
--- /dev/null
+++ b/frontend/src/components/ApiTokens.tsx
@@ -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 }) {
+ return ;
+}
+
+function GrantLine({ grant }: { grant: TokenGrant }) {
+ const { t, i18n } = useTranslation();
+ return (
+
+
+ {folderLabel(grant, t)}
+ · {rightsText(grant.rights, t, i18n.language)}
+
+ );
+}
+
+function TokenRow({
+ token,
+ index,
+ rowProps,
+ more,
+}: {
+ token: ApiToken;
+ index: number;
+ rowProps: Record;
+ more: ReactNode;
+}) {
+ const { t, i18n } = useTranslation();
+ const expires = formatDate(token.expires_at, i18n.language);
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Files.menu.dom.test.tsx b/frontend/src/components/Files.menu.dom.test.tsx
index 710ae732..b45bf33b 100644
--- a/frontend/src/components/Files.menu.dom.test.tsx
+++ b/frontend/src/components/Files.menu.dom.test.tsx
@@ -155,6 +155,7 @@ const folder = (id: number, extra: Partial): 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];
diff --git a/frontend/src/components/Files.tsx b/frontend/src/components/Files.tsx
index da78f1e9..cfe983ee 100644
--- a/frontend/src/components/Files.tsx
+++ b/frontend/src/components/Files.tsx
@@ -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
diff --git a/frontend/src/components/FilesRail.dom.test.tsx b/frontend/src/components/FilesRail.dom.test.tsx
index dfbfd493..b2e31c5b 100644
--- a/frontend/src/components/FilesRail.dom.test.tsx
+++ b/frontend/src/components/FilesRail.dom.test.tsx
@@ -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);
diff --git a/frontend/src/components/FilesRail.tsx b/frontend/src/components/FilesRail.tsx
index faa16032..37006805 100644
--- a/frontend/src/components/FilesRail.tsx
+++ b/frontend/src/components/FilesRail.tsx
@@ -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(null);
const [creating, setCreating] = useState(false);
@@ -202,8 +205,9 @@ export default function FilesRail({
{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 && (
{t("files.rail.noSources")}
)}
diff --git a/frontend/src/components/NotificationsPanel.menu.dom.test.tsx b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx
index 9f7f099a..6aacb9af 100644
--- a/frontend/src/components/NotificationsPanel.menu.dom.test.tsx
+++ b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx
@@ -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 = [];
diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx
index 0720694c..98f19f26 100644
--- a/frontend/src/components/NotificationsPanel.tsx
+++ b/frontend/src/components/NotificationsPanel.tsx
@@ -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({ keyOf: byId });
const clientMenu = useContextMenu({ 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 = {
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")}
)}
+ {isTokenExpiring && (
+ }
+ >
+ {t("inbox.openTokens")}
+
+ )}
{isDownloadDone && (
}
{tab === "notifications" && }
{tab === "downloads" && }
+ {tab === "tokens" && }
{tab === "account" && }
{tab === "diagnostics" && }
diff --git a/frontend/src/components/fileFolders.tsx b/frontend/src/components/fileFolders.tsx
index 0649e7d8..4705e9d5 100644
--- a/frontend/src/components/fileFolders.tsx
+++ b/frontend/src/components/fileFolders.tsx
@@ -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:`, 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;
+
+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";
+}
diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts
index 0ee75241..6749d15c 100644
--- a/frontend/src/components/ui/pressableSelectable.contract.test.ts
+++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts
@@ -17,6 +17,10 @@ const files = readSources(__dirname, [
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record = {
+ "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",
diff --git a/frontend/src/components/useFileActions.tsx b/frontend/src/components/useFileActions.tsx
index 552a39a1..8b30a065 100644
--- a/frontend/src/components/useFileActions.tsx
+++ b/frontend/src/components/useFileActions.tsx
@@ -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) => ({
diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx
index bf846765..0f43b25f 100644
--- a/frontend/src/design-lab/DesignLab.tsx
+++ b/frontend/src/design-lab/DesignLab.tsx
@@ -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() {
/>
+
+
Decided · API tokens, a machine's key to Files
+
+ 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").
+
+
+
Open · Files, the one library
diff --git a/frontend/src/design-lab/tokensRound.tsx b/frontend/src/design-lab/tokensRound.tsx
new file mode 100644
index 00000000..b879b845
--- /dev/null
+++ b/frontend/src/design-lab/tokensRound.tsx
@@ -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 = {
+ 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 (
+