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
+12
View File
@@ -5,6 +5,7 @@ import { BatchB } from "./batchB";
import { BatchC } from "./batchC";
import { BatchD } from "./batchD";
import { FilesRound } from "./filesRound";
import { TokensRound } from "./tokensRound";
import { Chip } from "../components/ui/Chip";
import { Slider } from "../components/ui/NativeControls";
import { SURFACE } from "../components/ui/surfaces";
@@ -75,6 +76,9 @@ export default function DesignLab() {
/>
</label>
<nav className="flex flex-wrap gap-1 text-xs">
<a href="#open-tokens" className="rounded-md px-1.5 py-0.5 text-accent state-layer">
Tokens
</a>
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-accent state-layer">
Files
</a>
@@ -101,6 +105,14 @@ export default function DesignLab() {
Resize the window to tablet size to see them there too.
</p>
</header>
<header id="open-tokens" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · API tokens, a machine&apos;s key to Files</h2>
<p className="text-sm text-muted">
Settings → API tokens. Picked: A, A, A — the variants with the accent edge (built; the
expiry is a segmented control, DESIGN.md "A choice between a few values").
</p>
</header>
<TokensRound />
<header id="open-files" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Open · Files, the one library</h2>
<p className="text-sm text-muted">
+515
View File
@@ -0,0 +1,515 @@
import clsx from "clsx";
import type { ReactNode } from "react";
import {
AlertTriangle,
Check,
Copy,
Folder,
KeyRound,
MoreHorizontal,
Plus,
Server,
SlidersHorizontal,
X,
} from "lucide-react";
import { Decision, Sample, Variant } from "./labKit";
import { Badge } from "../components/ui/Badge";
import { Chip } from "../components/ui/Chip";
import { SURFACE } from "../components/ui/surfaces";
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
import { fieldClass } from "../components/ui/fieldStyles";
// The API tokens round: how a machine (the hub) gets a key to the Files library. Static markup on
// purpose (the lab's rule); the sample tokens are the owner's own kind (the hub, a phone script).
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
const noop = () => {};
type Right = "list" | "read" | "write" | "delete";
const RIGHT_LABEL: Record<Right, string> = {
list: "Lista",
read: "Olvasás",
write: "Írás",
delete: "Törlés",
};
type Token = {
name: string;
prefix: string;
grants: { folder: string; hub?: boolean; rights: Right[] }[];
used: string;
expires: string;
soon?: boolean;
};
const TOKENS: Token[] = [
{
name: "Hub csere",
prefix: "sfl_7Qm2xK9a",
grants: [{ folder: "Hub", hub: true, rights: ["list", "read", "write", "delete"] }],
used: "12 perce",
expires: "2027. okt. 14.",
},
{
name: "Telefon – receptek",
prefix: "sfl_Bw41LdZr",
grants: [
{ folder: "Receptek", rights: ["list", "read"] },
{ folder: "Cimboráknak", rights: ["list", "read", "write"] },
],
used: "soha",
expires: "6 nap múlva",
soon: true,
},
];
/** The Settings page frame the tab lives in: the tab rail on the left, the tab on the right. */
function SettingsFrame({ children }: { children: ReactNode }) {
return (
<div className={clsx(SURFACE.panel, "flex max-w-3xl overflow-hidden")}>
<div className="hidden w-36 shrink-0 flex-col gap-1 border-r border-border/60 p-2 text-sm sm:flex">
{["Megjelenés", "Értesítések", "Letöltések", "API-tokenek", "Fiók"].map((l) => (
<span
key={l}
className={clsx(
"rounded-lg px-2.5 py-1.5",
l === "API-tokenek" ? "bg-accent/15 text-accent font-medium" : "text-muted",
)}
>
{l}
</span>
))}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3 p-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<h3 className="text-sm font-semibold">API-tokenek</h3>
<p className="text-xs leading-relaxed text-muted">
Egy token egy gépnek ad kulcsot a Fájlok könyvtár kijelölt mappáihoz — semmi máshoz.
</p>
</div>
<span className={buttonClass({ variant: "primary", size: "sm" })}>
<Plus className="size-4" />
Új token
</span>
</div>
{children}
</div>
</div>
);
}
function RightsLine({ grants }: { grants: Token["grants"] }) {
return (
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
{grants.map((g) => (
<span key={g.folder} className="flex items-center gap-1.5">
{g.hub ? <Server className="size-3.5" /> : <Folder className="size-3.5" />}
<span className="text-fg">{g.folder}</span>
<span>· {g.rights.map((r) => RIGHT_LABEL[r].toLowerCase()).join(", ")}</span>
</span>
))}
</span>
);
}
// ── T1 · the list ─────────────────────────────────────────────────────────────────────────────
function ListRows() {
return (
<div className="flex flex-col divide-y divide-border/60">
{TOKENS.map((tk) => (
<div key={tk.prefix} className="flex items-start gap-3 py-2.5">
<KeyRound className="mt-0.5 size-4 shrink-0 text-muted" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{tk.name}</span>
<code className="text-xs text-muted">{tk.prefix}…</code>
{tk.soon && <Badge tone="warn">Hamarosan lejár</Badge>}
</span>
<span className="text-xs text-muted">
<RightsLine grants={tk.grants} />
</span>
<span className="text-xs text-muted">
Utoljára használva: {tk.used} · Lejár: {tk.expires}
</span>
</div>
<span className={ICON} aria-label="Műveletek">
<MoreHorizontal className="size-4" />
</span>
</div>
))}
</div>
);
}
function ListTable() {
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="text-muted">
<tr className="border-b border-border/60">
<th className="py-1.5 pr-3 font-medium">Név</th>
<th className="py-1.5 pr-3 font-medium">Mappák</th>
<th className="py-1.5 pr-3 font-medium">Használva</th>
<th className="py-1.5 pr-3 font-medium">Lejár</th>
<th />
</tr>
</thead>
<tbody>
{TOKENS.map((tk) => (
<tr key={tk.prefix} className="border-b border-border/40 align-top">
<td className="py-2 pr-3">
<div className="text-sm font-medium">{tk.name}</div>
<code className="text-muted">{tk.prefix}…</code>
</td>
<td className="py-2 pr-3 text-muted">{tk.grants.map((g) => g.folder).join(", ")}</td>
<td className="py-2 pr-3 text-muted whitespace-nowrap">{tk.used}</td>
<td
className={clsx(
"py-2 pr-3 whitespace-nowrap",
tk.soon ? "text-warn" : "text-muted",
)}
>
{tk.expires}
</td>
<td className="py-1">
<span className={ICON} aria-label="Műveletek">
<MoreHorizontal className="size-4" />
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── T2 · the new-token dialog ───────────────────────────────────────────────────────────────────
function Dialog({
title,
children,
foot,
}: {
title: string;
children: ReactNode;
foot: ReactNode;
}) {
return (
<div className={clsx(SURFACE.panel, "flex w-full max-w-md flex-col shadow-float")}>
<div className="flex items-center gap-2 border-b border-border/60 px-4 py-3">
<h4 className="flex-1 text-sm font-semibold">{title}</h4>
<span className={ICON} aria-label="Bezárás">
<X className="size-4" />
</span>
</div>
<div className="flex flex-col gap-3 p-4 text-sm">{children}</div>
<div className="flex justify-end gap-2 border-t border-border/60 px-4 py-3">{foot}</div>
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">{label}</span>
{children}
</label>
);
}
function Expiry({ never = false }: { never?: boolean }) {
return (
<Field label="Lejárat">
<span className="flex flex-wrap gap-1">
{["30 nap", "90 nap", "1 év", "Soha"].map((l) => (
<Chip key={l} pressed={never ? l === "Soha" : l === "1 év"} onClick={noop}>
{l}
</Chip>
))}
</span>
{never && (
<span className="flex items-start gap-1.5 text-xs text-warn">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
Egy soha le nem járó token addig nyitja a mappát, amíg vissza nem vonod.
</span>
)}
</Field>
);
}
const FOLDERS: { name: string; kind: "hub" | "own" | "source"; on: Right[] }[] = [
{ name: "Hub", kind: "hub", on: [] },
{ name: "Receptek", kind: "own", on: ["list", "read"] },
{ name: "Cimboráknak", kind: "own", on: ["list", "read", "write"] },
{ name: "YouTube", kind: "source", on: [] },
];
function RightsMatrix() {
return (
<div className="flex flex-col divide-y divide-border/50 rounded-lg border border-border/60">
{FOLDERS.map((f) => (
<div key={f.name} className="flex flex-wrap items-center gap-2 px-2.5 py-2">
<span className="flex min-w-28 flex-1 items-center gap-1.5">
{f.kind === "hub" ? <Server className="size-3.5" /> : <Folder className="size-3.5" />}
{f.name}
</span>
<span className="flex gap-1">
{(Object.keys(RIGHT_LABEL) as Right[]).map((r) =>
// A source folder fills itself: nothing can be uploaded into it, so no Write.
f.kind === "source" && r === "write" ? null : (
<Chip key={r} pressed={f.on.includes(r)} onClick={noop}>
{RIGHT_LABEL[r]}
</Chip>
),
)}
</span>
</div>
))}
</div>
);
}
function PresetFirst({ custom }: { custom: boolean }) {
return (
<Dialog
title="Új API-token"
foot={
<>
<span className={buttonClass({ variant: "outline", size: "sm" })}>Mégse</span>
<span className={buttonClass({ variant: "primary", size: "sm" })}>Létrehozás</span>
</>
}
>
<div className="grid grid-cols-2 gap-2">
{[
{
on: !custom,
icon: Server,
title: "Hub csere",
text: "Egy közös Hub mappa; a token mindent tehet benne.",
},
{
on: custom,
icon: SlidersHorizontal,
title: "Egyedi",
text: "Te választod a mappákat és a jogokat.",
},
].map((c) => (
<span
key={c.title}
className={clsx(
SURFACE.card,
"flex flex-col gap-1 border p-3",
c.on ? "border-accent" : "border-transparent",
)}
>
<span className="flex items-center gap-1.5 font-medium">
<c.icon className="size-4" />
{c.title}
</span>
<span className="text-xs text-muted">{c.text}</span>
</span>
))}
</div>
<Field label="Név">
<span className={clsx(fieldClass({ size: "sm" }), "flex items-center")}>
{custom ? "Telefon – receptek" : "Hub csere"}
</span>
</Field>
{custom && <RightsMatrix />}
<Expiry />
</Dialog>
);
}
function OneForm() {
return (
<Dialog
title="Új API-token"
foot={
<>
<span className={buttonClass({ variant: "outline", size: "sm" })}>Mégse</span>
<span className={buttonClass({ variant: "primary", size: "sm" })}>Létrehozás</span>
</>
}
>
<Field label="Név">
<span className={clsx(fieldClass({ size: "sm" }), "flex items-center")}>Hub csere</span>
</Field>
<span className="flex items-center gap-2 text-xs text-muted">
Mappák és jogok
<span className={buttonClass({ variant: "link", size: "none" })}>Hub csere kitöltése</span>
</span>
<RightsMatrix />
<Expiry never />
</Dialog>
);
}
// ── T3 · the token, shown once ──────────────────────────────────────────────────────────────────
const RAW = "sfl_7Qm2xK9aVt3pLc8RzN0eWq5uYb1HdJ6gFs4kMx2oTi9a3F7kQe";
function CopyRow({ children, mono = true }: { children: ReactNode; mono?: boolean }) {
return (
<span className="flex items-stretch gap-1.5">
<code
className={clsx(
fieldClass({ size: "sm" }),
"flex min-w-0 flex-1 items-center break-all h-auto py-1.5",
mono && "font-mono text-xs",
)}
>
{children}
</code>
<span className={buttonClass({ variant: "outline", size: "sm" })}>
<Copy className="size-4" />
Másolás
</span>
</span>
);
}
function ShownInDialog() {
return (
<Dialog
title="A token elkészült"
foot={<span className={buttonClass({ variant: "primary", size: "sm" })}>Kész</span>}
>
<span className="flex items-start gap-1.5 text-xs text-warn">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
Most látod utoljára: mi csak a lenyomatát őrizzük. Ha elveszik, csinálj újat.
</span>
<Field label="Token">
<CopyRow>{RAW}</CopyRow>
</Field>
<Field label="Kipróbálás (curl)">
<CopyRow>
curl -H &quot;Authorization: Bearer {RAW.slice(0, 12)}…&quot;
https://siftlode.example/api/files/folders
</CopyRow>
</Field>
</Dialog>
);
}
function ShownAsBanner() {
return (
<SettingsFrame>
<div className="flex flex-col gap-2 rounded-lg border border-accent/50 bg-accent/6 p-3">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Check className="size-4 text-success" />
„Hub csere” elkészült — másold ki most, később nem látod.
</span>
<CopyRow>{RAW}</CopyRow>
<span className={clsx(buttonClass({ variant: "link", size: "none" }), "self-start")}>
curl példa másolása
</span>
</div>
<ListRows />
</SettingsFrame>
);
}
// ── The round ───────────────────────────────────────────────────────────────────────────────────
export function TokensRound() {
return (
<div className="flex flex-col gap-6">
<Decision
id="open-tokens-t1"
code="T1"
title="The token list (Settings → API tokens)"
question="How does the tab list the tokens: what each opens, when it was last used, when it runs out? Revoke is in each row's “…” menu, behind a confirm."
now="There is no token UI; tokens exist only through the API."
>
<div className="grid gap-4 xl:grid-cols-2">
<Variant
letter="A"
name="Rows, three lines each"
recommended
why="The Settings column is ~36rem: a row reads name + prefix, then every folder with its rights, then last use and expiry — nothing truncated, and the “soon expires” badge sits beside the name. Fits a phone as is."
>
<SettingsFrame>
<ListRows />
</SettingsFrame>
</Variant>
<Variant
letter="B"
name="A table"
why="Scans in columns like the Download formats table, but at this width the folders column truncates to names (rights drop out) and a phone needs the card fallback anyway."
>
<SettingsFrame>
<ListTable />
</SettingsFrame>
</Variant>
</div>
</Decision>
<Decision
id="open-tokens-t2"
code="T2"
title="Making a token"
question="How do you make one: the one-click Hub csere, or a token of your own folders and rights? Expiry: 30 days, 90 days, 1 year (default) or never (with a warning)."
now="Nothing in the app."
>
<div className="grid gap-4 xl:grid-cols-3">
<Variant
letter="A"
name="Pick the kind first — Hub csere"
recommended
why="The hub is the case you have: one tile, the name filled in, Create. The rights grid only appears for “Egyedi”, so the common token never shows a matrix to get wrong."
>
<PresetFirst custom={false} />
</Variant>
<Variant
letter="A"
name="…the same, Egyedi"
recommended
why="Each folder a row, its rights as chips. A source folder has no Write: nothing can be uploaded into it (the server refuses it too)."
>
<PresetFirst custom />
</Variant>
<Variant
letter="B"
name="One form, a preset link"
why="Everything at once, with “Hub csere kitöltése” filling the grid. Fewer clicks for a custom token, but the hub case starts from a matrix. Shown with “never” picked, and its warning."
>
<OneForm />
</Variant>
</div>
</Decision>
<Decision
id="open-tokens-t3"
code="T3"
title="The token, shown once"
question="Only its fingerprint is stored, so the token can be copied once. Where does it show, with a ready curl line to try it?"
now="Nothing in the app."
>
<div className="grid gap-4 xl:grid-cols-2">
<Variant
letter="A"
name="The dialog's last step"
recommended
why="You are already looking at the dialog: the token, a Copy, a curl line to try it, and “Kész”. Closing is the moment it is gone, and it is plain that it is."
>
<Sample label="after Létrehozás">
<ShownInDialog />
</Sample>
</Variant>
<Variant
letter="B"
name="A banner above the list"
why="The dialog closes and the token waits above the list until you leave the tab. The new row is right under it, but a banner is easy to scroll past — and leaving the tab loses it for good."
>
<ShownAsBanner />
</Variant>
</div>
</Decision>
</div>
);
}