feat(files): API tokens in Settings, the Hub folder in Files, a notice before a token runs out

Settings gains an API tokens tab: a row per token (name and prefix; each
folder with its rights; last use and expiry, "expires soon" marked),
revoked from its menu behind a confirm. A new token is the Hub exchange in
one click, or folders and rights picked per folder (no Write where an
upload cannot land); expiry 30/90 days, 1 year or never (warned). The
token shows once in the same dialog, with Copy and a ready curl line.

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