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 ( +
+ +
+ + {token.name} + {token.prefix}… + {token.expired ? ( + {t("settings.tokens.expired")} + ) : ( + token.expiring && {t("settings.tokens.expiring")} + )} + + + {token.grants.map((g) => ( + + ))} + + + {token.last_used_at ? ( + <> + {t("settings.tokens.lastUsed")} + + ) : ( + 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 })} + +
+ {more} +
+ ); +} + +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({ keyOf: (tk) => tk.id }); + const items = (token: ApiToken): ActionMenuItem[] => [ + { + id: "revoke", + label: t("settings.tokens.revoke"), + icon: , + danger: true, + onSelect: () => void onRevoke(token), + }, + ]; + const tokens = tokensQ.data; + + return ( +
+
+

{t("settings.tokens.intro")}

+ +
+ {tokensQ.isError && !tokens ? ( + void tokensQ.refetch()}> + {t("common.loadError")} + + ) : !tokens ? ( + + ) : tokens.length === 0 ? ( +

{t("settings.tokens.empty")}

+ ) : ( + + {tokens.map((token, i) => ( + + } + /> + ))} + + )} + + {making && setMaking(false)} />} +
+ ); +} + +// --- making one ----------------------------------------------------------------------------------- + +type Kind = "hub" | "custom"; +type Grants = Record; + +/** 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; + grants: Grants; + onChange: (g: Grants) => void; +}) { + const { t, i18n } = useTranslation(); + if (foldersQ.isError && !foldersQ.data) + return ( + void foldersQ.refetch()}> + {t("common.loadError")} + + ); + if (!foldersQ.data) return ; + const rows = grantable(foldersQ.data, t, i18n.language); + if (rows.length === 0) + return

{t("settings.tokens.noFolders")}

; + 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 ( +
+ {rows.map((f) => { + const label = folderLabel(f, t); + return ( +
+ + + {label} + + + {TOKEN_RIGHTS.map((r) => + r === "write" && !takesUploads(f) ? null : ( + toggle(f, r)} + > + {t(`settings.tokens.rights.${r}`)} + + ), + )} + +
+ ); + })} +
+ ); +} + +function KindTile({ + on, + icon, + title, + text, + onPick, +}: { + on: boolean; + icon: ReactNode; + title: string; + text: string; + onPick: () => void; +}) { + const textId = useId(); + return ( + + + {icon} + {title} + + {/* Read after the name, not into it ("Custom" — then what it means). */} + + {text} + + + ); +} + +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("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(null); + const name = nameDraft ?? (kind === "hub" ? t("settings.tokens.hubName") : ""); + const [grants, setGrants] = useState({}); + const [expiry, setExpiry] = useState(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 ( + +
+ {t("settings.tokens.once")} + + +
+ + + +
+ ); + + return ( + +
{ + e.preventDefault(); + if (ready && !create.isPending) create.mutate(); + }} + > +
+ } + title={t("settings.tokens.hubTitle")} + text={t("settings.tokens.hubText")} + onPick={() => setKind("hub")} + /> + } + title={t("settings.tokens.customTitle")} + text={t("settings.tokens.customText")} + onPick={() => setKind("custom")} + /> +
+ + {kind === "custom" && ( +
+ {t("settings.tokens.folders")} + +
+ )} +
+ + {t("settings.tokens.expiry")} + + ({ + value: expiryValue(d), + label: t(`settings.tokens.expiryOption.${expiryValue(d)}`), + }))} + value={expiryValue(expiry)} + onChange={(v) => setExpiry(v === "never" ? null : Number(v))} + /> + {expiry === null && {t("settings.tokens.neverWarning")}} +
+
+ + + + +
+ ); +} + +/** 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 ( +
+ {label} +
+ + {value} + + +
+
+ ); +} 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 && ( + + )} {isDownloadDone && (