feat(files): API tokens in Settings, the Hub folder in Files, a notice before a token runs out
Settings gains an API tokens tab: a row per token (name and prefix; each folder with its rights; last use and expiry, "expires soon" marked), revoked from its menu behind a confirm. A new token is the Hub exchange in one click, or folders and rights picked per folder (no Write where an upload cannot land); expiry 30/90 days, 1 year or never (warned). The token shows once in the same dialog, with Copy and a ready curl line. The Hub folder closes the Sources in the Files rail, takes uploads and moved-in files. A token about to run out warns its user once, a week before (the Files GC's sixth pass); the notice opens the tokens tab.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Settings → API tokens: a row per token says what it is, what it opens with which rights, when it was
|
||||
// last used and when it runs out (marked when that is soon); revoking asks first. A new token is the
|
||||
// Hub exchange in one click, or folders and rights picked one by one — never Write where an upload
|
||||
// cannot land — and its value is shown once, with a curl line to try it. i18n is key-passthrough
|
||||
// (a key's options ride along in brackets, so a test can read what was named).
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { api, type ApiToken, type FileFolder } from "../lib/api";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ confirm: vi.fn(async (_o?: unknown) => true) }));
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, o?: Record<string, unknown>) =>
|
||||
o && Object.keys(o).length ? `${key}(${Object.values(o).join(",")})` : key,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||
|
||||
import ApiTokens from "./ApiTokens";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
mocks.confirm.mockClear();
|
||||
});
|
||||
|
||||
const token = (id: number, extra: Partial<ApiToken> = {}): ApiToken => ({
|
||||
id,
|
||||
name: `Token ${id}`,
|
||||
prefix: `sfl_${id}abcdefg`,
|
||||
created_at: "2026-10-01T00:00:00Z",
|
||||
expires_at: "2027-10-01T00:00:00Z",
|
||||
expired: false,
|
||||
expiring: false,
|
||||
last_used_at: null,
|
||||
grants: [],
|
||||
...extra,
|
||||
});
|
||||
const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
|
||||
id,
|
||||
kind: "source",
|
||||
source_key: null,
|
||||
name: null,
|
||||
retention_days: 30,
|
||||
count: 1,
|
||||
size_bytes: 0,
|
||||
created_at: null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
function mount(tokens: ApiToken[] | Error, folders: FileFolder[] = []) {
|
||||
if (tokens instanceof Error) vi.spyOn(api, "apiTokens").mockRejectedValue(tokens);
|
||||
else vi.spyOn(api, "apiTokens").mockResolvedValue(tokens);
|
||||
vi.spyOn(api, "fileFolders").mockResolvedValue(folders);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<ApiTokens />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const rows = () => screen.getAllByTestId("api-token-row");
|
||||
const dialog = () => screen.getByRole("dialog");
|
||||
const pressed = (b: HTMLElement) => b.getAttribute("aria-pressed") === "true";
|
||||
const inRow = (group: string, name: string) =>
|
||||
[...screen.getByRole("group", { name: group }).querySelectorAll("button")].find(
|
||||
(b) => b.textContent === name,
|
||||
);
|
||||
|
||||
describe("API tokens · the list", () => {
|
||||
it("reads what a token opens, with which rights, when it was used and when it runs out", async () => {
|
||||
mount([
|
||||
token(1, {
|
||||
name: "Hub exchange",
|
||||
last_used_at: new Date().toISOString(),
|
||||
grants: [
|
||||
{
|
||||
folder_id: 7,
|
||||
kind: "hub",
|
||||
source_key: null,
|
||||
name: null,
|
||||
rights: ["write", "list", "read", "delete"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
token(2, {
|
||||
expiring: true,
|
||||
expires_at: null,
|
||||
grants: [
|
||||
{ folder_id: 3, kind: "own", source_key: null, name: "Receptek", rights: ["read"] },
|
||||
],
|
||||
}),
|
||||
token(3, { expired: true }),
|
||||
]);
|
||||
await waitFor(() => expect(rows()).toHaveLength(3));
|
||||
const [hub, soon, gone] = rows();
|
||||
expect(hub!.textContent).toContain("sfl_1abcdefg…");
|
||||
// Rights in the server's order, whatever order they came in.
|
||||
expect(hub!.textContent).toContain(
|
||||
"files.folder.hub· settings.tokens.rights.list, settings.tokens.rights.read, settings.tokens.rights.write, settings.tokens.rights.delete",
|
||||
);
|
||||
expect(hub!.textContent).toContain("settings.tokens.lastUsed");
|
||||
expect(hub!.textContent).not.toContain("settings.tokens.expiring");
|
||||
expect(soon!.textContent).toContain("Receptek· settings.tokens.rights.read");
|
||||
expect(soon!.textContent).toContain("settings.tokens.expiring");
|
||||
expect(soon!.textContent).toContain("settings.tokens.neverUsed");
|
||||
expect(soon!.textContent).toContain("settings.tokens.neverExpires");
|
||||
expect(gone!.textContent).toContain("settings.tokens.expired");
|
||||
expect(gone!.textContent).toContain("settings.tokens.expiredOn(");
|
||||
});
|
||||
|
||||
it("says when there is none, and when the list could not be read", async () => {
|
||||
mount([]);
|
||||
expect(await screen.findByText("settings.tokens.empty")).toBeTruthy();
|
||||
cleanup();
|
||||
mount(new Error("down"));
|
||||
expect(await screen.findByText("common.loadError")).toBeTruthy();
|
||||
expect(screen.queryByText("settings.tokens.empty")).toBeNull();
|
||||
});
|
||||
|
||||
it("revokes from the row's menu, only once the confirm says yes", async () => {
|
||||
mount([token(5, { name: "Phone" })]);
|
||||
const revoke = vi.spyOn(api, "revokeApiToken").mockResolvedValue({ ok: true, deleted: 5 });
|
||||
await waitFor(() => expect(rows()).toHaveLength(1));
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
const open = async () => {
|
||||
act(() => {
|
||||
fireEvent.contextMenu(rows()[0]!);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "settings.tokens.revoke")!,
|
||||
);
|
||||
};
|
||||
await open();
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.confirm.mock.calls[0]![0]).toMatchObject({
|
||||
title: "settings.tokens.revokeTitle(Phone)",
|
||||
danger: true,
|
||||
});
|
||||
expect(revoke).not.toHaveBeenCalled();
|
||||
await open();
|
||||
await waitFor(() => expect(revoke).toHaveBeenCalledWith(5));
|
||||
});
|
||||
});
|
||||
|
||||
describe("API tokens · making one", () => {
|
||||
const made = (extra: Partial<ApiToken> = {}) => ({
|
||||
ok: true as const,
|
||||
...token(9, extra),
|
||||
token: "sfl_SECRETVALUE",
|
||||
});
|
||||
|
||||
it("makes the Hub exchange in one click, a year long, and shows the token once with a curl line", async () => {
|
||||
mount([]);
|
||||
const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made());
|
||||
fireEvent.click(await screen.findByText("settings.tokens.new"));
|
||||
expect(pressed(screen.getByRole("button", { name: "settings.tokens.hubTitle" }))).toBe(true);
|
||||
expect((screen.getByLabelText("settings.tokens.name") as HTMLInputElement).value).toBe(
|
||||
"settings.tokens.hubName",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.create" }));
|
||||
await waitFor(() =>
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
name: "settings.tokens.hubName",
|
||||
expires_days: 365,
|
||||
preset: "hub",
|
||||
}),
|
||||
);
|
||||
await screen.findByText("settings.tokens.madeTitle");
|
||||
const codes = [...dialog().querySelectorAll("code")].map((c) => c.textContent);
|
||||
expect(codes[0]).toBe("sfl_SECRETVALUE");
|
||||
expect(codes[1]).toBe(
|
||||
`curl -H "Authorization: Bearer sfl_SECRETVALUE" ${window.location.origin}/api/files/folders`,
|
||||
);
|
||||
// The keyboard lands on copying it, not on the closed form's button.
|
||||
expect(document.activeElement?.textContent).toBe("settings.tokens.copy");
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("a custom token takes the folders and rights picked; no Write where nothing can be uploaded", async () => {
|
||||
mount(
|
||||
[],
|
||||
[
|
||||
folder(1, { source_key: "youtube" }),
|
||||
folder(2, { source_key: "upload" }),
|
||||
folder(3, { source_key: "site:x.com", count: 0 }), // empty: not offered, as the rail hides it
|
||||
folder(4, { kind: "hub", count: 0 }),
|
||||
folder(5, { kind: "own", name: "Receptek" }),
|
||||
],
|
||||
);
|
||||
const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made());
|
||||
fireEvent.click(await screen.findByText("settings.tokens.new"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.customTitle" }));
|
||||
const name = screen.getByLabelText("settings.tokens.name") as HTMLInputElement;
|
||||
expect(name.value).toBe("");
|
||||
await screen.findByRole("group", { name: "Receptek" });
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("group")
|
||||
.map((g) => g.getAttribute("aria-label"))
|
||||
.filter((l) => l !== "settings.tokens.kind" && l !== "settings.tokens.expiry"),
|
||||
).toEqual(["files.source.upload", "files.source.youtube", "files.folder.hub", "Receptek"]);
|
||||
expect(inRow("files.source.youtube", "settings.tokens.rights.write")).toBeUndefined();
|
||||
expect(inRow("files.source.upload", "settings.tokens.rights.write")).toBeTruthy();
|
||||
expect(inRow("files.folder.hub", "settings.tokens.rights.write")).toBeTruthy();
|
||||
|
||||
const createBtn = screen.getByRole("button", { name: "settings.tokens.create" });
|
||||
fireEvent.change(name, { target: { value: " Phone " } });
|
||||
expect(createBtn.hasAttribute("disabled")).toBe(true); // a name, but no right yet
|
||||
fireEvent.click(inRow("Receptek", "settings.tokens.rights.list")!);
|
||||
fireEvent.click(inRow("Receptek", "settings.tokens.rights.write")!);
|
||||
fireEvent.click(inRow("files.source.youtube", "settings.tokens.rights.read")!);
|
||||
// Picked and dropped again: no grant for that folder.
|
||||
fireEvent.click(inRow("files.folder.hub", "settings.tokens.rights.read")!);
|
||||
fireEvent.click(inRow("files.folder.hub", "settings.tokens.rights.read")!);
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.expiryOption.30" }));
|
||||
expect(createBtn.hasAttribute("disabled")).toBe(false);
|
||||
fireEvent.click(createBtn);
|
||||
await waitFor(() => expect(create).toHaveBeenCalledTimes(1));
|
||||
const body = create.mock.calls[0]![0];
|
||||
expect(body).toMatchObject({ name: "Phone", expires_days: 30 });
|
||||
expect(body.preset).toBeUndefined();
|
||||
expect(body.grants).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ folder_id: 5, rights: ["list", "write"] },
|
||||
{ folder_id: 1, rights: ["read"] },
|
||||
]),
|
||||
);
|
||||
expect(body.grants).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a token that never runs out says what that means, and is sent without an expiry", async () => {
|
||||
mount([]);
|
||||
const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made());
|
||||
fireEvent.click(await screen.findByText("settings.tokens.new"));
|
||||
expect(screen.queryByText("settings.tokens.neverWarning")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.expiryOption.never" }));
|
||||
expect(screen.getByText("settings.tokens.neverWarning")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.create" }));
|
||||
await waitFor(() =>
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({ expires_days: null })),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { Check, Copy, KeyRound, Plus, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
TOKEN_EXPIRY_DAYS,
|
||||
TOKEN_EXPIRY_DEFAULT,
|
||||
TOKEN_RIGHTS,
|
||||
type ApiToken,
|
||||
type FileFolder,
|
||||
type TokenGrant,
|
||||
type TokenRight,
|
||||
} from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { compareText } from "../lib/collate";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { folderIcon, folderLabel, takesUploads } from "./fileFolders";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import { Input } from "./ui/Input";
|
||||
import Modal, { ModalFooter } from "./ui/Modal";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
|
||||
// Settings → API tokens: a machine's key to the user's Files library (docs/DESIGN.md decision log,
|
||||
// "API tokens"). A row per token in three lines — what it is, what it opens, when it was used and
|
||||
// when it runs out — revoked from its "…" behind a confirm. A new token is made in a dialog that
|
||||
// asks the kind first (the one-click Hub exchange, or folders and rights of the user's own picking)
|
||||
// and, once made, shows the token in the same dialog: the one time it can be seen.
|
||||
|
||||
/** The rights a token holds in a folder, in the reader's words: "list, read, write". */
|
||||
function rightsText(rights: readonly TokenRight[], t: TFunction, lang: string): string {
|
||||
return TOKEN_RIGHTS.filter((r) => rights.includes(r))
|
||||
.map((r) => t(`settings.tokens.rights.${r}`).toLocaleLowerCase(lang))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/** A folder's glyph (`folderIcon`), drawn at a row's size. */
|
||||
function Glyph({ icon: Icon }: { icon: ReturnType<typeof folderIcon> }) {
|
||||
return <Icon className="size-3.5 shrink-0" aria-hidden />;
|
||||
}
|
||||
|
||||
function GrantLine({ grant }: { grant: TokenGrant }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<Glyph icon={folderIcon(grant)} />
|
||||
<span className="text-fg">{folderLabel(grant, t)}</span>
|
||||
<span>· {rightsText(grant.rights, t, i18n.language)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenRow({
|
||||
token,
|
||||
index,
|
||||
rowProps,
|
||||
more,
|
||||
}: {
|
||||
token: ApiToken;
|
||||
index: number;
|
||||
rowProps: Record<string, unknown>;
|
||||
more: ReactNode;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const expires = formatDate(token.expires_at, i18n.language);
|
||||
return (
|
||||
<div
|
||||
{...navItemAttrs(index, token.name, { inset: true })}
|
||||
{...rowProps}
|
||||
data-testid="api-token-row"
|
||||
className="flex items-start gap-3 py-2.5"
|
||||
>
|
||||
<KeyRound className="mt-0.5 size-4 shrink-0 text-muted" aria-hidden />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium break-all">{token.name}</span>
|
||||
<code className="text-xs text-muted">{token.prefix}…</code>
|
||||
{token.expired ? (
|
||||
<Badge tone="warn">{t("settings.tokens.expired")}</Badge>
|
||||
) : (
|
||||
token.expiring && <Badge tone="warn">{t("settings.tokens.expiring")}</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
|
||||
{token.grants.map((g) => (
|
||||
<GrantLine key={g.folder_id} grant={g} />
|
||||
))}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{token.last_used_at ? (
|
||||
<>
|
||||
{t("settings.tokens.lastUsed")} <RelativeTime at={token.last_used_at} />
|
||||
</>
|
||||
) : (
|
||||
t("settings.tokens.neverUsed")
|
||||
)}
|
||||
{" · "}
|
||||
{token.expires_at === null
|
||||
? t("settings.tokens.neverExpires")
|
||||
: token.expired
|
||||
? t("settings.tokens.expiredOn", { date: expires })
|
||||
: t("settings.tokens.expiresOn", { date: expires })}
|
||||
</span>
|
||||
</div>
|
||||
{more}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApiTokens() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [making, setMaking] = useState(false);
|
||||
const tokensQ = useQuery({ queryKey: qk.apiTokens(), queryFn: api.apiTokens });
|
||||
const revoke = useMutation({
|
||||
mutationFn: (id: number) => api.revokeApiToken(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.apiTokens() }),
|
||||
meta: { failTitle: "errors.writeFailed.apiToken.revoke" },
|
||||
});
|
||||
const onRevoke = async (token: ApiToken) => {
|
||||
const ok = await confirm({
|
||||
title: t("settings.tokens.revokeTitle", { name: token.name }),
|
||||
message: t("settings.tokens.revokeBody"),
|
||||
confirmLabel: t("settings.tokens.revokeAction"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) revoke.mutate(token.id);
|
||||
};
|
||||
const menu = useContextMenu<ApiToken>({ keyOf: (tk) => tk.id });
|
||||
const items = (token: ApiToken): ActionMenuItem[] => [
|
||||
{
|
||||
id: "revoke",
|
||||
label: t("settings.tokens.revoke"),
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
danger: true,
|
||||
onSelect: () => void onRevoke(token),
|
||||
},
|
||||
];
|
||||
const tokens = tokensQ.data;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<p className="flex-1 text-xs leading-relaxed text-muted">{t("settings.tokens.intro")}</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => setMaking(true)}
|
||||
>
|
||||
{t("settings.tokens.new")}
|
||||
</Button>
|
||||
</div>
|
||||
{tokensQ.isError && !tokens ? (
|
||||
<StateMessage tone="error" onRetry={() => void tokensQ.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : !tokens ? (
|
||||
<LoadingState shape="list" />
|
||||
) : tokens.length === 0 ? (
|
||||
<p className="py-4 text-sm text-muted">{t("settings.tokens.empty")}</p>
|
||||
) : (
|
||||
<NavGrid className="flex flex-col divide-y divide-border/60">
|
||||
{tokens.map((token, i) => (
|
||||
<TokenRow
|
||||
key={token.id}
|
||||
token={token}
|
||||
index={i}
|
||||
rowProps={menu.triggerProps(token)}
|
||||
more={
|
||||
<MoreButton
|
||||
label={t("settings.tokens.rowMenu")}
|
||||
expanded={menu.open && menu.target?.id === token.id}
|
||||
menuProps={menu.buttonProps(token)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
)}
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel={t("settings.tokens.rowMenu")}
|
||||
items={menu.target ? items(menu.target) : []}
|
||||
/>
|
||||
{making && <NewTokenDialog onClose={() => setMaking(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- making one -----------------------------------------------------------------------------------
|
||||
|
||||
type Kind = "hub" | "custom";
|
||||
type Grants = Record<number, readonly TokenRight[]>;
|
||||
|
||||
/** The folders a custom token may be given, as the Files rail shows them: the sources that hold
|
||||
* something, then the Hub folder, then the user's own. A right a folder cannot take (writing into a
|
||||
* site's folder) is not offered. */
|
||||
function grantable(folders: readonly FileFolder[], t: TFunction, lang: string): FileFolder[] {
|
||||
const byName = (a: FileFolder, b: FileFolder) =>
|
||||
compareText(folderLabel(a, t), folderLabel(b, t), lang);
|
||||
return [
|
||||
...folders.filter((f) => f.kind === "source" && f.count > 0).sort(byName),
|
||||
...folders.filter((f) => f.kind === "hub"),
|
||||
...folders.filter((f) => f.kind === "own").sort(byName),
|
||||
];
|
||||
}
|
||||
|
||||
function RightsGrid({
|
||||
foldersQ,
|
||||
grants,
|
||||
onChange,
|
||||
}: {
|
||||
foldersQ: UseQueryResult<FileFolder[]>;
|
||||
grants: Grants;
|
||||
onChange: (g: Grants) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
if (foldersQ.isError && !foldersQ.data)
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => void foldersQ.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
if (!foldersQ.data) return <LoadingState />;
|
||||
const rows = grantable(foldersQ.data, t, i18n.language);
|
||||
if (rows.length === 0)
|
||||
return <p className="text-sm text-muted">{t("settings.tokens.noFolders")}</p>;
|
||||
const toggle = (folder: FileFolder, right: TokenRight) => {
|
||||
const had = grants[folder.id] ?? [];
|
||||
const next = had.includes(right) ? had.filter((r) => r !== right) : [...had, right];
|
||||
onChange({ ...grants, [folder.id]: next });
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col divide-y divide-border/50 rounded-lg border border-border/60">
|
||||
{rows.map((f) => {
|
||||
const label = folderLabel(f, t);
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className="flex flex-wrap items-center gap-2 px-2.5 py-2"
|
||||
>
|
||||
<span className="flex min-w-28 flex-1 items-center gap-1.5 text-sm">
|
||||
<Glyph icon={folderIcon(f)} />
|
||||
<span className="break-all">{label}</span>
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{TOKEN_RIGHTS.map((r) =>
|
||||
r === "write" && !takesUploads(f) ? null : (
|
||||
<Chip
|
||||
key={r}
|
||||
pressed={(grants[f.id] ?? []).includes(r)}
|
||||
onClick={() => toggle(f, r)}
|
||||
>
|
||||
{t(`settings.tokens.rights.${r}`)}
|
||||
</Chip>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KindTile({
|
||||
on,
|
||||
icon,
|
||||
title,
|
||||
text,
|
||||
onPick,
|
||||
}: {
|
||||
on: boolean;
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
text: string;
|
||||
onPick: () => void;
|
||||
}) {
|
||||
const textId = useId();
|
||||
return (
|
||||
<Pressable
|
||||
aria-pressed={on}
|
||||
aria-describedby={textId}
|
||||
onClick={onPick}
|
||||
className={`${SURFACE.card} flex flex-col items-start gap-1 border p-3 text-left ${
|
||||
on ? "border-accent" : "border-transparent hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
{icon}
|
||||
{title}
|
||||
</span>
|
||||
{/* Read after the name, not into it ("Custom" — then what it means). */}
|
||||
<span id={textId} aria-hidden className="text-xs text-muted">
|
||||
{text}
|
||||
</span>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const HubIcon = folderIcon({ kind: "hub", source_key: null, name: null });
|
||||
|
||||
const expiryValue = (days: number | null) => (days === null ? "never" : String(days));
|
||||
|
||||
function NewTokenDialog({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const nameId = useId();
|
||||
const [kind, setKind] = useState<Kind>("hub");
|
||||
// The name as typed; untouched, the Hub exchange is named for what it is and a custom one is blank.
|
||||
const [nameDraft, setNameDraft] = useState<string | null>(null);
|
||||
const name = nameDraft ?? (kind === "hub" ? t("settings.tokens.hubName") : "");
|
||||
const [grants, setGrants] = useState<Grants>({});
|
||||
const [expiry, setExpiry] = useState<number | null>(TOKEN_EXPIRY_DEFAULT);
|
||||
// Read as the dialog opens, not when Custom is picked: the grid is there by the time it is wanted.
|
||||
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
|
||||
const picked = Object.entries(grants)
|
||||
.filter(([, rights]) => rights.length > 0)
|
||||
.map(([id, rights]) => ({ folder_id: Number(id), rights: [...rights] }));
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
api.createApiToken(
|
||||
kind === "hub"
|
||||
? { name: name.trim(), expires_days: expiry, preset: "hub" }
|
||||
: { name: name.trim(), expires_days: expiry, grants: picked },
|
||||
),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.apiTokens() });
|
||||
// The Hub exchange makes the Hub folder the first time.
|
||||
void qc.invalidateQueries({ queryKey: qk.fileFolders() });
|
||||
},
|
||||
meta: { failTitle: "errors.writeFailed.apiToken.create" },
|
||||
});
|
||||
const made = create.data;
|
||||
const ready = name.trim() !== "" && (kind === "hub" || picked.length > 0);
|
||||
|
||||
if (made)
|
||||
return (
|
||||
<Modal
|
||||
initialFocus="action"
|
||||
title={t("settings.tokens.madeTitle")}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<InlineNotice>{t("settings.tokens.once")}</InlineNotice>
|
||||
<CopyField label={t("settings.tokens.token")} value={made.token} autoFocus />
|
||||
<CopyField
|
||||
label={t("settings.tokens.tryIt")}
|
||||
value={`curl -H "Authorization: Bearer ${made.token}" ${window.location.origin}/api/files/folders`}
|
||||
/>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t("settings.tokens.done")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
initialFocus="field"
|
||||
title={t("settings.tokens.newTitle")}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<form
|
||||
id={`${nameId}-form`}
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (ready && !create.isPending) create.mutate();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("settings.tokens.kind")}
|
||||
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
|
||||
>
|
||||
<KindTile
|
||||
on={kind === "hub"}
|
||||
icon={<HubIcon className="size-4" aria-hidden />}
|
||||
title={t("settings.tokens.hubTitle")}
|
||||
text={t("settings.tokens.hubText")}
|
||||
onPick={() => setKind("hub")}
|
||||
/>
|
||||
<KindTile
|
||||
on={kind === "custom"}
|
||||
icon={<SlidersHorizontal className="size-4" aria-hidden />}
|
||||
title={t("settings.tokens.customTitle")}
|
||||
text={t("settings.tokens.customText")}
|
||||
onPick={() => setKind("custom")}
|
||||
/>
|
||||
</div>
|
||||
<label htmlFor={nameId} className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted">{t("settings.tokens.name")}</span>
|
||||
<Input
|
||||
id={nameId}
|
||||
data-initial-focus=""
|
||||
className="w-full"
|
||||
maxLength={80}
|
||||
value={name}
|
||||
onChange={(e) => setNameDraft(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{kind === "custom" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted">{t("settings.tokens.folders")}</span>
|
||||
<RightsGrid foldersQ={foldersQ} grants={grants} onChange={setGrants} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted" aria-hidden>
|
||||
{t("settings.tokens.expiry")}
|
||||
</span>
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("settings.tokens.expiry")}
|
||||
className="self-start"
|
||||
options={TOKEN_EXPIRY_DAYS.map((d) => ({
|
||||
value: expiryValue(d),
|
||||
label: t(`settings.tokens.expiryOption.${expiryValue(d)}`),
|
||||
}))}
|
||||
value={expiryValue(expiry)}
|
||||
onChange={(v) => setExpiry(v === "never" ? null : Number(v))}
|
||||
/>
|
||||
{expiry === null && <InlineNotice>{t("settings.tokens.neverWarning")}</InlineNotice>}
|
||||
</div>
|
||||
</form>
|
||||
<ModalFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
form={`${nameId}-form`}
|
||||
disabled={!ready || create.isPending}
|
||||
>
|
||||
{t("settings.tokens.create")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** A value to copy, shown whole (it wraps), with its Copy button. */
|
||||
function CopyField({
|
||||
label,
|
||||
value,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
if (await copyText(value)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
return;
|
||||
}
|
||||
// The value is on screen and selectable: a failed copy is a convenience that failed.
|
||||
notify({ level: "warning", message: t("common.copy.failed") });
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted">{label}</span>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<code className="min-w-0 flex-1 break-all rounded-lg border border-border bg-card px-2.5 py-1.5 font-mono text-xs">
|
||||
{value}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
autoFocus={autoFocus}
|
||||
icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
{copied ? t("settings.tokens.copied") : t("settings.tokens.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -155,6 +155,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
|
||||
});
|
||||
const YT = folder(1, { source_key: "youtube" });
|
||||
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
|
||||
const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 });
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -377,6 +378,18 @@ describe("Files · my file's row", () => {
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 1 }));
|
||||
});
|
||||
|
||||
it("moves into the Hub folder too, the one its hub picks files up from", async () => {
|
||||
mocks.files = [file(2, { folder_id: 1 })];
|
||||
mocks.folders = [YT, HUB, OWN];
|
||||
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
|
||||
await waitFor(() => expect(menuRows()).toContain("files.folder.hub"));
|
||||
fireEvent.click(menuItem("files.folder.hub"));
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 4 }));
|
||||
});
|
||||
|
||||
it("offers no move home while it is home, and says so when there is nowhere to go", async () => {
|
||||
mocks.files = [file(2, { folder_id: 1 })];
|
||||
mocks.folders = [YT];
|
||||
@@ -1043,6 +1056,14 @@ describe("Files · dropping a file on the page", () => {
|
||||
expect(await screen.findByText("recept.pdf")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lands in the Hub folder when dropped there", () => {
|
||||
mocks.files = [];
|
||||
mocks.folders = [YT, HUB];
|
||||
mount({ kind: "folder", id: 4 });
|
||||
drag("dragenter");
|
||||
expect(screen.getByText("files.drop.here:files.folder.hub")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is no target where nothing can be uploaded — but the browser never gets the file", () => {
|
||||
mocks.files = [];
|
||||
mocks.folders = [YT];
|
||||
|
||||
@@ -26,7 +26,7 @@ import { MetaEditModal } from "./downloadModals";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { FileCard } from "./FileCard";
|
||||
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
|
||||
import { folderLabel } from "./fileFolders";
|
||||
import { folderLabel, takesUploads } from "./fileFolders";
|
||||
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
|
||||
import { useFileActions } from "./useFileActions";
|
||||
import { useFolderActions } from "./useFolderActions";
|
||||
@@ -109,12 +109,12 @@ function matches(file: LibraryFile, q: string, lang: string): boolean {
|
||||
return hay.toLocaleLowerCase(lang).includes(q.toLocaleLowerCase(lang));
|
||||
}
|
||||
|
||||
/** Where an upload from this view lands: the folder itself when it may take one (an own folder, the
|
||||
* Uploads folder — `folders.upload_target`), else the Uploads folder. */
|
||||
/** Where an upload from this view lands: the folder itself when it may take one (`takesUploads`),
|
||||
* else the Uploads folder. */
|
||||
function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number | null {
|
||||
if (view.kind !== "folder") return null;
|
||||
const f = folders.find((x) => x.id === view.id);
|
||||
return f && (f.kind === "own" || f.source_key === "upload") ? f.id : null;
|
||||
return f && takesUploads(f) ? f.id : null;
|
||||
}
|
||||
|
||||
/** The files the viewer pages through: the list's, and the open one even when the list moved on
|
||||
|
||||
@@ -136,6 +136,19 @@ describe("FilesRail · folders", () => {
|
||||
await waitFor(() => expect(menuRows()).toEqual(["files.folder.retention"]));
|
||||
});
|
||||
|
||||
it("the Hub folder closes the sources, shown while empty, its menu only its retention", async () => {
|
||||
const hub = folder(4, { kind: "hub", count: 0, retention_days: null });
|
||||
await mount([hub, folder(3, { source_key: "site:tiktok.com" }), YT, OWN]);
|
||||
const sources = entry("files.source.youtube").parentElement!;
|
||||
const names = [...sources.querySelectorAll("button")].map((b) => b.textContent);
|
||||
// Last, though its name sorts first: the sources by name, then the Hub folder.
|
||||
expect(names).toEqual(["files.source.youtube1", "tiktok.com1", "files.folder.hub0"]);
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("files.folder.hub"));
|
||||
});
|
||||
await waitFor(() => expect(menuRows()).toEqual(["files.folder.retention"]));
|
||||
});
|
||||
|
||||
it("renames in the entry, and Enter hands the keyboard back to it", async () => {
|
||||
await mount();
|
||||
const update = vi.spyOn(api, "updateFolder").mockResolvedValue(OWN);
|
||||
|
||||
@@ -124,6 +124,9 @@ export default function FilesRail({
|
||||
compareText(folderLabel(a, t), folderLabel(b, t), i18n.language);
|
||||
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
|
||||
const own = folders.filter((f) => f.kind === "own").sort(byName);
|
||||
// The Hub folder closes the sources, shown even while empty: it exists only once a hub token made
|
||||
// it, and an empty one is where the hub's files will turn up.
|
||||
const hub = folders.filter((f) => f.kind === "hub");
|
||||
|
||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
@@ -202,8 +205,9 @@ export default function FilesRail({
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{failed}
|
||||
{sources.map(entry)}
|
||||
{hub.map(entry)}
|
||||
{/* Only when there is nothing at all: files moved into own folders were still downloaded. */}
|
||||
{foldersQ.data && sources.length === 0 && total === 0 && (
|
||||
{foldersQ.data && sources.length + hub.length === 0 && total === 0 && (
|
||||
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noSources")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ const h = vi.hoisted(() => ({
|
||||
client: [] as unknown[],
|
||||
setPage: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
focusSettingsTab: vi.fn(),
|
||||
setFilters: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
clearAll: vi.fn(),
|
||||
@@ -44,6 +45,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
remove: (id: number) => h.remove(id),
|
||||
clearAll: () => h.clearAll(),
|
||||
}));
|
||||
vi.mock("../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab }));
|
||||
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) }));
|
||||
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
|
||||
vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) }));
|
||||
@@ -83,7 +85,15 @@ import PageShell from "./ui/PageShell";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
for (const f of [h.setPage, h.openFile, h.setFilters, h.remove, h.clearAll, h.confirm])
|
||||
for (const f of [
|
||||
h.setPage,
|
||||
h.openFile,
|
||||
h.focusSettingsTab,
|
||||
h.setFilters,
|
||||
h.remove,
|
||||
h.clearAll,
|
||||
h.confirm,
|
||||
])
|
||||
f.mockClear();
|
||||
});
|
||||
|
||||
@@ -171,6 +181,26 @@ describe("NotificationsPanel · a server row", () => {
|
||||
expect(h.openFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a token about to run out is named in the reader's words and opens Settings on its tab", async () => {
|
||||
h.server = [
|
||||
server(1, {
|
||||
type: "api_token_expiring",
|
||||
title: "API token Phone expires soon",
|
||||
data: { token_id: 4, name: "Phone", expires_at: "2026-10-21T10:00:00+00:00" },
|
||||
}),
|
||||
];
|
||||
h.client = [];
|
||||
mount();
|
||||
const row = rowOf("inbox.apiTokenExpiring.title");
|
||||
expect(row.textContent).toContain("inbox.apiTokenExpiring.body");
|
||||
expect(row.textContent).not.toContain("API token Phone expires soon");
|
||||
fireEvent.click(within(row).getByText("inbox.openTokens"));
|
||||
expect(h.focusSettingsTab).toHaveBeenCalledWith("tokens");
|
||||
expect(h.setPage).toHaveBeenCalledWith("settings");
|
||||
await openMenu(() => fireEvent.click(moreOf("inbox.apiTokenExpiring.title")));
|
||||
expect(menuRows()).toEqual(["inbox.openTokens", "inbox.markRead", "inbox.dismiss"]);
|
||||
});
|
||||
|
||||
it("holds the link, mark read and dismiss", async () => {
|
||||
h.server = [server(1)];
|
||||
h.client = [];
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
@@ -23,6 +24,7 @@ import { jobRunText } from "../lib/jobResult";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
||||
import { focusSettingsTab } from "../lib/settingsTab";
|
||||
import {
|
||||
clearAll as clearClient,
|
||||
dismiss as dismissClient,
|
||||
@@ -37,7 +39,7 @@ import {
|
||||
type VideoHiddenMeta,
|
||||
type VideoWatchedMeta,
|
||||
} from "../lib/notifications";
|
||||
import { channelYouTubeUrl } from "../lib/format";
|
||||
import { channelYouTubeUrl, formatDate } from "../lib/format";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { LEVEL_STYLE } from "./ui/toastLevels";
|
||||
@@ -186,6 +188,10 @@ export default function NotificationsPanel() {
|
||||
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
|
||||
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
|
||||
const openScheduler = () => setPage("scheduler");
|
||||
const openTokens = () => {
|
||||
focusSettingsTab("tokens");
|
||||
setPage("settings");
|
||||
};
|
||||
// A finished download opens on its file, in its folder; a notice from before it named one, on Files.
|
||||
const openDone = (n: AppNotification) => {
|
||||
const { job_id: id, folder_id: folder } = n.data ?? {};
|
||||
@@ -225,7 +231,16 @@ export default function NotificationsPanel() {
|
||||
onSelect: () => openDone(n),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
: kind === "token"
|
||||
? [
|
||||
{
|
||||
id: "open",
|
||||
label: t("inbox.openTokens"),
|
||||
icon: icon(KeyRound),
|
||||
onSelect: openTokens,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!n.read
|
||||
? [
|
||||
{
|
||||
@@ -397,6 +412,7 @@ export default function NotificationsPanel() {
|
||||
onDismiss={() => dismissMut.mutate(n.id)}
|
||||
onOpenScheduler={openScheduler}
|
||||
onOpenFile={() => openDone(n)}
|
||||
onOpenTokens={openTokens}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -462,9 +478,10 @@ const DONE_TITLE: Record<string, string | undefined> = {
|
||||
upload: "inbox.uploadDone.title",
|
||||
};
|
||||
|
||||
function serverKind(n: AppNotification): "scheduler" | "download" | null {
|
||||
function serverKind(n: AppNotification): "scheduler" | "download" | "token" | null {
|
||||
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
|
||||
if (n.type === "download_done") return "download";
|
||||
if (n.type === "api_token_expiring" && typeof n.data?.name === "string") return "token";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -511,6 +528,7 @@ function NotificationRow({
|
||||
onDismiss,
|
||||
onOpenScheduler,
|
||||
onOpenFile,
|
||||
onOpenTokens,
|
||||
}: {
|
||||
n: AppNotification;
|
||||
t: TFunction;
|
||||
@@ -521,6 +539,7 @@ function NotificationRow({
|
||||
onDismiss: () => void;
|
||||
onOpenScheduler: () => void;
|
||||
onOpenFile: () => void;
|
||||
onOpenTokens: () => void;
|
||||
}) {
|
||||
const lang = useTranslation().i18n.language;
|
||||
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
|
||||
@@ -532,6 +551,7 @@ function NotificationRow({
|
||||
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
|
||||
const isScheduler = serverKind(n) === "scheduler";
|
||||
const isDownloadDone = serverKind(n) === "download";
|
||||
const isTokenExpiring = serverKind(n) === "token";
|
||||
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
||||
// A download's life after it is done: shared with you, about to expire, expired, evicted.
|
||||
const lifecycle = DOWNLOAD_LIFECYCLE[n.type];
|
||||
@@ -566,6 +586,11 @@ function NotificationRow({
|
||||
const d = n.data as { title?: string | null; from?: string };
|
||||
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
|
||||
body = d.title || null;
|
||||
} else if (isTokenExpiring) {
|
||||
// A machine's key to the library runs out within a week: which one, and when.
|
||||
const d = n.data as { name: string; expires_at?: string | null };
|
||||
title = t("inbox.apiTokenExpiring.title");
|
||||
body = t("inbox.apiTokenExpiring.body", { name: d.name, date: formatDate(d.expires_at, lang) });
|
||||
} else if (isDownloadDone) {
|
||||
// "Download ready" / "Edit ready" / "Upload ready" per the producer's kind, with the video's title
|
||||
// as the body (shipped in data.title). Same notification type; the copy differs so an edit or an
|
||||
@@ -614,6 +639,17 @@ function NotificationRow({
|
||||
{t("inbox.openScheduler")}
|
||||
</Button>
|
||||
)}
|
||||
{isTokenExpiring && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
data-nav-open=""
|
||||
onClick={onOpenTokens}
|
||||
icon={<KeyRound className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t("inbox.openTokens")}
|
||||
</Button>
|
||||
)}
|
||||
{isDownloadDone && (
|
||||
<Button
|
||||
variant="link"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Bell,
|
||||
Check,
|
||||
Download,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { DownloadFormats } from "./ProfileEditor";
|
||||
import DiagnosticsSettings from "./DiagnosticsSettings";
|
||||
import ApiTokens from "./ApiTokens";
|
||||
import { type SettingsTabId } from "../lib/settingsTab";
|
||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||
import { api, HttpError, type Me } from "../lib/api";
|
||||
import { displayName, roleKey } from "../lib/api/account";
|
||||
@@ -61,11 +64,12 @@ export interface PrefsController {
|
||||
saveState: PrefsSaveState;
|
||||
}
|
||||
|
||||
type TabId = "appearance" | "notifications" | "downloads" | "account" | "diagnostics";
|
||||
type TabId = SettingsTabId;
|
||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||
{ id: "appearance", icon: Monitor },
|
||||
{ id: "notifications", icon: Bell },
|
||||
{ id: "downloads", icon: Download },
|
||||
{ id: "tokens", icon: KeyRound },
|
||||
{ id: "account", icon: User },
|
||||
{ id: "diagnostics", icon: Activity },
|
||||
];
|
||||
@@ -114,6 +118,7 @@ export default function SettingsPanel() {
|
||||
{tab === "appearance" && <Appearance prefs={prefs} />}
|
||||
{tab === "notifications" && <Notifications prefs={prefs} />}
|
||||
{tab === "downloads" && <Downloads />}
|
||||
{tab === "tokens" && <ApiTokens />}
|
||||
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
||||
{tab === "diagnostics" && <DiagnosticsSettings />}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Film, Folder, Globe, Upload, type LucideIcon } from "lucide-react";
|
||||
import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
|
||||
import { YouTubeIcon } from "../lib/brandIcons";
|
||||
import type { FileFolder } from "../lib/api";
|
||||
|
||||
// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
|
||||
// (`youtube`, `plex`, `upload`, `site:<host>`, see backend downloads/folders.py) and leaves
|
||||
// its name to the client, in the reader's language; an own folder carries the name its user gave it.
|
||||
// its name to the client, in the reader's language; an own folder carries the name its user gave it;
|
||||
// the Hub folder (one per user, made by a hub token) is named by the client too.
|
||||
|
||||
const SITE_PREFIX = "site:";
|
||||
|
||||
@@ -24,10 +25,20 @@ export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
|
||||
return Globe;
|
||||
}
|
||||
|
||||
export function folderLabel(folder: FileFolder, t: T): string {
|
||||
type Folderish = Pick<FileFolder, "kind" | "source_key" | "name">;
|
||||
|
||||
export function folderLabel(folder: Folderish, t: T): string {
|
||||
if (folder.kind === "hub") return t("files.folder.hub");
|
||||
return folder.kind === "own" ? (folder.name ?? "") : sourceLabel(folder.source_key ?? "", t);
|
||||
}
|
||||
|
||||
export function folderIcon(folder: FileFolder): LucideIcon | typeof YouTubeIcon {
|
||||
export function folderIcon(folder: Folderish): LucideIcon | typeof YouTubeIcon {
|
||||
if (folder.kind === "hub") return Server;
|
||||
return folder.kind === "own" ? Folder : sourceIcon(folder.source_key ?? "");
|
||||
}
|
||||
|
||||
/** Whether an upload may land in `folder` (the backend's `folders.upload_target`): an own folder,
|
||||
* the Hub folder, or Uploads — a file put in a site's folder would claim an origin it does not have. */
|
||||
export function takesUploads(folder: Folderish): boolean {
|
||||
return folder.kind !== "source" || folder.source_key === "upload";
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ const files = readSources(__dirname, [
|
||||
|
||||
/** file → [how many plain Pressables, why they are not titles]. */
|
||||
const PLAIN: Record<string, [number, string]> = {
|
||||
"ApiTokens.tsx": [
|
||||
1,
|
||||
"a token kind's tile in the new-token dialog (an option: its text is its label)",
|
||||
],
|
||||
"DownloadTray.tsx": [
|
||||
2,
|
||||
"the folded pill (its summary is its label); a failure's reason, which opens its details",
|
||||
|
||||
@@ -186,7 +186,11 @@ export function useFileActions({
|
||||
},
|
||||
],
|
||||
};
|
||||
const own = folders.filter((f) => f.kind === "own");
|
||||
// The user's own folders, then the Hub folder: the ones a file may be moved into from anywhere.
|
||||
const own = [
|
||||
...folders.filter((f) => f.kind === "own"),
|
||||
...folders.filter((f) => f.kind === "hub"),
|
||||
];
|
||||
const home = folders.find((f) => f.kind === "source" && f.source_key === file.source_key);
|
||||
const to = (f: FileFolder) => () => update.mutate({ id: file.id, patch: { folder_id: f.id } });
|
||||
const items: ActionMenuItem[] = own.map((f) => ({
|
||||
|
||||
Reference in New Issue
Block a user