refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,269 @@
// @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("../../components/ui/confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../../lib/clipboard", () => ({ copyText: vi.fn(async () => true) }));
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,
has_link: false,
...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");
// Two Copy buttons in one dialog: each is named by the value it copies.
expect(
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.token)" }),
).toBe(document.activeElement);
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.tryIt)" });
// Once copied, its name says what it shows, so a voice command saying "Copied" finds it.
fireEvent.click(document.activeElement!);
await screen.findByRole("button", {
name: "settings.tokens.copiedField(settings.tokens.token)",
});
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 })),
);
});
});
+504
View File
@@ -0,0 +1,504 @@
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 { compareText } from "../../lib/collate";
import { formatDate } from "../../lib/format";
import { navItemAttrs } from "../../lib/listNav";
import { useConfirm } from "../../components/ui/confirmContext";
import { folderIcon, folderLabel, takesUploads } from "../files/fileFolders";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import { Chip } from "../../components/ui/Chip";
import { ContextMenu } from "../../components/ui/ContextMenu";
import InlineNotice from "../../components/ui/InlineNotice";
import { Input } from "../../components/ui/Input";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { MoreButton } from "../../components/ui/MoreButton";
import NavGrid from "../../components/ui/NavGrid";
import { Pressable } from "../../components/ui/Pressable";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { RelativeTime } from "../../components/ui/RelativeTime";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { SURFACE } from "../../components/ui/surfaces";
import { useContextMenu } from "../../components/ui/useContextMenu";
import { useCopyFeedback } from "../../components/ui/useCopyFeedback";
// 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, copy } = useCopyFeedback();
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" />}
// Two of these sit in one dialog: each is named by the value it copies, and by the word
// it shows, so a voice command saying what is on screen finds it.
aria-label={t(copied ? "settings.tokens.copiedField" : "settings.tokens.copyField", {
field: label,
})}
onClick={() => void copy(value)}
>
{copied ? t("settings.tokens.copied") : t("settings.tokens.copy")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,230 @@
// @vitest-environment jsdom
//
// Settings → Diagnosztika. Raising the level is a consent, so choosing Részletes or Teljes
// must change NOTHING until the consent box's own button is pressed; going back to Alap needs no
// consent. i18n is a key-passthrough (with the count, so the retention days are visible); the level
// endpoint is stubbed, and `me` lives in a real query cache so a saved level re-renders like in the app.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DiagnosticsLevel, DiagnosticsState, Me } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: { count?: number; h?: number; m?: number; time?: string }) =>
opts?.count != null
? `${key}:${opts.count}`
: opts?.m != null
? `${key}:${opts.h ?? 0}h${opts.m}m`
: opts?.time != null
? `${key}:${opts.time}`
: key,
i18n: { language: "en" },
}),
};
});
const setLevel = vi.fn(
(level: DiagnosticsLevel): Promise<{ ok: true; diagnostics: DiagnosticsState }> =>
Promise.resolve({
ok: true,
diagnostics: {
level,
until: level === "INFO" ? null : new Date(Date.now() + 86_400_000).toISOString(),
retention_days: 7,
session_minutes: 60,
},
}),
);
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/api")>();
return {
...actual,
api: { ...actual.api, setDiagnosticsLevel: (l: DiagnosticsLevel) => setLevel(l) },
};
});
import DiagnosticsSettings from "./DiagnosticsSettings";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function mount(me: Partial<Me>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), {
id: 1,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
...me,
});
render(
<QueryClientProvider client={qc}>
<DiagnosticsSettings />
</QueryClientProvider>,
);
return qc;
}
const levelButton = (name: string) =>
within(screen.getByRole("group", { name: "settings.diagnostics.level" })).getByRole("button", {
name,
});
describe("DiagnosticsSettings", () => {
it("choosing a detailed level asks first and changes nothing until confirmed", async () => {
mount({});
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
const consent = screen.getByTestId("diagnostics-consent");
expect(consent.textContent).toContain("settings.diagnostics.consent.what.DEBUG");
expect(consent.textContent).toContain("settings.diagnostics.consent.server:7");
expect(setLevel).not.toHaveBeenCalled();
fireEvent.click(
within(consent).getByRole("button", { name: "settings.diagnostics.consent.cancel" }),
);
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
"true",
);
expect(setLevel).not.toHaveBeenCalled();
});
it("the consent button raises the level, and the status line offers the way back", async () => {
mount({});
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(setLevel).toHaveBeenCalledWith("TRACE");
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
expect(levelButton("settings.diagnostics.levels.TRACE").getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.revert" }));
await waitFor(() => expect(screen.queryByTestId("diagnostics-status")).toBeNull());
expect(setLevel).toHaveBeenLastCalledWith("INFO");
});
it("counts the 24 hours from the moment the level was raised, not from when the page opened", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
vi.setSystemTime(new Date("2026-09-19T02:54:00Z"));
mount({});
vi.setSystemTime(new Date("2026-09-19T02:56:30Z")); // the page sat open for 2.5 minutes
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(screen.getByTestId("diagnostics-status").textContent).toContain("24 hr 0 min");
} finally {
vi.useRealTimers();
}
});
it("going back to the base level needs no consent", async () => {
const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } });
fireEvent.click(levelButton("settings.diagnostics.levels.INFO"));
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO"));
});
it("the focus goes back to the level picker when the box it was in goes away", async () => {
mount({});
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
const cancel = screen.getByRole("button", { name: "settings.diagnostics.consent.cancel" });
cancel.focus();
fireEvent.click(cancel);
expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.INFO"));
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
const confirm = screen.getByTestId("diagnostics-consent-confirm");
confirm.focus();
fireEvent.click(confirm);
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.TRACE"));
const revert = screen.getByRole("button", { name: "settings.diagnostics.revert" });
revert.focus();
fireEvent.click(revert);
await waitFor(() => expect(screen.queryByTestId("diagnostics-status")).toBeNull());
expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.INFO"));
});
it("the watch outlives a render in which the element it is watching is still there", async () => {
// The distinguishing property of the restore, and the one the browser found: it keeps looking
// until the element is GONE, instead of spending itself on the first render after the change.
// Chrome commits a saved level as two renders and jsdom as one, so this drives the general
// case directly — the user moved the focus somewhere else while the request was out, and that
// somewhere else disappears later, several renders on.
vi.useFakeTimers();
try {
// Raised, so the status line's countdown is mounted and its 30 s tick keeps re-rendering.
const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } });
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
const elsewhere = document.createElement("button");
document.body.append(elsewhere);
elsewhere.focus();
// Cancel closes the box; the watch is armed on `elsewhere`, which is still in the page.
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.consent.cancel" }));
expect(document.activeElement).toBe(elsewhere); // their place is kept, nothing is stolen
// Renders keep coming (the countdown ticks) while it is still there, and must not spend it.
await vi.advanceTimersByTimeAsync(60_000);
expect(document.activeElement).toBe(elsewhere);
elsewhere.remove(); // now the focus really has fallen to <body>
expect(document.activeElement).toBe(document.body);
await vi.advanceTimersByTimeAsync(30_000);
expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.DEBUG"));
} finally {
vi.useRealTimers();
}
});
it("the ticking status line is not a live region", async () => {
const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } });
const status = screen.getByTestId("diagnostics-status");
expect(status.closest('[role="status"], [aria-live]')).toBeNull();
});
it("a `me` fetch that left before the change cannot put the old level back", async () => {
const qc = mount({});
let answerStale: (me: unknown) => void = () => {};
const stale = qc
.fetchQuery({
queryKey: qk.me(),
queryFn: () => new Promise((resolve) => (answerStale = resolve)),
})
.catch(() => null);
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
answerStale({
id: 1,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
});
await stale;
expect(qc.getQueryData<Me>(qk.me())?.diagnostics.level).toBe("TRACE");
expect(screen.getByTestId("diagnostics-status")).toBeTruthy();
});
it("a lapsed level shows as the base level", () => {
const until = new Date(Date.now() - 60_000).toISOString();
mount({ diagnostics: { level: "TRACE", until, retention_days: 7, session_minutes: 60 } });
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
"true",
);
expect(screen.queryByTestId("diagnostics-status")).toBeNull();
});
});
@@ -0,0 +1,193 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DiagnosticsLevel } from "../../lib/api";
import { levelInForce } from "../../lib/diagnostics";
import { focusIsUnclaimed } from "../../lib/useFocusKeeper";
import { updateMeCache, useMe } from "../../lib/useMe";
import { Button } from "../../components/ui/Button";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { Section, SettingRow } from "../../components/ui/form";
import MyLogViewer from "./MyLogViewer";
import { RecorderSettings } from "../../features/diagnostics/DebugRecorder";
import { formatUnit } from "../../lib/format";
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
/** Hours and minutes until `until`, refreshed every 30 s — the status line counts down. */
function useRemaining(until: string | null): { h: number; m: number } | null {
// The clock is read at render, and the interval only re-renders: a `now` held in state was the
// moment the page MOUNTED, so a level raised two minutes after opening Settings read "24 h 2 m"
// until the first tick (prod UAT 2026-09-19).
const [, tick] = useState(0);
useEffect(() => {
if (!until) return;
const id = window.setInterval(() => tick((n) => n + 1), 30_000);
return () => window.clearInterval(id);
}, [until]);
if (!until) return null;
// eslint-disable-next-line react-hooks/purity -- same as DebugRecorder's elapsed clock, and the comment above records the prod UAT finding that a state-held `now` produced: a level raised two minutes after opening Settings read "24 h 2 m" until the first tick.
const minutes = Math.max(0, Math.ceil((Date.parse(until) - Date.now()) / 60_000));
return { h: Math.floor(minutes / 60), m: minutes % 60 };
}
// Settings → Diagnosztika: the consented diagnostics level and the tab's own log.
//
// There is no separate on/off switch: Alap IS off. Choosing Részletes or Teljes does not change
// anything yet — it opens the consent box under the picker, which says what will be recorded, that
// the server keeps it and for how long, and that it lapses; only its button raises the level.
// Returning to Alap needs no consent and happens at once.
export default function DiagnosticsSettings() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const me = useMe();
const qc = useQueryClient();
const state = me.diagnostics;
const current = levelInForce(state);
const [pending, setPending] = useState<DiagnosticsLevel | null>(null);
const remaining = useRemaining(current === "INFO" ? null : (state?.until ?? null));
// The consent box and the status line each go away with the button that closed them — the one
// holding the focus. Put it back on the level picker (its chosen option) rather than let it fall
// to <body>, where the next Tab starts over from the top of the page. Only when it did fall: a
// user who moved on while the request was out keeps their place.
//
// Watched until that button is actually GONE, not for one render: a saved level arrives as two
// updates (the `me` cache, then the closed box), and the first render still has the button in the
// page — measured in Chrome, where a one-shot check spent itself there and the focus then fell.
const pickerRef = useRef<HTMLDivElement>(null);
const closedFrom = useRef<Element | null>(null);
useEffect(() => {
const from = closedFrom.current;
if (!from || from.isConnected) return;
closedFrom.current = null;
if (!focusIsUnclaimed()) return;
pickerRef.current?.querySelector<HTMLElement>('[aria-pressed="true"]')?.focus();
});
const watchFocus = () => {
closedFrom.current = document.activeElement;
};
const change = useMutation({
mutationFn: api.setDiagnosticsLevel,
meta: { failTitle: "settings.diagnostics.failed" },
onSuccess: (result) => {
watchFocus();
updateMeCache(qc, (prev) => ({ ...prev, diagnostics: result.diagnostics }));
setPending(null);
},
});
const cancel = () => {
watchFocus();
setPending(null);
};
const choose = (level: DiagnosticsLevel) => {
if (level === "INFO") {
setPending(null);
if (current !== "INFO") change.mutate("INFO");
} else {
setPending(level === current ? null : level);
}
};
const levelName = (level: DiagnosticsLevel) => t(`settings.diagnostics.levels.${level}`);
return (
<>
<Section title={t("settings.diagnostics.title")}>
<SettingRow
label={t("settings.diagnostics.level")}
hint={t("settings.diagnostics.levelHint")}
>
<div ref={pickerRef}>
<SegmentedToggle
size="md"
ariaLabel={t("settings.diagnostics.level")}
value={pending ?? current}
onChange={choose}
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
/>
</div>
</SettingRow>
{pending && (
<div
data-testid="diagnostics-consent"
className="mt-2 rounded-xl border border-border bg-accent/5 p-3 text-sm"
>
<p className="font-semibold">{t(`settings.diagnostics.consent.title.${pending}`)}</p>
<ul className="mt-1.5 mb-3 list-disc space-y-1 pl-5 text-muted">
<li>{t(`settings.diagnostics.consent.what.${pending}`)}</li>
<li>
{t("settings.diagnostics.consent.server", {
count: state?.retention_days ?? 7,
})}
</li>
<li>{t("settings.diagnostics.consent.never")}</li>
<li>
{t("settings.diagnostics.consent.lapse")}{" "}
<a
href="/privacy#diagnostics"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
>
{t("settings.diagnostics.consent.policy")}
</a>
</li>
</ul>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" onClick={cancel}>
{t("settings.diagnostics.consent.cancel")}
</Button>
<Button
size="sm"
data-testid="diagnostics-consent-confirm"
loading={change.isPending}
onClick={() => change.mutate(pending)}
>
{t("settings.diagnostics.consent.confirm")}
</Button>
</div>
</div>
)}
{current !== "INFO" && !pending && remaining && (
// Not a live region: the countdown changes every minute, and a screen reader would read
// the whole line out again each time for as long as the page is open.
<div
data-testid="diagnostics-status"
className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm"
>
<span className="inline-flex items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-warn" aria-hidden />
<span>
{t("settings.diagnostics.status", {
level: levelName(current),
time:
remaining.h > 0
? `${formatUnit(remaining.h, "hour", lang)} ${formatUnit(remaining.m, "minute", lang)}`
: formatUnit(remaining.m, "minute", lang),
})}
</span>
</span>
<Button variant="link" loading={change.isPending} onClick={() => change.mutate("INFO")}>
{t("settings.diagnostics.revert")}
</Button>
</div>
)}
</Section>
<Section title={t("recorder.settings.title")}>
<RecorderSettings />
</Section>
<Section title={t("settings.myLog.title")}>
<MyLogViewer />
</Section>
</>
);
}
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
//
// "Saját napló" shows this tab's own log live, newest first, filters it by level group and
// text, and copies exactly what is filtered (oldest first, one JSON event per line).
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { clear, log, setLevel } from "../../lib/log";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: { count?: number }) =>
opts?.count != null ? `${key}:${opts.count}` : key,
}),
};
});
const copied: string[] = [];
vi.mock("../../lib/clipboard", () => ({
copyText: (text: string) => {
copied.push(text);
return Promise.resolve(true);
},
}));
import MyLogViewer from "./MyLogViewer";
beforeEach(() => {
setLevel("DEBUG");
log.info("nav.view", "push feed", { page: "feed" });
log.warn("http.request", "GET /api/feed 503", { status: 503 });
log.debug("ui.click", "click feed-refresh", { control: "feed-refresh" });
});
afterEach(() => {
cleanup();
clear();
setLevel("INFO");
copied.length = 0;
});
const rows = () =>
within(screen.getByTestId("my-log-lines"))
.queryAllByRole("button")
.map((b) => b.textContent);
describe("MyLogViewer", () => {
it("lists the tab's events newest first and follows new ones", async () => {
render(<MyLogViewer />);
expect(rows().map((r) => r!.includes("ui.click"))).toEqual([true, false, false]);
await act(async () => {
log.error("app.uncaught", "TypeError: x is undefined");
await new Promise((r) => requestAnimationFrame(() => r(null)));
});
expect(rows()[0]).toContain("app.uncaught");
});
it("a row keeps its place and the focus as new events arrive above it", async () => {
render(<MyLogViewer />);
const row = within(screen.getByTestId("my-log-lines")).getAllByRole("button")[0]!;
row.focus();
fireEvent.click(row); // at Részletes this click is itself a new event in real use
await act(async () => {
log.debug("ui.click", "a newer event");
await new Promise((r) => requestAnimationFrame(() => r(null)));
});
expect(row.isConnected).toBe(true);
expect(document.activeElement).toBe(row);
expect(row.getAttribute("aria-expanded")).toBe("true");
});
it("filters by level group and by text, and copies only what is shown", async () => {
render(<MyLogViewer />);
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.warning/ }));
expect(rows()).toHaveLength(1);
expect(rows()[0]).toContain("GET /api/feed 503");
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.all/ }));
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "FEED-REFRESH" } });
expect(rows()).toHaveLength(1);
const copy = async () => {
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "settings.myLog.copy" }));
});
return copied[copied.length - 1]!.split("\n").map((l) => JSON.parse(l).event);
};
expect(await copy()).toEqual(["ui.click"]); // only what the search left
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "" } });
expect(await copy()).toEqual(["nav.view", "http.request", "ui.click"]); // oldest first
});
it("opens a row to its details and clears the log", async () => {
render(<MyLogViewer />);
const first = within(screen.getByTestId("my-log-lines")).getAllByRole("button")[0]!;
fireEvent.click(first);
expect(first.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByTestId("my-log-lines").textContent).toContain('"control": "feed-refresh"');
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "settings.myLog.clear" }));
await new Promise((r) => requestAnimationFrame(() => r(null)));
});
expect(rows()).toEqual([]);
expect(screen.getByText("settings.myLog.emptyAll")).toBeTruthy();
});
});
@@ -0,0 +1,225 @@
import clsx from "clsx";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Search } from "lucide-react";
import { copyText } from "../../lib/clipboard";
import {
clear,
events,
LEVEL_GROUPS,
subscribe,
type LevelGroup as Filter,
type LogEvent,
} from "../../lib/log";
import { notify } from "../../lib/notifications";
import LogLine from "../../features/diagnostics/LogLine";
import NavGrid from "../../components/ui/NavGrid";
import { Button } from "../../components/ui/Button";
import FilterChipGroup from "../../components/ui/FilterChipGroup";
import { InnerInput } from "../../components/ui/Input";
import { fieldClass } from "../../components/ui/fieldStyles";
// Rows rendered at once. The ring holds up to 2000 events; drawing all of them on every new event
// would make the settings page the slowest thing in the app while it is watching the app.
const ROWS_SHOWN = 500;
/** The ring, live: re-read at most once per animation frame however fast events arrive. */
function useLogEvents(): LogEvent[] {
const [list, setList] = useState(events);
useEffect(() => {
let frame: number | null = null;
const unsubscribe = subscribe(() => {
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
setList(events());
});
});
// eslint-disable-next-line react-hooks/set-state-in-effect -- this IS the subscribe-to-an-external-store shape the rule's own text allows: the ring fills outside React, and a sync at subscribe time is what closes the gap between the first render and the listener. useSyncExternalStore would drop the rAF coalescing, which is the point at a few hundred events a second.
setList(events()); // whatever arrived between the first render and the subscription
return () => {
unsubscribe();
if (frame !== null) window.cancelAnimationFrame(frame);
};
}, []);
return list;
}
// A row's React key: the event OBJECT's own number, handed out the first time it is drawn. The ring
// keeps the same objects, so the key stays put as the list grows at the top — an index key shifted
// every row on every new event, remounting all of them and dropping the focus off the row button
// (at Részletes a click on a row logs a line itself, so it happened on every click).
const rowIds = new WeakMap<LogEvent, number>();
let nextRowId = 0;
const rowKey = (event: LogEvent) => {
let id = rowIds.get(event);
if (id === undefined) {
id = nextRowId++;
rowIds.set(event, id);
}
return id;
};
const matches = (event: LogEvent, needle: string) =>
!needle ||
[event.event, event.msg, event.request_id ?? "", event.attrs ? JSON.stringify(event.attrs) : ""]
.join(" ")
.toLowerCase()
.includes(needle);
// "Saját napló" — this tab's own client log (lib/log.ts), exactly what it would send while a raised
// diagnostics level is on: newest first, filtered by level group and free text, a row opens to its
// correlation id and attributes, and "Copy" puts the FILTERED events on the clipboard as JSON lines
// (one event per line) for a bug report.
export default function MyLogViewer() {
const { t } = useTranslation();
const all = useLogEvents();
const [filter, setFilter] = useState<Filter>("all");
const [query, setQuery] = useState("");
const [open, setOpen] = useState<LogEvent | null>(null);
const counts = useMemo(() => {
const out: Record<Filter, number> = {
all: all.length,
error: 0,
warning: 0,
info: 0,
detail: 0,
};
for (const e of all) {
for (const [group, levels] of Object.entries(LEVEL_GROUPS)) {
if (levels.includes(e.level)) out[group as Filter] += 1;
}
}
return out;
}, [all]);
const shown = useMemo(() => {
const needle = query.trim().toLowerCase();
const levels = filter === "all" ? null : LEVEL_GROUPS[filter];
const out: LogEvent[] = [];
for (let i = all.length - 1; i >= 0; i--) {
const e = all[i]!;
if ((!levels || levels.includes(e.level)) && matches(e, needle)) out.push(e);
}
return out;
}, [all, filter, query]);
const copy = async () => {
// Oldest first, as a log reads — the list shows newest first only for watching it live.
const text = [...shown]
.reverse()
.map((e) => JSON.stringify(e))
.join("\n");
const ok = await copyText(text);
notify(
ok
? { level: "success", message: t("settings.myLog.copied", { count: shown.length }) }
: { level: "error", message: t("settings.myLog.copyFailed") },
);
};
const filters: readonly Filter[] = ["all", "error", "warning", "info", "detail"];
return (
<div data-testid="my-log" className="overflow-hidden rounded-xl border border-border bg-bg">
<p className="px-3 pt-2 text-xs text-muted">{t("settings.myLog.hint")}</p>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label
className={clsx(
fieldClass({ size: "sm", pill: true }),
"flex min-w-[10rem] flex-1 items-center gap-1.5",
)}
>
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<InnerInput
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("settings.myLog.search")}
aria-label={t("settings.myLog.search")}
/>
</label>
<FilterChipGroup
ariaLabel={t("settings.myLog.filterLabel")}
value={filter}
onChange={setFilter}
options={filters.map((f) => ({
value: f,
label: t(`settings.myLog.filters.${f}`),
count: counts[f],
}))}
/>
</div>
<div className="max-h-96 overflow-auto font-mono text-2xs" data-testid="my-log-lines">
{shown.length === 0 ? (
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
{all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")}
</p>
) : (
<NavGrid>
{shown.slice(0, ROWS_SHOWN).map((e, index) => (
<LogLine
navIndex={index}
key={rowKey(e)}
ts={e.ts}
level={e.level}
event={e.event}
msg={e.msg}
open={open === e}
onToggle={() => setOpen(open === e ? null : e)}
>
<pre className="overflow-x-auto whitespace-pre">
{JSON.stringify(
{
event: e.event,
msg: e.msg,
request_id: e.request_id,
ts: e.ts,
tab_id: e.tab_id,
attrs: e.attrs,
},
null,
2,
)}
</pre>
</LogLine>
))}
</NavGrid>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
<span>
{t("settings.myLog.count", { count: all.length })}
{shown.length > ROWS_SHOWN &&
` · ${t("settings.myLog.capped", { shown: ROWS_SHOWN, count: shown.length })}`}
</span>
<span className="flex gap-1.5">
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
disabled={shown.length === 0}
onClick={() => void copy()}
>
{t("settings.myLog.copy")}
</Button>
<Button
variant="outline"
size="xs"
disabled={all.length === 0}
onClick={() => {
clear();
setOpen(null);
}}
>
{t("settings.myLog.clear")}
</Button>
</span>
</div>
</div>
);
}
@@ -0,0 +1,141 @@
// @vitest-environment jsdom
//
// The download formats (Settings → Downloads, and the download dialog's "Manage formats"): one aligned
// list, built-ins first, each row saying what it saves. A row's one menu: a built-in's only
// Duplicate; the user's own Edit, Duplicate and Delete (behind a confirm). A duplicate is a NEW format
// with the original's settings and a name of its own. i18n is key-passthrough.
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 DownloadProfile } 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) => key }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => mocks.confirm }));
import { DownloadFormats } from "./ProfileEditor";
const SPEC = {
mode: "av",
max_height: 480,
container: "mp4",
audio_format: "m4a",
vcodec: null,
embed_subs: true,
embed_chapters: false,
embed_thumbnail: false,
sponsorblock: true,
};
const PROFILES = [
{ id: 9, name: "Phone 480", is_builtin: false, spec: SPEC },
{ id: 1, name: "best", is_builtin: true, spec: { mode: "a", audio_format: "mp3" } },
] as unknown as DownloadProfile[];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
});
async function mount() {
vi.spyOn(api, "downloadProfiles").mockResolvedValue(PROFILES);
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadFormats />
</QueryClientProvider>,
);
await screen.findByText("Phone 480");
}
const rows = () => [...document.querySelectorAll<HTMLElement>("[data-nav-index]")];
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
const pick = (label: string) =>
fireEvent.click(
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
);
async function openMenu(row: HTMLElement) {
act(() => {
fireEvent.contextMenu(row);
});
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
describe("DownloadFormats", () => {
it("lists the built-ins first, each saying what it saves", async () => {
await mount();
expect(rows().map((r) => r.textContent)).toEqual([
expect.stringContaining("downloads.profiles.modeA · MP3"),
expect.stringContaining(
"downloads.profiles.modeAv · ≤480p · downloads.profiles.short.subs · downloads.profiles.short.sponsorblock",
),
]);
});
it("a built-in's menu only duplicates; the user's own edits, duplicates and deletes", async () => {
await mount();
await openMenu(rows()[0]!);
expect(menuRows()).toEqual(["downloads.profiles.duplicate"]);
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await openMenu(rowOf("Phone 480"));
expect(menuRows()).toEqual([
"downloads.profiles.edit",
"downloads.profiles.duplicate",
"downloads.profiles.delete",
]);
});
it("deletes only through the confirm", async () => {
const del = vi.spyOn(api, "deleteDownloadProfile").mockResolvedValue({} as never);
await mount();
mocks.confirm.mockResolvedValueOnce(false);
await openMenu(rowOf("Phone 480"));
pick("downloads.profiles.delete");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(del).not.toHaveBeenCalled();
await openMenu(rowOf("Phone 480"));
pick("downloads.profiles.delete");
await waitFor(() => expect(del).toHaveBeenCalledWith(9));
});
it("a duplicate is a new format with the original's settings and a name of its own", async () => {
const create = vi.spyOn(api, "createDownloadProfile").mockResolvedValue({} as never);
const update = vi.spyOn(api, "updateDownloadProfile").mockResolvedValue({} as never);
await mount();
await openMenu(rowOf("Phone 480"));
pick("downloads.profiles.duplicate");
// The form opens with the keyboard in its name, the field named by its label.
const name = await screen.findByRole("textbox", { name: "downloads.profiles.name" });
await waitFor(() => expect(document.activeElement).toBe(name));
expect((name as HTMLInputElement).value).toBe("");
fireEvent.change(name, { target: { value: "Phone copy" } });
fireEvent.click(screen.getByText("downloads.profiles.create"));
await waitFor(() =>
expect(create).toHaveBeenCalledWith({
name: "Phone copy",
spec: expect.objectContaining({ max_height: 480, embed_subs: true, sponsorblock: true }),
}),
);
expect(update).not.toHaveBeenCalled();
});
it("Edit saves over the format itself", async () => {
const update = vi.spyOn(api, "updateDownloadProfile").mockResolvedValue({} as never);
await mount();
await openMenu(rowOf("Phone 480"));
pick("downloads.profiles.edit");
const name = await screen.findByDisplayValue("Phone 480");
fireEvent.change(name, { target: { value: "Phone" } });
fireEvent.click(screen.getByText("downloads.profiles.save"));
await waitFor(() =>
expect(update).toHaveBeenCalledWith(9, expect.objectContaining({ name: "Phone" })),
);
});
});
@@ -0,0 +1,403 @@
import { useEffect, useId, useRef, useState } from "react";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CopyPlus, Lock, Pencil, Plus, Trash2 } from "lucide-react";
import Modal from "../../components/ui/Modal";
import { useConfirm } from "../../components/ui/confirmContext";
import { api, type DownloadProfile, type DownloadSpec } from "../../lib/api";
import { profileName } from "../../lib/vocab";
import { Button } from "../../components/ui/Button";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import FilterSelect from "../../components/ui/FilterSelect";
import { CheckRow } from "../../components/ui/NativeControls";
import { Input } from "../../components/ui/Input";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { ContextMenu } from "../../components/ui/ContextMenu";
import DataTable, { type Column } from "../../components/ui/DataTable";
import { menuGroups } from "../../components/ui/menuGroups";
import { MoreButton } from "../../components/ui/MoreButton";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useContextMenu } from "../../components/ui/useContextMenu";
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
const BLANK: DownloadSpec = {
mode: "av",
max_height: 720,
container: "mp4",
audio_format: "m4a",
vcodec: null,
embed_subs: false,
embed_chapters: true,
embed_thumbnail: true,
sponsorblock: false,
};
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<CheckRow checked={checked} onChange={onChange} className="text-sm">
{label}
</CheckRow>
);
}
// The download formats: the built-in ones (read-only, a base to start from) and the user's own, in one
// aligned list whose rows each have ONE menu (its "…", a right-click, a long-press, Shift+F10) —
// Edit · Duplicate · Delete, a built-in's only Duplicate. The new/edit form opens under the list.
// Settings → Downloads shows it in place; the download dialog's "Manage formats" in a modal.
const MODE_KEY = {
av: "downloads.profiles.modeAv",
v: "downloads.profiles.modeV",
a: "downloads.profiles.modeA",
} as const;
/** What a format saves, in a line: the content, the quality (or the audio format), the extras. */
function specSummary(t: TFunction, spec: Partial<DownloadSpec>): string {
const mode = spec.mode ?? "av";
const parts = [
t(MODE_KEY[mode]),
mode === "a"
? (spec.audio_format ?? "m4a").toUpperCase()
: spec.max_height
? `≤${spec.max_height}p`
: t("downloads.profiles.best"),
];
if (mode !== "a" && spec.embed_subs) parts.push(t("downloads.profiles.short.subs"));
if (spec.embed_chapters) parts.push(t("downloads.profiles.short.chapters"));
if (spec.embed_thumbnail) parts.push(t("downloads.profiles.short.thumbnail"));
if (spec.sponsorblock) parts.push(t("downloads.profiles.short.sponsorblock"));
return parts.join(" · ");
}
export function DownloadFormats() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles });
// Built-ins first, then the user's own — the order the dialog's menu lists them in.
const all = profilesQ.data ?? [];
const profiles = [...all.filter((p) => p.is_builtin), ...all.filter((p) => !p.is_builtin)];
const [editing, setEditing] = useState<DownloadProfile | null>(null);
const [name, setName] = useState("");
const [spec, setSpec] = useState<DownloadSpec>(BLANK);
const [formOpen, setFormOpen] = useState(false);
// Opening the form (New, Edit, Duplicate) puts the keyboard in its name: the form opens away from
// the control that asked for it, and "New format" is replaced by it.
const [opened, setOpened] = useState(0);
const nameField = useRef<HTMLInputElement>(null);
const nameId = useId();
useEffect(() => {
if (opened) nameField.current?.focus();
}, [opened]);
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
const startNew = () => {
setEditing(null);
setName("");
setSpec(BLANK);
setFormOpen(true);
setOpened((n) => n + 1);
};
const startEdit = (p: DownloadProfile) => {
setEditing(p);
setName(p.name);
setSpec({ ...BLANK, ...p.spec });
setFormOpen(true);
setOpened((n) => n + 1);
};
// A new format from an existing one: its settings, and a name of its own to give.
const startCopy = (p: DownloadProfile) => {
setEditing(null);
setName("");
setSpec({ ...BLANK, ...p.spec });
setFormOpen(true);
setOpened((n) => n + 1);
};
const save = useMutation({
mutationFn: () =>
editing
? api.updateDownloadProfile(editing.id, { name, spec })
: api.createDownloadProfile({ name, spec }),
onSuccess: () => {
invalidate();
setFormOpen(false);
},
meta: { failTitle: "errors.writeFailed.downloadProfileSave" },
});
const del = useMutation({
mutationFn: (id: number) => api.deleteDownloadProfile(id),
onSuccess: invalidate,
meta: { failTitle: "errors.writeFailed.downloadProfileDelete" },
});
const onDelete = async (p: DownloadProfile) => {
if (
await confirm({
title: t("downloads.profiles.delete"),
message: t("downloads.profiles.deleteConfirm", { name: p.name }),
confirmLabel: t("downloads.profiles.delete"),
danger: true,
})
) {
del.mutate(p.id);
}
};
const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p }));
const menu = useContextMenu<DownloadProfile>({ keyOf: (p) => p.id });
const items = (p: DownloadProfile): ActionMenuItem[] =>
menuGroups(
p.is_builtin
? []
: [
{
id: "edit",
label: t("downloads.profiles.edit"),
icon: <Pencil className="w-4 h-4" />,
onSelect: () => startEdit(p),
},
],
[
{
id: "copy",
label: t("downloads.profiles.duplicate"),
icon: <CopyPlus className="w-4 h-4" />,
onSelect: () => startCopy(p),
},
],
p.is_builtin
? []
: [
{
id: "delete",
label: t("downloads.profiles.delete"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
onSelect: () => void onDelete(p),
},
],
);
const columns: Column<DownloadProfile>[] = [
{
key: "name",
header: t("downloads.profiles.name"),
cardPrimary: true,
render: (p) => (
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{profileName(t, p)}</span>
{p.is_builtin && (
<Lock
className="w-3.5 h-3.5 shrink-0 text-muted"
aria-label={t("downloads.profiles.builtin")}
/>
)}
</span>
),
},
{
key: "spec",
header: t("downloads.profiles.saves"),
render: (p) => {
const summary = specSummary(t, p.spec);
return (
<span className="block truncate text-muted" title={summary}>
{summary}
</span>
);
},
},
{
key: "menu",
header: "",
width: "3rem",
align: "right",
cardLabel: false,
cardRow: true,
render: (p) => (
<MoreButton
label={t("downloads.profiles.rowMenu")}
size="md"
expanded={menu.open && menu.target?.id === p.id}
menuProps={menu.buttonProps(p)}
/>
),
},
];
return (
<div>
{profilesQ.isError && !profilesQ.data ? (
<StateMessage tone="error" onRetry={() => void profilesQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : !profilesQ.data ? (
<LoadingState shape="list" />
) : (
<div className="mb-4">
<DataTable
rows={profiles}
columns={columns}
rowKey={(p) => String(p.id)}
pager={false}
keyboardNav
fixedLayout
rowProps={(p) => menu.triggerProps(p)}
/>
</div>
)}
<ContextMenu
menu={menu}
ariaLabel={t("downloads.profiles.rowMenu")}
items={menu.target ? items(menu.target) : []}
/>
{!formOpen ? (
<Button variant="primary" size="sm" icon={<Plus className="w-4 h-4" />} onClick={startNew}>
{t("downloads.profiles.new")}
</Button>
) : (
<div className="rounded-xl border border-border p-4 space-y-3">
<div>
<label htmlFor={nameId} className="block text-sm font-medium mb-1">
{t("downloads.profiles.name")}
</label>
<Input
id={nameId}
ref={nameField}
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.profiles.namePlaceholder")}
/>
</div>
{/* Mode on its own row: three worded choices need the width a half column lacks on a
phone. Segmented for the short fixed sets, a menu for the seven heights (owner's pick). */}
<div>
<span className="block text-sm font-medium mb-1" aria-hidden="true">
{t("downloads.profiles.mode")}
</span>
<SegmentedToggle
ariaLabel={t("downloads.profiles.mode")}
size="md"
fullWidth
value={spec.mode}
onChange={(mode) => patch({ mode })}
options={[
{ value: "av", label: t("downloads.profiles.modeAv") },
{ value: "v", label: t("downloads.profiles.modeV") },
{ value: "a", label: t("downloads.profiles.modeA") },
]}
/>
</div>
<div>
{spec.mode !== "a" ? (
<div>
<span className="block text-sm font-medium mb-1" aria-hidden="true">
{t("downloads.profiles.quality")}
</span>
<FilterSelect
label={t("downloads.profiles.quality")}
hideLabel
selected={[String(spec.max_height ?? "")]}
onChange={([v]) => v !== undefined && patch({ max_height: v ? Number(v) : null })}
options={HEIGHTS.map((h) => ({
value: String(h ?? ""),
label: h ? `${h}p` : t("downloads.profiles.best"),
}))}
/>
</div>
) : (
<div>
<span className="block text-sm font-medium mb-1" aria-hidden="true">
{t("downloads.profiles.audioFormat")}
</span>
{/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it
unreliably as a bare file, and the module's purpose is in-browser playback. */}
<SegmentedToggle
ariaLabel={t("downloads.profiles.audioFormat")}
size="md"
value={spec.audio_format ?? "m4a"}
onChange={(audio_format) => patch({ audio_format })}
options={[
{ value: "m4a", label: "M4A" },
{ value: "mp3", label: "MP3" },
]}
/>
</div>
)}
{/* Container/codec are no longer user-selectable: every download is clamped to the
mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a
shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
</div>
{/* One option a line: a CheckRow is inline, and in the wide Settings column four of them
ran together on one line. */}
<div className="flex flex-col items-start gap-1.5 pt-1">
{spec.mode !== "a" && (
<Check
label={t("downloads.profiles.embedSubs")}
checked={spec.embed_subs}
onChange={(v) => patch({ embed_subs: v })}
/>
)}
<Check
label={t("downloads.profiles.embedChapters")}
checked={spec.embed_chapters}
onChange={(v) => patch({ embed_chapters: v })}
/>
<Check
label={t("downloads.profiles.embedThumbnail")}
checked={spec.embed_thumbnail}
onChange={(v) => patch({ embed_thumbnail: v })}
/>
<Check
label={t("downloads.profiles.sponsorblock")}
checked={spec.sponsorblock}
onChange={(v) => patch({ sponsorblock: v })}
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={() => setFormOpen(false)}>
{t("downloads.actions.cancel")}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
>
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
</Button>
</div>
</div>
)}
</div>
);
}
export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
return (
<Modal
initialFocus="close"
title={t("downloads.profiles.title")}
onClose={onClose}
maxWidth="max-w-xl"
>
<DownloadFormats />
</Modal>
);
}
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
//
// Settings → Account names the account's latest sign-in (prod UAT 2026-09-19: only the admin's user
// list showed it), and its role in the reader's language — the raw `user` read in English under HU.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import type { Me } from "../../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars?.time != null ? `${key}:${String(vars.time)}` : key,
i18n: { language: "en" },
}),
};
});
vi.mock("../../lib/format", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/format")>();
return { ...actual, formatDateTime: (iso: string) => `at ${iso}` };
});
import { AccountHeader } from "./SettingsPanel";
afterEach(cleanup);
const me = (extra: Partial<Me>): Me =>
({
email: "someone@example.com",
display_name: "Someone",
avatar_url: null,
role: "user",
last_login_at: null,
...extra,
}) as Me;
describe("AccountHeader", () => {
it("names the latest sign-in and the role in the reader's language", () => {
render(<AccountHeader me={me({ last_login_at: "2026-09-19T02:00:00Z" })} />);
expect(screen.getByTestId("account-last-login").textContent).toBe(
"settings.account.lastLogin:at 2026-09-19T02:00:00Z",
);
expect(screen.getByText("nav.role.user")).toBeTruthy();
});
it("says when none is recorded", () => {
render(<AccountHeader me={me({})} />);
expect(screen.getByTestId("account-last-login").textContent).toBe(
"settings.account.lastLoginNone",
);
});
});
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
//
// The per-user download-cookies section is WRITE-ONLY: the stored blob is never fetched or
// shown back, so opening "Set"/"Update" always reveals an EMPTY field, and saving posts exactly the
// pasted value to the matching host. This drives the real DownloadAuth against a mocked api so we can
// assert the field never carries a prior value and the right host is written.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
downloadCredentials: vi.fn(),
setDownloadCredential: vi.fn(),
deleteDownloadCredential: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../../lib/api", () => ({ api: h }));
// Partial mock: SettingsPanel's module graph (PrefsProvider) also pulls getNotifSettings from here.
vi.mock("../../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/notifications")>();
return { ...actual, notify: vi.fn() };
});
vi.mock("../../components/ui/confirmContext", () => ({
useConfirm: () => vi.fn(async () => true),
}));
import { DownloadAuth } from "./SettingsPanel";
const HOSTS = [
{ host_key: "facebook", label: "Facebook", configured: false, updated_at: null },
{
host_key: "instagram",
label: "Instagram",
configured: true,
updated_at: "2026-08-01T00:00:00Z",
},
];
function renderPanel() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<DownloadAuth />
</QueryClientProvider>,
);
}
describe("DownloadAuth (write-only cookies)", () => {
beforeEach(() => {
h.downloadCredentials.mockResolvedValue({ hosts: HOSTS });
h.setDownloadCredential.mockResolvedValue({ ok: true, hosts: HOSTS });
h.deleteDownloadCredential.mockResolvedValue({ ok: true, hosts: HOSTS });
});
afterEach(cleanup);
it("lists every supported host with its configured state", async () => {
renderPanel();
await screen.findByTestId("cookie-host-facebook");
expect(screen.getByTestId("cookie-status-facebook").textContent).toContain(
"settings.downloadAuth.notSet",
);
expect(screen.getByTestId("cookie-status-instagram").textContent).toContain(
"settings.downloadAuth.configured",
);
});
it("opens an EMPTY field on Set and posts the pasted value to that host", async () => {
renderPanel();
await screen.findByTestId("cookie-host-facebook");
// The Set button is the outline action on the facebook row.
const setBtn = screen.getByText("settings.downloadAuth.set");
fireEvent.click(setBtn);
const field = (await screen.findByTestId("cookie-input-facebook")) as HTMLTextAreaElement;
expect(field.value).toBe(""); // write-only: never prefilled
fireEvent.change(field, {
target: { value: "# Netscape\n.facebook.com\tTRUE\t/\tT\t0\tc\t1\n" },
});
fireEvent.click(screen.getByText("settings.downloadAuth.save"));
await waitFor(() =>
expect(h.setDownloadCredential).toHaveBeenCalledWith(
"facebook",
"# Netscape\n.facebook.com\tTRUE\t/\tT\t0\tc\t1\n",
),
);
});
it("re-opening Update on a configured host still shows an empty field", async () => {
renderPanel();
await screen.findByTestId("cookie-host-instagram");
fireEvent.click(screen.getByText("settings.downloadAuth.update"));
const field = (await screen.findByTestId("cookie-input-instagram")) as HTMLTextAreaElement;
expect(field.value).toBe(""); // the stored blob is never fetched into the field
});
it("renders an honest error state with retry when the fetch fails, not an empty section", async () => {
// Honest-states: a failed load must NOT render as a bare empty section (no host rows).
h.downloadCredentials.mockRejectedValueOnce(new Error("boom"));
h.downloadCredentials.mockResolvedValue({ hosts: HOSTS });
renderPanel();
// The error message shows and no host rows render.
await screen.findByText("settings.downloadAuth.loadError");
expect(screen.queryByTestId("cookie-host-facebook")).toBeNull();
// Retry refetches → the list appears.
fireEvent.click(screen.getByText("common.retry"));
await screen.findByTestId("cookie-host-facebook");
});
});
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
//
// Settings has a Downloads tab: the download formats and the sign-ins to sites that need one, both
// preferences of the downloading. The sign-ins left the Account tab for it. i18n is key-passthrough.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "../../lib/api";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../../lib/storage", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/storage")>()),
// Opened on the Downloads tab: Appearance, the default, needs the whole prefs provider.
useTabPersistedState: () => useState("downloads"),
}));
vi.mock("../../lib/useMe", () => ({
useMe: () => ({ id: 1, role: "user", email: "a@b.c", google_enabled: false }),
}));
vi.mock("../../features/prefs/prefsContext", () => ({ usePrefs: () => ({ saveState: "idle" }) }));
vi.mock("../../features/wizard/wizardContext", () => ({
useWizard: () => ({ openWizard: vi.fn() }),
}));
vi.mock("../../components/ui/confirmContext", () => ({
useConfirm: () => vi.fn(async () => true),
}));
import SettingsPanel from "./SettingsPanel";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function mount() {
vi.spyOn(api, "downloadProfiles").mockResolvedValue([]);
vi.spyOn(api, "downloadCredentials").mockResolvedValue({ hosts: [] } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<SettingsPanel />
</QueryClientProvider>,
);
}
describe("Settings · Downloads", () => {
it("holds the formats and the site sign-ins; Account no longer does", async () => {
mount();
expect(await screen.findByText("settings.downloads.formats")).toBeTruthy();
expect(screen.getByText("settings.downloadAuth.title")).toBeTruthy();
fireEvent.click(screen.getByTestId("settings-tab-account"));
expect(screen.queryByText("settings.downloadAuth.title")).toBeNull();
});
});
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
//
// Ending every other session, or changing the password, revokes every API token on the server — so
// the Tokens tab must not then show them from its cache as if they still worked.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Me } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
const h = vi.hoisted(() => ({
logoutOthers: vi.fn(async () => ({ ok: true })),
setPassword: vi.fn(async (_p: string, _c?: string) => ({ ok: true })),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../../lib/api", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/api")>()),
api: h,
}));
vi.mock("../../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/notifications")>();
return { ...actual, notify: vi.fn() };
});
vi.mock("../../components/ui/confirmContext", () => ({
useConfirm: () => vi.fn(async () => true),
}));
import { SignInMethods } from "./SettingsPanel";
afterEach(cleanup);
const me = { email: "a@example.com", has_password: true, google_enabled: false } as Me;
function renderIt() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.apiTokens(), [{ id: 1 }]);
render(
<QueryClientProvider client={qc}>
<SignInMethods me={me} />
</QueryClientProvider>,
);
return qc;
}
const tokensStale = (qc: QueryClient) => qc.getQueryState(qk.apiTokens())?.isInvalidated;
describe("SignInMethods", () => {
it("signing the other sessions out marks the token list stale", async () => {
const qc = renderIt();
fireEvent.click(screen.getByText("settings.account.sessions.action"));
await waitFor(() => expect(h.logoutOthers).toHaveBeenCalled());
await waitFor(() => expect(tokensStale(qc)).toBe(true));
});
it("a password changed marks the token list stale", async () => {
const qc = renderIt();
fireEvent.click(screen.getByText("settings.account.password.change"));
fireEvent.change(screen.getByPlaceholderText("settings.account.password.current"), {
target: { value: "old one" },
});
fireEvent.change(screen.getByPlaceholderText("settings.account.password.new"), {
target: { value: "a brand new one" },
});
fireEvent.click(screen.getByText("settings.account.password.save"));
await waitFor(() => expect(h.setPassword).toHaveBeenCalled());
await waitFor(() => expect(tokensStale(qc)).toBe(true));
});
});
@@ -0,0 +1,903 @@
import { useState } from "react";
import { connectGoogle } from "../../lib/googleConnect";
import { qk } from "../../lib/queryKeys";
import { formatDate, formatDateTime, formatUnit } from "../../lib/format";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Activity,
Bell,
Check,
Download,
KeyRound,
Loader2,
Monitor,
RefreshCw,
Trash2,
User,
} from "lucide-react";
import { DownloadFormats } from "./ProfileEditor";
import DiagnosticsSettings from "./DiagnosticsSettings";
import ApiTokens from "./ApiTokens";
import { type SettingsTabId } from "../../lib/settingsTab";
import { SCHEMES, type Scheme, type ThemePrefs } from "../../lib/theme";
import { api, HttpError, type Me } from "../../lib/api";
import { displayName, roleKey } from "../../lib/api/account";
import { notifyActionError } from "../../lib/actionErrors";
import { LS, useTabPersistedState } from "../../lib/storage";
import Avatar from "../../components/ui/Avatar";
import { notify, type NotifSettings } from "../../lib/notifications";
import Tooltip from "../../components/ui/Tooltip";
import { Section, SwitchRow } from "../../components/ui/form";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import { type SaveState } from "../../components/ui/DraftSaveBar";
import { useConfirm } from "../../components/ui/confirmContext";
import { StateMessage } from "../../components/ui/QueryState";
import { useWizard } from "../../features/wizard/wizardContext";
import {
START_ON_HOME_OPTIONS,
START_ON_HOME_PREF,
} from "../../features/navigation/navigationInit";
import { usePref } from "../../lib/usePref";
import { usePrefs } from "../../features/prefs/prefsContext";
import Tabs from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel";
import { useMe } from "../../lib/useMe";
import { Pressable } from "../../components/ui/Pressable";
import { Slider } from "../../components/ui/NativeControls";
import { Input, Textarea } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
// outcome. PrefsProvider owns the state + the save; this controller
// is how the panel reads/writes it.
export type PrefsSaveState = SaveState;
export interface PrefsController {
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;
perf: boolean;
setPerf: (v: boolean) => void;
hints: boolean;
setHints: (v: boolean) => void;
railLabels: boolean;
setRailLabels: (v: boolean) => void;
notif: NotifSettings;
setNotif: (n: NotifSettings) => void;
saveState: PrefsSaveState;
}
type TabId = SettingsTabId;
const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor },
{ id: "notifications", icon: Bell },
{ id: "downloads", icon: Download },
{ id: "tokens", icon: KeyRound },
{ id: "account", icon: User },
{ id: "diagnostics", icon: Activity },
];
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
// shown by the Header; the tab rail stays as in-page sub-navigation.
export default function SettingsPanel() {
const me = useMe();
const { t } = useTranslation();
// The editable prefs (theme/perf/hints/notifications + save state) live in PrefsProvider.
const prefs = usePrefs();
// "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button).
const { openWizard } = useWizard();
const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance");
const panel = useTabPanel();
// Clamp at render so a stale/removed tab id falls back to Appearance.
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
? (tabRaw as TabId)
: "appearance";
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<div className={`${SURFACE.panel} overflow-hidden`}>
<div className="flex flex-col sm:flex-row">
{/* Tab rail: a scrolling row above the content on a phone, the side rail from `sm` up (see
Tabs). Active is a clear accent pill. */}
<Tabs
idBase={panel.idBase}
orientation="vertical"
className="flex gap-1 overflow-x-auto border-b border-border/60 p-2 sm:w-36 sm:shrink-0 sm:flex-col sm:border-b-0 sm:border-r"
active={tab}
onChange={setTab}
tabs={TABS.map((tabItem) => ({
id: tabItem.id,
label: t(`settings.tabs.${tabItem.id}`),
icon: tabItem.icon,
testId: `settings-tab-${tabItem.id}`,
}))}
/>
{/* Render only the active tab so the panel sizes to its actual content. (Stacking
every tab in one grid cell made the whole panel as tall as the tallest tab —
Account — leaving the short tabs with dead space and a needless scrollbar.) */}
<div {...panel.panelProps(tab)} className="flex-1 min-w-0 p-4">
{tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "downloads" && <Downloads />}
{tab === "tokens" && <ApiTokens />}
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
{tab === "diagnostics" && <DiagnosticsSettings />}
</div>
</div>
{/* Auto-save indicator — a change on any tab persists immediately; this just flashes the
outcome (the Account tab's actions have their own feedback). */}
<SaveIndicator state={prefs.saveState} />
</div>
</div>
);
}
// A small transient indicator of the auto-save outcome. Renders nothing while idle; it sits at the
// bottom of the settings card and fades after a success (the state resets to "idle" in App).
function SaveIndicator({ state }: { state: PrefsSaveState }) {
const { t } = useTranslation();
if (state === "idle") return null;
return (
<div
data-testid="settings-save-indicator"
data-state={state}
className="flex items-center justify-end gap-1.5 border-t border-border/60 px-4 py-2.5 text-sm"
>
{state === "saving" ? (
<span className="inline-flex items-center gap-1.5 text-muted">
<Loader2 className="w-4 h-4 animate-spin" />
{t("settings.save.saving")}
</span>
) : state === "saved" ? (
<span className="inline-flex items-center gap-1.5 text-success">
<Check className="w-4 h-4" />
{t("settings.save.saved")}
</span>
) : (
<span className="text-danger">{t("settings.save.failed")}</span>
)}
</div>
);
}
function Appearance({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
const { theme, setTheme, perf, setPerf, hints, setHints, railLabels, setRailLabels } = prefs;
// The landing page: read at boot (NavigationProvider), so a change applies from the next start.
const [startOnHome, setStartOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS);
return (
<>
<Section title={t("settings.appearance.colorScheme")}>
{/* One row whatever the count: a fifth scheme wrapped alone onto a second line. */}
<div className="grid auto-cols-fr grid-flow-col gap-2">
{SCHEMES.map((s) => (
<Tooltip key={s.id} hint={s.name}>
<Pressable
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
aria-label={s.name}
aria-pressed={theme.scheme === s.id}
className={`h-9 w-full rounded-lg border-2 transition touch-target ${
theme.scheme === s.id ? "border-fg" : "border-transparent"
}`}
style={{ background: s.swatch }}
/>
</Tooltip>
))}
</div>
</Section>
<Section title={t("settings.appearance.display")}>
<SwitchRow
label={t("settings.appearance.darkMode")}
checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
<SwitchRow
label={t("settings.appearance.backgroundImage")}
hint={t("settings.appearance.backgroundImageHint")}
checked={theme.bgImage}
onChange={(v) => setTheme({ ...theme, bgImage: v })}
/>
{theme.bgImage && (
<div className="pl-1 pb-1">
<div className="flex items-center justify-between text-xs text-muted mb-1">
<span>{t("settings.appearance.backgroundFade")}</span>
<span className="tabular-nums">{theme.bgFade}%</span>
</div>
<Slider
min={0}
max={100}
step={1}
value={theme.bgFade}
onChange={(v) => setTheme({ ...theme, bgFade: v })}
aria-label={t("settings.appearance.backgroundFade")}
/>
</div>
)}
<SwitchRow
label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")}
checked={perf}
onChange={setPerf}
/>
<SwitchRow
label={t("settings.appearance.showHints")}
hint={t("settings.appearance.showHintsHint")}
checked={hints}
onChange={setHints}
/>
<SwitchRow
label={t("settings.appearance.railLabels")}
hint={t("settings.appearance.railLabelsHint")}
checked={railLabels}
onChange={setRailLabels}
/>
</Section>
<Section title={t("settings.appearance.startup")}>
<SwitchRow
label={t("settings.appearance.startOnHome")}
hint={t("settings.appearance.startOnHomeHint")}
checked={startOnHome}
onChange={setStartOnHome}
/>
</Section>
<Section title={t("settings.appearance.textSize")}>
<Slider
min={0.9}
max={1.3}
step={0.02}
value={theme.fontScale}
onChange={(v) => setTheme({ ...theme, fontScale: v })}
aria-label={t("settings.appearance.textSize")}
/>
<div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div>
</Section>
</>
);
}
function Notifications({ prefs }: { prefs: PrefsController }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Controlled by PrefsProvider (live preview via configureNotifications there), which auto-saves
// it. The "send test" button below fires immediately.
const { notif: cfg, setNotif } = prefs;
const update = (p: Partial<NotifSettings>) => setNotif({ ...cfg, ...p });
const autoOn = cfg.durationMs > 0;
return (
<Section title={t("settings.notifications.title")}>
<SwitchRow
label={t("settings.notifications.sound")}
hint={t("settings.notifications.soundHint")}
checked={cfg.sound}
onChange={(v) => update({ sound: v })}
/>
<SwitchRow
label={t("settings.notifications.autoDismiss")}
hint={t("settings.notifications.autoDismissHint")}
checked={autoOn}
onChange={(v) => update({ durationMs: v ? 6000 : 0 })}
/>
{autoOn && (
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted text-xs">{t("settings.notifications.dismissAfter")}</span>
<span className="text-muted text-xs">
{formatUnit(Math.round(cfg.durationMs / 1000), "second", lang)}
</span>
</div>
<Slider
className="mt-1"
min={2000}
max={15000}
step={1000}
value={cfg.durationMs}
onChange={(v) => update({ durationMs: v })}
aria-label={t("settings.notifications.dismissAfter")}
/>
</div>
)}
<Button
onClick={() =>
notify({
level: "info",
title: t("settings.notifications.testTitle"),
message: t("settings.notifications.testMessage"),
sound: true,
})
}
variant="link"
className="mt-2 text-sm"
>
{t("settings.notifications.sendTest")}
</Button>
</Section>
);
}
function AccessRow({
title,
granted,
grantedHint,
enableHint,
onEnable,
}: {
title: string;
granted: boolean;
grantedHint: string;
enableHint: string;
onEnable: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{title}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{granted ? grantedHint : enableHint}
</p>
</div>
{granted ? (
<Badge tone="accent">{t("settings.account.granted")}</Badge>
) : (
<Tooltip hint={t("settings.account.enableHint")}>
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
{t("settings.account.enable")}
</Button>
</Tooltip>
)}
</div>
);
}
// Sign-in methods: link a Google account to a password account (or vice-versa set a password),
// so either method can reach the same account. Google connect is a full-page OAuth round-trip
// (auth.py attaches the identity to the signed-in account via /auth/google/connect); the password form posts
// directly with inline errors.
export function SignInMethods({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [sessBusy, setSessBusy] = useState(false);
// SA4: sign every OTHER session for this account out (this one stays). For a shared/left-behind
// device or a suspected compromise.
const logoutOthers = async () => {
if (
!(await confirm({
title: t("settings.account.sessions.confirmTitle"),
message: t("settings.account.sessions.confirmBody"),
confirmLabel: t("settings.account.sessions.action"),
danger: true,
}))
)
return;
setSessBusy(true);
try {
await api.logoutOthers();
// Every API token went with the other sessions: the Tokens tab asks again when it next opens,
// rather than trusting its cached list for another half minute.
void qc.invalidateQueries({ queryKey: qk.apiTokens() });
notify({ level: "success", message: t("settings.account.sessions.done") });
} catch (e) {
notifyActionError(e, t("settings.account.sessions.failed"));
}
setSessBusy(false);
};
const submit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setErr(null);
setBusy(true);
const confirmCurrent = me.has_password ? current : undefined;
try {
await api.setPassword(next, confirmCurrent);
setOpen(false);
setCurrent("");
setNext("");
// Refresh `me` so has_password flips and the section switches to "Change password". Every API
// token went with the old password, so the Tokens tab asks again when it next opens, too.
void qc.invalidateQueries({ queryKey: qk.apiTokens() });
await qc.invalidateQueries({ queryKey: qk.me() });
notify({
level: "success",
message: t("settings.account.password.saved", { email: me.email }),
});
} catch (e) {
setErr(
(e instanceof HttpError ? e.detail : undefined) ?? t("settings.account.password.failed"),
);
}
setBusy(false);
};
// Offer Google only when this instance has Google OAuth configured (or the account is already
// linked — then we still show its "Connected" status even if the instance creds were removed).
const showGoogle = me.google_enabled || me.has_google;
return (
<Section title={t("settings.account.signInMethods")}>
{showGoogle && (
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.googleLink.title")}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{me.has_google
? t("settings.account.googleLink.connectedHint")
: t("settings.account.googleLink.connectHint")}
</p>
{/* Which address Google actually answers with — sent only when it differs from the
account's own (see /api/me). "Connected" alone left that unanswerable, and it is
exactly what someone who registered with a `+tag` needs to know. */}
{me.google_email && (
<p className="text-xs text-fg/80 mt-1.5">
<span className="text-muted">{t("settings.account.googleLink.signedInAs")}</span>{" "}
{me.google_email}
</p>
)}
</div>
{me.has_google ? (
<Badge tone="accent">{t("settings.account.googleLink.connected")}</Badge>
) : (
<Button variant="glass" size="sm" onClick={() => connectGoogle("identity")}>
{t("settings.account.googleLink.connect")}
</Button>
)}
</div>
)}
<div className={showGoogle ? "border-t border-border mt-1 pt-1" : ""}>
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.password.title")}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{me.has_password
? t("settings.account.password.setHint")
: t("settings.account.password.unsetHint")}
</p>
</div>
<Button
variant="glass"
size="sm"
aria-expanded={open}
onClick={() => {
setOpen((o) => !o);
setErr(null);
}}
>
{me.has_password
? t("settings.account.password.change")
: t("settings.account.password.set")}
</Button>
</div>
{open && (
<form onSubmit={submit} className="space-y-2 pt-1 pb-1">
{me.has_password && (
<Input
className="w-full"
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
placeholder={t("settings.account.password.current")}
autoComplete="current-password"
/>
)}
<Input
className="w-full"
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
placeholder={t("settings.account.password.new")}
autoComplete="new-password"
/>
{err && (
<p role="alert" className="text-xs text-danger">
{err}
</p>
)}
<Button type="submit" variant="primary" size="sm" disabled={busy || !next}>
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
</Button>
</form>
)}
</div>
<div className="border-t border-border mt-1 pt-1">
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.sessions.title")}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{t("settings.account.sessions.hint")}
</p>
</div>
<Button
variant="glass"
size="sm"
className="shrink-0"
onClick={logoutOthers}
disabled={sessBusy}
>
{t("settings.account.sessions.action")}
</Button>
</div>
</div>
</Section>
);
}
// Two-way Plex watch-state sync (Phase A): the owner links their account to the Plex admin account
// and does a one-time "Plex is master" import. Admin-only (rides the server admin token) and only
// shown when the Plex module is enabled. Two-way push/pull arrive in later phases.
function PlexWatchSync() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() });
const enabled = link.data?.sync_enabled ?? false;
const announce = (imp: { watched: number; in_progress: number } | null | undefined) => {
if (imp) {
notify({
level: "success",
message: t("settings.plexSync.imported", {
watched: imp.watched,
in_progress: imp.in_progress,
}),
});
// The browse grid's watch badges read plex_states — refresh them.
qc.invalidateQueries({ queryKey: qk.plex() });
}
};
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say
// "connect YouTube"). Their 409/500 open the global error dialog and the rest go to the app-wide
// reporter (lib/queryClient). Actual import failures come back as 200 {error}, handled by announce.
const toggle = useMutation({
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
meta: { failTitle: "errors.writeFailed.plexWatchLink" },
onSuccess: (res) => {
qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import);
},
});
const reimport = useMutation({
mutationFn: () => api.plexWatchImport(),
meta: { failTitle: "errors.writeFailed.plexWatchImport" },
onSuccess: (res) => {
qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import);
},
});
const busy = toggle.isPending || reimport.isPending;
// Reflect the toggle's target immediately (optimistic) so the switch responds on click rather than
// only flipping once the multi-second import returns — otherwise it reads as "nothing happened".
const shown = toggle.isPending ? Boolean(toggle.variables) : enabled;
const last = link.data?.last_watch_sync_at
? formatDateTime(link.data.last_watch_sync_at, lang)
: null;
return (
<Section title={t("settings.plexSync.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p>
<SwitchRow
label={t("settings.plexSync.toggle")}
hint={t("settings.plexSync.toggleHint")}
checked={shown}
disabled={busy}
onChange={(v) => !busy && toggle.mutate(v)}
/>
{busy ? (
<div className="mt-2 flex items-center gap-2 text-xs text-muted">
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
{t("settings.plexSync.importing")}
</div>
) : (
enabled && (
<div className="mt-2 flex items-center justify-between gap-3">
<p className="text-xs text-muted">
{last
? t("settings.plexSync.lastSync", { when: last })
: t("settings.plexSync.never")}
</p>
<Button
variant="outline"
size="sm"
icon={<RefreshCw />}
loading={reimport.isPending}
onClick={() => reimport.mutate()}
>
{t("settings.plexSync.importNow")}
</Button>
</div>
)
)}
</Section>
);
}
// Per-user download authentication: store your own login cookies for an auth-walled host
// (e.g. a private Facebook group you belong to) so downloads from it use YOUR login. Write-only —
// the stored blob is never shown back; each row shows only the host + whether it's set + when.
// A cookie-authed download is kept private to your account (never cache-shared with another user).
// Exported for a focused DOM test (write-only behaviour); rendered on the Downloads tab below.
export function DownloadAuth() {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const q = useQuery({
queryKey: qk.downloadCredentials(),
queryFn: () => api.downloadCredentials(),
});
const [openHost, setOpenHost] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const save = useMutation({
mutationFn: ({ host, cookies }: { host: string; cookies: string }) =>
api.setDownloadCredential(host, cookies),
onSuccess: (res) => {
qc.setQueryData(qk.downloadCredentials(), res);
setOpenHost(null);
setDraft("");
notify({ level: "success", message: t("settings.downloadAuth.saved") });
},
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the
// statuses it does not cover go to the app-wide reporter.
meta: { failTitle: "settings.downloadAuth.failed" },
});
const remove = useMutation({
mutationFn: (host: string) => api.deleteDownloadCredential(host),
meta: { failTitle: "errors.writeFailed.downloadCookiesRemove" },
onSuccess: (res) => {
qc.setQueryData(qk.downloadCredentials(), res);
notify({ level: "success", message: t("settings.downloadAuth.removed") });
},
});
const hosts = q.data?.hosts ?? [];
const onRemove = async (host: string) => {
const ok = await confirm({
title: t("settings.downloadAuth.removeConfirmTitle"),
message: t("settings.downloadAuth.removeConfirmBody"),
confirmLabel: t("settings.downloadAuth.removeConfirmAction"),
danger: true,
});
if (ok) remove.mutate(host);
};
return (
<Section title={t("settings.downloadAuth.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.downloadAuth.intro")}</p>
{/* The host list is server-provided (always non-empty on success), so an empty `hosts` means
the fetch failed or is still loading — render an honest state, never a bare empty section
that reads as "nothing to configure here". */}
{q.isError && hosts.length === 0 ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("settings.downloadAuth.loadError")}
</StateMessage>
) : q.isLoading ? (
<StateMessage>{t("common.loading")}</StateMessage>
) : (
<div className="divide-y divide-border">
{hosts.map((h) => {
const when = h.updated_at ? formatDate(h.updated_at, i18n.language) : "";
const open = openHost === h.host_key;
return (
<div key={h.host_key} className="py-2" data-testid={`cookie-host-${h.host_key}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium">{h.label}</div>
<p
className="text-xs text-muted mt-0.5"
data-testid={`cookie-status-${h.host_key}`}
>
{h.configured
? t("settings.downloadAuth.configured", { when })
: t("settings.downloadAuth.notSet")}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{h.configured && (
<Button
variant="danger-soft"
size="sm"
onClick={() => onRemove(h.host_key)}
disabled={remove.isPending}
>
{t("settings.downloadAuth.remove")}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
setOpenHost(open ? null : h.host_key);
setDraft("");
}}
>
{h.configured
? t("settings.downloadAuth.update")
: t("settings.downloadAuth.set")}
</Button>
</div>
</div>
{open && (
<div className="mt-2 space-y-2">
{/* Write-only: the field is ALWAYS empty on open — the stored blob is never
fetched or prefilled. */}
<Textarea
className="w-full font-mono resize-y"
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={5}
placeholder={t("settings.downloadAuth.placeholder")}
data-testid={`cookie-input-${h.host_key}`}
spellCheck={false}
autoComplete="off"
/>
<p className="text-2xs text-muted leading-relaxed">
{t("settings.downloadAuth.help")}
</p>
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
disabled={!draft.trim() || save.isPending}
onClick={() => save.mutate({ host: h.host_key, cookies: draft })}
>
{save.isPending
? t("settings.downloadAuth.saving")
: t("settings.downloadAuth.save")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setOpenHost(null);
setDraft("");
}}
>
{t("settings.downloadAuth.cancel")}
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</Section>
);
}
/** Who is signed in: name, address, role — and the latest sign-in on any device, so one the user does
* not recognise is something they can see. */
export function AccountHeader({ me }: { me: Me }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
return (
<Section title={t("settings.account.title")}>
<div className="flex items-center gap-3 mb-3">
<Avatar
src={me.avatar_url}
fallback={me.display_name ?? me.email}
className="w-12 h-12 rounded-full"
/>
<div className="min-w-0">
<div className="font-semibold truncate">{displayName(me)}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
{/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */}
<div className="text-xs text-muted">{t(`nav.role.${roleKey(me)}`)}</div>
<div className="text-xs text-muted" data-testid="account-last-login">
{me.last_login_at
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) })
: t("settings.account.lastLoginNone")}
</div>
</div>
</div>
</Section>
);
}
// How this account downloads: its formats (the download dialog's choices) and its sign-ins to sites
// that need one. Preferences of the downloading, not of the account's identity.
function Downloads() {
const { t } = useTranslation();
return (
<>
<Section title={t("settings.downloads.formats")}>
<p className="text-xs text-muted leading-relaxed mb-2">
{t("settings.downloads.formatsIntro")}
</p>
<DownloadFormats />
</Section>
<DownloadAuth />
</>
);
}
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const { t } = useTranslation();
const confirm = useConfirm();
const onDeleteAccount = async () => {
const ok = await confirm({
title: t("settings.account.deleteTitle"),
message: t("settings.account.deleteConfirm"),
confirmLabel: t("settings.account.deleteConfirmButton"),
danger: true,
});
if (!ok) return;
try {
await api.deleteAccount();
// Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
window.location.href = "/?deleted=1";
} catch (e) {
// A refusal with a reason (the last admin, a 409) is already in the global error dialog, which
// the reporter skips; anything else used to end the confirmed delete with nothing said.
notifyActionError(e, t("errors.writeFailed.deleteAccount"));
}
};
return (
<>
<AccountHeader me={me} />
<SignInMethods me={me} />
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
{me.google_enabled ? (
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed mb-1">
{t("settings.account.youtubeAccessIntro")}
</p>
<div className="divide-y divide-border">
<AccessRow
title={t("settings.account.readTitle")}
granted={me.can_read}
grantedHint={t("settings.account.readGrantedHint")}
enableHint={t("settings.account.readEnableHint")}
onEnable={() => connectGoogle("read")}
/>
<AccessRow
title={t("settings.account.writeTitle")}
granted={me.can_write}
grantedHint={t("settings.account.writeGrantedHint")}
enableHint={t("settings.account.writeEnableHint")}
onEnable={() => connectGoogle("write")}
/>
</div>
<Button variant="link" className="mt-2 text-sm" onClick={onOpenWizard}>
{t("settings.account.walkMeThrough")}
</Button>
</Section>
) : null}
<Section title={t("settings.account.dangerZone")}>
<p className="text-xs text-muted leading-relaxed mb-2">
{t("settings.account.deleteHint")}
</p>
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
<Trash2 className="w-4 h-4" />
{t("settings.account.deleteAccount")}
</Button>
</Section>
</>
);
}