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:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 })),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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'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">
|
||||
|
||||
@@ -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 "Authorization: Bearer {RAW.slice(0, 12)}…"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ const SLICE_MODULES = [
|
||||
["plexApi", () => import("./plex")],
|
||||
["downloadsApi", () => import("./downloads")],
|
||||
["filesApi", () => import("./files")],
|
||||
["tokensApi", () => import("./tokens")],
|
||||
["diagnosticsApi", () => import("./diagnostics")],
|
||||
["debugSessionsApi", () => import("./diagnostics")],
|
||||
] as const;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { getActiveAccount, req, uploadReq } from "./core";
|
||||
import type { DownloadJob } from "./downloads";
|
||||
|
||||
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user.
|
||||
export type FolderKind = "source" | "own";
|
||||
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user;
|
||||
// "hub": the one folder the user swaps files with their hub machine through (made by its token).
|
||||
export type FolderKind = "source" | "own" | "hub";
|
||||
|
||||
export interface FileFolder {
|
||||
id: number;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// @vitest-environment jsdom
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TOKEN_EXPIRY_DAYS, TOKEN_RIGHTS } from "./tokens";
|
||||
|
||||
// The token dialog's choices, held to what the backend takes (apitokens.py, routes/tokens.py), read
|
||||
// from their source as text — the backend cannot be imported here. A right the server does not know
|
||||
// is a 422 for the whole token; an expiry past its ceiling likewise.
|
||||
const backend = (file: string) =>
|
||||
readFileSync(resolve(__dirname, `../../../../backend/app/${file}`), "utf-8");
|
||||
|
||||
describe("the token dialog's choices", () => {
|
||||
it("are the backend's rights, in its order", () => {
|
||||
const body = backend("apitokens.py").match(/^class Right\(StrEnum\):\n([\s\S]*?)\n\n/m)?.[1];
|
||||
expect(body, "apitokens.py no longer defines Right").toBeTruthy();
|
||||
const rights = [...body!.matchAll(/=\s*"(\w+)"/g)].map((m) => m[1]);
|
||||
expect(TOKEN_RIGHTS).toEqual(rights);
|
||||
});
|
||||
|
||||
it("last no longer than the backend allows", () => {
|
||||
const max = Number(backend("routes/tokens.py").match(/^_MAX_EXPIRY_DAYS = (\d+)$/m)?.[1]);
|
||||
expect(max).toBeGreaterThan(0);
|
||||
for (const days of TOKEN_EXPIRY_DAYS) if (days !== null) expect(days).toBeLessThanOrEqual(max);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// A user's API tokens: a machine's key to their Files library, folder by folder (backend
|
||||
// `routes/tokens.py`, `apitokens.py`). Made, listed and revoked by the signed-in user only; a new
|
||||
// token's value is in the answer that made it and nowhere after.
|
||||
import { req } from "./core";
|
||||
import type { FolderKind } from "./files";
|
||||
|
||||
// What a token may do in a folder. The backend's `Right` enum, in its order (held equal by
|
||||
// tokens.contract.test.ts).
|
||||
export type TokenRight = "list" | "read" | "write" | "delete";
|
||||
export const TOKEN_RIGHTS: readonly TokenRight[] = ["list", "read", "write", "delete"];
|
||||
|
||||
// How long a new token lasts, in days; null = never. Within the backend's `_MAX_EXPIRY_DAYS` (the
|
||||
// contract test), a year the default.
|
||||
export const TOKEN_EXPIRY_DAYS: readonly (number | null)[] = [30, 90, 365, null];
|
||||
export const TOKEN_EXPIRY_DEFAULT = 365;
|
||||
|
||||
export interface TokenGrant {
|
||||
folder_id: number;
|
||||
kind: FolderKind;
|
||||
source_key: string | null;
|
||||
name: string | null;
|
||||
rights: TokenRight[];
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
id: number;
|
||||
name: string;
|
||||
prefix: string; // the token's first characters, to tell it apart by
|
||||
created_at: string;
|
||||
expires_at: string | null; // null = never
|
||||
expired: boolean;
|
||||
expiring: boolean; // runs out within the span its user is warned in (the server's)
|
||||
last_used_at: string | null;
|
||||
grants: TokenGrant[];
|
||||
}
|
||||
|
||||
export interface NewToken {
|
||||
name: string;
|
||||
expires_days: number | null; // null = never
|
||||
// `hub`: the one Hub folder, every right (the server makes the folder on first use). Else `grants`.
|
||||
preset?: "hub";
|
||||
grants?: { folder_id: number; rights: TokenRight[] }[];
|
||||
}
|
||||
|
||||
export const tokensApi = {
|
||||
apiTokens: (): Promise<ApiToken[]> => req("/api/tokens"),
|
||||
// The answer carries the token's value, once.
|
||||
createApiToken: (body: NewToken): Promise<ApiToken & { ok: true; token: string }> =>
|
||||
req("/api/tokens", { method: "POST", body: JSON.stringify(body) }),
|
||||
revokeApiToken: (id: number): Promise<{ ok: true; deleted: number }> =>
|
||||
req(`/api/tokens/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user