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
|
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
|
`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.
|
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.
|
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("token_hash", sa.String(64), nullable=False, unique=True),
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True)),
|
sa.Column("expires_at", sa.DateTime(timezone=True)),
|
||||||
sa.Column("last_used_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(
|
sa.Column(
|
||||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
"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
|
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
|
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
|
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
|
import secrets
|
||||||
@@ -29,9 +30,11 @@ from app import debugsession, logs
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import ApiToken, ApiTokenGrant, User
|
from app.models import ApiToken, ApiTokenGrant, User
|
||||||
|
from app.notifications import create_notification
|
||||||
from app.ratelimit import RateLimiter, client_key
|
from app.ratelimit import RateLimiter, client_key
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
from app.security import hash_token
|
from app.security import hash_token
|
||||||
|
from app.utils import iso
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
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
|
# 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.
|
# minute, not one per request.
|
||||||
_TOUCH_EVERY = timedelta(minutes=1)
|
_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):
|
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))
|
logs.set_user(user.id, *debugsession.level_for(user, None))
|
||||||
request.state.user_id = user.id
|
request.state.user_id = user.id
|
||||||
return caller
|
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).
|
"""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
|
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`).
|
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
|
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).
|
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,
|
It skips the dot-prefixed system trees (all but `.uploads`) and anything newer than an hour,
|
||||||
so an in-flight download is never touched.
|
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.
|
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 import func, or_, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs, sysconfig
|
from app import apitokens, logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import quota, service, storage
|
from app.downloads import quota, service, storage
|
||||||
from app.downloads.status import JobStatus
|
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.
|
# posters). Runs last so the keep-set reflects the assets that survived passes 2-4.
|
||||||
swept = _sweep_orphan_files(db, settings.files_root)
|
swept = _sweep_orphan_files(db, settings.files_root)
|
||||||
|
|
||||||
|
# 6. A machine's token about to run out.
|
||||||
|
tokens_warned = apitokens.warn_expiring(db)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"warned": warned,
|
"warned": warned,
|
||||||
"expired": expired,
|
"expired": expired,
|
||||||
"evicted": evicted,
|
"evicted": evicted,
|
||||||
"reaped": reaped,
|
"reaped": reaped,
|
||||||
"swept": swept,
|
"swept": swept,
|
||||||
|
"tokens_warned": tokens_warned,
|
||||||
}
|
}
|
||||||
log.info("download_gc %s", result)
|
log.info("download_gc %s", result)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -968,6 +968,8 @@ class ApiToken(Base, TimestampMixin):
|
|||||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL = never
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL = never
|
||||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
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):
|
class ApiTokenGrant(Base):
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ def _serialize(db: Session, token: ApiToken) -> dict:
|
|||||||
"created_at": iso(token.created_at),
|
"created_at": iso(token.created_at),
|
||||||
"expires_at": iso(token.expires_at),
|
"expires_at": iso(token.expires_at),
|
||||||
"expired": token.expires_at is not None and token.expires_at <= now,
|
"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),
|
"last_used_at": iso(token.last_used_at),
|
||||||
"grants": [
|
"grants": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,11 +16,20 @@ from sqlalchemy import select
|
|||||||
from app import apitokens
|
from app import apitokens
|
||||||
from app.apitokens import files_caller
|
from app.apitokens import files_caller
|
||||||
from app.downloads import folders, service
|
from app.downloads import folders, service
|
||||||
|
from app.downloads import gc as gcmod
|
||||||
from app.downloads import upload as uploadmod
|
from app.downloads import upload as uploadmod
|
||||||
from app.downloads.status import JobStatus
|
from app.downloads.status import JobStatus
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.main import app
|
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.ratelimit import RateLimiter
|
||||||
from app.routes import tokens as tokens_routes
|
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
|
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):
|
def test_another_users_token_cannot_be_revoked(db, make_user, as_user):
|
||||||
alice, bob = make_user(), make_user()
|
alice, bob = make_user(), make_user()
|
||||||
made = _make(as_user(alice), preset="hub")
|
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-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 | 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-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,
|
type PanelLayout,
|
||||||
} from "./lib/panelLayout";
|
} from "./lib/panelLayout";
|
||||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||||
import { LS, setTabRaw } from "./lib/storage";
|
import { LS } from "./lib/storage";
|
||||||
import { usePref } from "./lib/usePref";
|
import { usePref } from "./lib/usePref";
|
||||||
import { useVerifyEntry } from "./lib/useVerifyEntry";
|
import { useVerifyEntry } from "./lib/useVerifyEntry";
|
||||||
import { useNavigation, useNavigationActions } from "./components/navigationContext";
|
import { useNavigation, useNavigationActions } from "./components/navigationContext";
|
||||||
@@ -46,6 +46,7 @@ import ErrorDialog from "./components/ErrorDialog";
|
|||||||
import VersionBanner from "./components/VersionBanner";
|
import VersionBanner from "./components/VersionBanner";
|
||||||
import VerifyBanner from "./components/VerifyBanner";
|
import VerifyBanner from "./components/VerifyBanner";
|
||||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||||
|
import { focusSettingsTab } from "./lib/settingsTab";
|
||||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
// 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
|
// 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).
|
// 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") {
|
if (result === "ok") {
|
||||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||||
void refetchMe();
|
void refetchMe();
|
||||||
setTabRaw(LS.settingsTab, "account");
|
focusSettingsTab("account");
|
||||||
setPage("settings");
|
setPage("settings");
|
||||||
} else {
|
} else {
|
||||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
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 YT = folder(1, { source_key: "youtube" });
|
||||||
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
|
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
|
||||||
|
const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 });
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -377,6 +378,18 @@ describe("Files · my file's row", () => {
|
|||||||
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 1 }));
|
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 () => {
|
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.files = [file(2, { folder_id: 1 })];
|
||||||
mocks.folders = [YT];
|
mocks.folders = [YT];
|
||||||
@@ -1043,6 +1056,14 @@ describe("Files · dropping a file on the page", () => {
|
|||||||
expect(await screen.findByText("recept.pdf")).toBeTruthy();
|
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", () => {
|
it("is no target where nothing can be uploaded — but the browser never gets the file", () => {
|
||||||
mocks.files = [];
|
mocks.files = [];
|
||||||
mocks.folders = [YT];
|
mocks.folders = [YT];
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { MetaEditModal } from "./downloadModals";
|
|||||||
import { jobTitle } from "./downloadTitle";
|
import { jobTitle } from "./downloadTitle";
|
||||||
import { FileCard } from "./FileCard";
|
import { FileCard } from "./FileCard";
|
||||||
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
|
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 { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
|
||||||
import { useFileActions } from "./useFileActions";
|
import { useFileActions } from "./useFileActions";
|
||||||
import { useFolderActions } from "./useFolderActions";
|
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));
|
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
|
/** Where an upload from this view lands: the folder itself when it may take one (`takesUploads`),
|
||||||
* Uploads folder — `folders.upload_target`), else the Uploads folder. */
|
* else the Uploads folder. */
|
||||||
function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number | null {
|
function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number | null {
|
||||||
if (view.kind !== "folder") return null;
|
if (view.kind !== "folder") return null;
|
||||||
const f = folders.find((x) => x.id === view.id);
|
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
|
/** 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"]));
|
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 () => {
|
it("renames in the entry, and Enter hands the keyboard back to it", async () => {
|
||||||
await mount();
|
await mount();
|
||||||
const update = vi.spyOn(api, "updateFolder").mockResolvedValue(OWN);
|
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);
|
compareText(folderLabel(a, t), folderLabel(b, t), i18n.language);
|
||||||
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
|
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
|
||||||
const own = folders.filter((f) => f.kind === "own").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 [renamingId, setRenamingId] = useState<number | null>(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -202,8 +205,9 @@ export default function FilesRail({
|
|||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{failed}
|
{failed}
|
||||||
{sources.map(entry)}
|
{sources.map(entry)}
|
||||||
|
{hub.map(entry)}
|
||||||
{/* Only when there is nothing at all: files moved into own folders were still downloaded. */}
|
{/* 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>
|
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noSources")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const h = vi.hoisted(() => ({
|
|||||||
client: [] as unknown[],
|
client: [] as unknown[],
|
||||||
setPage: vi.fn(),
|
setPage: vi.fn(),
|
||||||
openFile: vi.fn(),
|
openFile: vi.fn(),
|
||||||
|
focusSettingsTab: vi.fn(),
|
||||||
setFilters: vi.fn(),
|
setFilters: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
clearAll: vi.fn(),
|
clearAll: vi.fn(),
|
||||||
@@ -44,6 +45,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
|||||||
remove: (id: number) => h.remove(id),
|
remove: (id: number) => h.remove(id),
|
||||||
clearAll: () => h.clearAll(),
|
clearAll: () => h.clearAll(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab }));
|
||||||
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) }));
|
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) }));
|
||||||
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
|
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
|
||||||
vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) }));
|
vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) }));
|
||||||
@@ -83,7 +85,15 @@ import PageShell from "./ui/PageShell";
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
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();
|
f.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,6 +181,26 @@ describe("NotificationsPanel · a server row", () => {
|
|||||||
expect(h.openFile).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("holds the link, mark read and dismiss", async () => {
|
||||||
h.server = [server(1)];
|
h.server = [server(1)];
|
||||||
h.client = [];
|
h.client = [];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
KeyRound,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -23,6 +24,7 @@ import { jobRunText } from "../lib/jobResult";
|
|||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
||||||
|
import { focusSettingsTab } from "../lib/settingsTab";
|
||||||
import {
|
import {
|
||||||
clearAll as clearClient,
|
clearAll as clearClient,
|
||||||
dismiss as dismissClient,
|
dismiss as dismissClient,
|
||||||
@@ -37,7 +39,7 @@ import {
|
|||||||
type VideoHiddenMeta,
|
type VideoHiddenMeta,
|
||||||
type VideoWatchedMeta,
|
type VideoWatchedMeta,
|
||||||
} from "../lib/notifications";
|
} from "../lib/notifications";
|
||||||
import { channelYouTubeUrl } from "../lib/format";
|
import { channelYouTubeUrl, formatDate } from "../lib/format";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { LEVEL_STYLE } from "./ui/toastLevels";
|
import { LEVEL_STYLE } from "./ui/toastLevels";
|
||||||
@@ -186,6 +188,10 @@ export default function NotificationsPanel() {
|
|||||||
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
|
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
|
||||||
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
|
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
|
||||||
const openScheduler = () => setPage("scheduler");
|
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.
|
// A finished download opens on its file, in its folder; a notice from before it named one, on Files.
|
||||||
const openDone = (n: AppNotification) => {
|
const openDone = (n: AppNotification) => {
|
||||||
const { job_id: id, folder_id: folder } = n.data ?? {};
|
const { job_id: id, folder_id: folder } = n.data ?? {};
|
||||||
@@ -225,7 +231,16 @@ export default function NotificationsPanel() {
|
|||||||
onSelect: () => openDone(n),
|
onSelect: () => openDone(n),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: kind === "token"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "open",
|
||||||
|
label: t("inbox.openTokens"),
|
||||||
|
icon: icon(KeyRound),
|
||||||
|
onSelect: openTokens,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(!n.read
|
...(!n.read
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -397,6 +412,7 @@ export default function NotificationsPanel() {
|
|||||||
onDismiss={() => dismissMut.mutate(n.id)}
|
onDismiss={() => dismissMut.mutate(n.id)}
|
||||||
onOpenScheduler={openScheduler}
|
onOpenScheduler={openScheduler}
|
||||||
onOpenFile={() => openDone(n)}
|
onOpenFile={() => openDone(n)}
|
||||||
|
onOpenTokens={openTokens}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -462,9 +478,10 @@ const DONE_TITLE: Record<string, string | undefined> = {
|
|||||||
upload: "inbox.uploadDone.title",
|
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 === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
|
||||||
if (n.type === "download_done") return "download";
|
if (n.type === "download_done") return "download";
|
||||||
|
if (n.type === "api_token_expiring" && typeof n.data?.name === "string") return "token";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +528,7 @@ function NotificationRow({
|
|||||||
onDismiss,
|
onDismiss,
|
||||||
onOpenScheduler,
|
onOpenScheduler,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
onOpenTokens,
|
||||||
}: {
|
}: {
|
||||||
n: AppNotification;
|
n: AppNotification;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
@@ -521,6 +539,7 @@ function NotificationRow({
|
|||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
onOpenScheduler: () => void;
|
onOpenScheduler: () => void;
|
||||||
onOpenFile: () => void;
|
onOpenFile: () => void;
|
||||||
|
onOpenTokens: () => void;
|
||||||
}) {
|
}) {
|
||||||
const lang = useTranslation().i18n.language;
|
const lang = useTranslation().i18n.language;
|
||||||
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
|
// 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 isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
|
||||||
const isScheduler = serverKind(n) === "scheduler";
|
const isScheduler = serverKind(n) === "scheduler";
|
||||||
const isDownloadDone = serverKind(n) === "download";
|
const isDownloadDone = serverKind(n) === "download";
|
||||||
|
const isTokenExpiring = serverKind(n) === "token";
|
||||||
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
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.
|
// A download's life after it is done: shared with you, about to expire, expired, evicted.
|
||||||
const lifecycle = DOWNLOAD_LIFECYCLE[n.type];
|
const lifecycle = DOWNLOAD_LIFECYCLE[n.type];
|
||||||
@@ -566,6 +586,11 @@ function NotificationRow({
|
|||||||
const d = n.data as { title?: string | null; from?: string };
|
const d = n.data as { title?: string | null; from?: string };
|
||||||
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
|
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
|
||||||
body = d.title || null;
|
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) {
|
} else if (isDownloadDone) {
|
||||||
// "Download ready" / "Edit ready" / "Upload ready" per the producer's kind, with the video's title
|
// "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
|
// 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")}
|
{t("inbox.openScheduler")}
|
||||||
</Button>
|
</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 && (
|
{isDownloadDone && (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
Check,
|
Check,
|
||||||
Download,
|
Download,
|
||||||
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
Monitor,
|
Monitor,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -17,6 +18,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DownloadFormats } from "./ProfileEditor";
|
import { DownloadFormats } from "./ProfileEditor";
|
||||||
import DiagnosticsSettings from "./DiagnosticsSettings";
|
import DiagnosticsSettings from "./DiagnosticsSettings";
|
||||||
|
import ApiTokens from "./ApiTokens";
|
||||||
|
import { type SettingsTabId } from "../lib/settingsTab";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, HttpError, type Me } from "../lib/api";
|
import { api, HttpError, type Me } from "../lib/api";
|
||||||
import { displayName, roleKey } from "../lib/api/account";
|
import { displayName, roleKey } from "../lib/api/account";
|
||||||
@@ -61,11 +64,12 @@ export interface PrefsController {
|
|||||||
saveState: PrefsSaveState;
|
saveState: PrefsSaveState;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = "appearance" | "notifications" | "downloads" | "account" | "diagnostics";
|
type TabId = SettingsTabId;
|
||||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
{ id: "appearance", icon: Monitor },
|
{ id: "appearance", icon: Monitor },
|
||||||
{ id: "notifications", icon: Bell },
|
{ id: "notifications", icon: Bell },
|
||||||
{ id: "downloads", icon: Download },
|
{ id: "downloads", icon: Download },
|
||||||
|
{ id: "tokens", icon: KeyRound },
|
||||||
{ id: "account", icon: User },
|
{ id: "account", icon: User },
|
||||||
{ id: "diagnostics", icon: Activity },
|
{ id: "diagnostics", icon: Activity },
|
||||||
];
|
];
|
||||||
@@ -114,6 +118,7 @@ export default function SettingsPanel() {
|
|||||||
{tab === "appearance" && <Appearance prefs={prefs} />}
|
{tab === "appearance" && <Appearance prefs={prefs} />}
|
||||||
{tab === "notifications" && <Notifications prefs={prefs} />}
|
{tab === "notifications" && <Notifications prefs={prefs} />}
|
||||||
{tab === "downloads" && <Downloads />}
|
{tab === "downloads" && <Downloads />}
|
||||||
|
{tab === "tokens" && <ApiTokens />}
|
||||||
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
||||||
{tab === "diagnostics" && <DiagnosticsSettings />}
|
{tab === "diagnostics" && <DiagnosticsSettings />}
|
||||||
</div>
|
</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 { YouTubeIcon } from "../lib/brandIcons";
|
||||||
import type { FileFolder } from "../lib/api";
|
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
|
// 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
|
// (`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:";
|
const SITE_PREFIX = "site:";
|
||||||
|
|
||||||
@@ -24,10 +25,20 @@ export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
|
|||||||
return Globe;
|
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);
|
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 ?? "");
|
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]. */
|
/** file → [how many plain Pressables, why they are not titles]. */
|
||||||
const PLAIN: Record<string, [number, string]> = {
|
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": [
|
"DownloadTray.tsx": [
|
||||||
2,
|
2,
|
||||||
"the folded pill (its summary is its label); a failure's reason, which opens its details",
|
"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 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 to = (f: FileFolder) => () => update.mutate({ id: file.id, patch: { folder_id: f.id } });
|
||||||
const items: ActionMenuItem[] = own.map((f) => ({
|
const items: ActionMenuItem[] = own.map((f) => ({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BatchB } from "./batchB";
|
|||||||
import { BatchC } from "./batchC";
|
import { BatchC } from "./batchC";
|
||||||
import { BatchD } from "./batchD";
|
import { BatchD } from "./batchD";
|
||||||
import { FilesRound } from "./filesRound";
|
import { FilesRound } from "./filesRound";
|
||||||
|
import { TokensRound } from "./tokensRound";
|
||||||
import { Chip } from "../components/ui/Chip";
|
import { Chip } from "../components/ui/Chip";
|
||||||
import { Slider } from "../components/ui/NativeControls";
|
import { Slider } from "../components/ui/NativeControls";
|
||||||
import { SURFACE } from "../components/ui/surfaces";
|
import { SURFACE } from "../components/ui/surfaces";
|
||||||
@@ -75,6 +76,9 @@ export default function DesignLab() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<nav className="flex flex-wrap gap-1 text-xs">
|
<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">
|
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-accent state-layer">
|
||||||
Files
|
Files
|
||||||
</a>
|
</a>
|
||||||
@@ -101,6 +105,14 @@ export default function DesignLab() {
|
|||||||
Resize the window to tablet size to see them there too.
|
Resize the window to tablet size to see them there too.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</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">
|
<header id="open-files" className="mt-6 scroll-mt-20">
|
||||||
<h2 className="text-xl font-bold">Open · Files, the one library</h2>
|
<h2 className="text-xl font-bold">Open · Files, the one library</h2>
|
||||||
<p className="text-sm text-muted">
|
<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",
|
"rename": "Couldn't rename the folder",
|
||||||
"retention": "Couldn't change how long the folder keeps its files",
|
"retention": "Couldn't change how long the folder keeps its files",
|
||||||
"delete": "Couldn't delete the folder"
|
"delete": "Couldn't delete the folder"
|
||||||
|
},
|
||||||
|
"apiToken": {
|
||||||
|
"create": "Couldn't create the token",
|
||||||
|
"revoke": "Couldn't revoke the token"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"menu": "Folder actions",
|
"menu": "Folder actions",
|
||||||
"new": "New folder",
|
"new": "New folder",
|
||||||
"newName": "Folder name",
|
"newName": "Folder name",
|
||||||
|
"hub": "Hub",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"retention": "Keeps files",
|
"retention": "Keeps files",
|
||||||
"forever": "Until I delete them",
|
"forever": "Until I delete them",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"andMore_other": "and {{count}} more",
|
"andMore_other": "and {{count}} more",
|
||||||
"openScheduler": "Open Scheduler",
|
"openScheduler": "Open Scheduler",
|
||||||
"openFile": "Open the file",
|
"openFile": "Open the file",
|
||||||
|
"openTokens": "Open API tokens",
|
||||||
"revertFailed": "Couldn't undo that — please try again",
|
"revertFailed": "Couldn't undo that — please try again",
|
||||||
"downloadDone": {
|
"downloadDone": {
|
||||||
"title": "Download ready"
|
"title": "Download ready"
|
||||||
@@ -46,6 +47,10 @@
|
|||||||
"downloadExpiring": {
|
"downloadExpiring": {
|
||||||
"title": "A download expires soon"
|
"title": "A download expires soon"
|
||||||
},
|
},
|
||||||
|
"apiTokenExpiring": {
|
||||||
|
"title": "An API token expires soon",
|
||||||
|
"body": "“{{name}}” stops working on {{date}}."
|
||||||
|
},
|
||||||
"downloadExpired": {
|
"downloadExpired": {
|
||||||
"title": "A download expired and was removed"
|
"title": "A download expired and was removed"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -145,6 +145,7 @@
|
|||||||
"evicted": "evicted",
|
"evicted": "evicted",
|
||||||
"reaped": "cleaned up",
|
"reaped": "cleaned up",
|
||||||
"swept": "swept",
|
"swept": "swept",
|
||||||
|
"tokens_warned": "tokens warned",
|
||||||
"libraries": "libraries",
|
"libraries": "libraries",
|
||||||
"movies": "movies",
|
"movies": "movies",
|
||||||
"shows": "shows",
|
"shows": "shows",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"diagnostics": "Diagnostics",
|
"diagnostics": "Diagnostics",
|
||||||
"downloads": "Downloads"
|
"downloads": "Downloads",
|
||||||
|
"tokens": "API tokens"
|
||||||
},
|
},
|
||||||
"plexSync": {
|
"plexSync": {
|
||||||
"title": "Plex watch sync",
|
"title": "Plex watch sync",
|
||||||
@@ -235,5 +236,54 @@
|
|||||||
"downloads": {
|
"downloads": {
|
||||||
"formats": "Download formats",
|
"formats": "Download formats",
|
||||||
"formatsIntro": "The choices the download dialog offers. The built-in ones are fixed; duplicate one to make your own."
|
"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",
|
"rename": "Nem sikerült átnevezni a mappát",
|
||||||
"retention": "Nem sikerült módosítani a mappa megőrzési idejé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"
|
"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",
|
"ok": "OK",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"menu": "Mappa műveletei",
|
"menu": "Mappa műveletei",
|
||||||
"new": "Új mappa",
|
"new": "Új mappa",
|
||||||
"newName": "Mappa neve",
|
"newName": "Mappa neve",
|
||||||
|
"hub": "Hub",
|
||||||
"rename": "Átnevezés",
|
"rename": "Átnevezés",
|
||||||
"retention": "Fájlok megőrzése",
|
"retention": "Fájlok megőrzése",
|
||||||
"forever": "Amíg ki nem törlöm",
|
"forever": "Amíg ki nem törlöm",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"andMore_other": "és még {{count}}",
|
"andMore_other": "és még {{count}}",
|
||||||
"openScheduler": "Ütemező megnyitása",
|
"openScheduler": "Ütemező megnyitása",
|
||||||
"openFile": "Fájl megnyitása",
|
"openFile": "Fájl megnyitása",
|
||||||
|
"openTokens": "API-tokenek megnyitása",
|
||||||
"revertFailed": "Nem sikerült visszavonni — próbáld újra",
|
"revertFailed": "Nem sikerült visszavonni — próbáld újra",
|
||||||
"downloadDone": {
|
"downloadDone": {
|
||||||
"title": "Letöltés kész"
|
"title": "Letöltés kész"
|
||||||
@@ -46,6 +47,10 @@
|
|||||||
"downloadExpiring": {
|
"downloadExpiring": {
|
||||||
"title": "Hamarosan lejár egy letöltés"
|
"title": "Hamarosan lejár egy letöltés"
|
||||||
},
|
},
|
||||||
|
"apiTokenExpiring": {
|
||||||
|
"title": "Hamarosan lejár egy API-token",
|
||||||
|
"body": "„{{name}}” lejár: {{date}}."
|
||||||
|
},
|
||||||
"downloadExpired": {
|
"downloadExpired": {
|
||||||
"title": "Egy letöltés lejárt, és törlődött"
|
"title": "Egy letöltés lejárt, és törlődött"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -145,6 +145,7 @@
|
|||||||
"evicted": "helyhiány miatt törölve",
|
"evicted": "helyhiány miatt törölve",
|
||||||
"reaped": "eltakarítva",
|
"reaped": "eltakarítva",
|
||||||
"swept": "kisöpörve",
|
"swept": "kisöpörve",
|
||||||
|
"tokens_warned": "token figyelmeztetve",
|
||||||
"libraries": "könyvtár",
|
"libraries": "könyvtár",
|
||||||
"movies": "film",
|
"movies": "film",
|
||||||
"shows": "sorozat",
|
"shows": "sorozat",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"notifications": "Értesítések",
|
"notifications": "Értesítések",
|
||||||
"account": "Fiók",
|
"account": "Fiók",
|
||||||
"diagnostics": "Diagnosztika",
|
"diagnostics": "Diagnosztika",
|
||||||
"downloads": "Letöltések"
|
"downloads": "Letöltések",
|
||||||
|
"tokens": "API-tokenek"
|
||||||
},
|
},
|
||||||
"plexSync": {
|
"plexSync": {
|
||||||
"title": "Plex nézettség-szinkron",
|
"title": "Plex nézettség-szinkron",
|
||||||
@@ -235,5 +236,54 @@
|
|||||||
"downloads": {
|
"downloads": {
|
||||||
"formats": "Letöltési formátumok",
|
"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."
|
"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";
|
import { filesApi } from "./api/files";
|
||||||
export type * from "./api/files";
|
export type * from "./api/files";
|
||||||
export { FILES_LIST_CAP, FILE_SORTS } 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";
|
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
|
||||||
export type * from "./api/diagnostics";
|
export type * from "./api/diagnostics";
|
||||||
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
|
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
|
||||||
@@ -84,6 +87,7 @@ export const api = {
|
|||||||
...plexApi,
|
...plexApi,
|
||||||
...downloadsApi,
|
...downloadsApi,
|
||||||
...filesApi,
|
...filesApi,
|
||||||
|
...tokensApi,
|
||||||
...diagnosticsApi,
|
...diagnosticsApi,
|
||||||
...debugSessionsApi,
|
...debugSessionsApi,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const SLICE_MODULES = [
|
|||||||
["plexApi", () => import("./plex")],
|
["plexApi", () => import("./plex")],
|
||||||
["downloadsApi", () => import("./downloads")],
|
["downloadsApi", () => import("./downloads")],
|
||||||
["filesApi", () => import("./files")],
|
["filesApi", () => import("./files")],
|
||||||
|
["tokensApi", () => import("./tokens")],
|
||||||
["diagnosticsApi", () => import("./diagnostics")],
|
["diagnosticsApi", () => import("./diagnostics")],
|
||||||
["debugSessionsApi", () => import("./diagnostics")],
|
["debugSessionsApi", () => import("./diagnostics")],
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { getActiveAccount, req, uploadReq } from "./core";
|
import { getActiveAccount, req, uploadReq } from "./core";
|
||||||
import type { DownloadJob } from "./downloads";
|
import type { DownloadJob } from "./downloads";
|
||||||
|
|
||||||
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user.
|
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user;
|
||||||
export type FolderKind = "source" | "own";
|
// "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 {
|
export interface FileFolder {
|
||||||
id: number;
|
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,
|
fileFolders: () => ["file-folders"] as const,
|
||||||
filePreview: (id: number) => ["file-preview", id] as const,
|
filePreview: (id: number) => ["file-preview", id] as const,
|
||||||
storageUsage: () => ["storage-usage"] as const,
|
storageUsage: () => ["storage-usage"] as const,
|
||||||
|
apiTokens: () => ["api-tokens"] as const,
|
||||||
// --- downloads ---
|
// --- downloads ---
|
||||||
downloads: () => ["downloads"] as const,
|
downloads: () => ["downloads"] as const,
|
||||||
downloadIndex: () => ["download-index"] 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