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