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

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

The Hub folder closes the Sources in the Files rail, takes uploads and
moved-in files. A token about to run out warns its user once, a week
before (the Files GC's sixth pass); the notice opens the tokens tab.
This commit is contained in:
2026-10-14 23:33:34 +02:00
parent 4f58d15d3f
commit db56f25387
39 changed files with 1759 additions and 26 deletions
@@ -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];