- The queue holds an upload it sees land done while this tab's upload still waits for its answer, so the dialog's notice is the only one whichever comes first; another device's upload follows after. - One `useCopyFeedback` for every Copy beside a value (token, share link, set-password link): Copied for a moment, a notice a screen reader hears, a warning when the clipboard refuses; the token dialog's two Copy buttons are named by what they copy. - The share dialog's link list shows loading, and a load error with Retry, never "no links" — and no New link beside a list it has not seen. - The settings texts say that signing out elsewhere or a password change revokes the API tokens. - DESIGN.md logs the folder-link decisions; the design lab keeps only the chosen renderings of the Links, Tokens and Files rounds.
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
import clsx from "clsx";
|
||
import type { ReactNode } from "react";
|
||
import {
|
||
AlertTriangle,
|
||
Copy,
|
||
Folder,
|
||
KeyRound,
|
||
MoreHorizontal,
|
||
Plus,
|
||
Server,
|
||
SlidersHorizontal,
|
||
X,
|
||
} from "lucide-react";
|
||
import { Reference, Sample } 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";
|
||
|
||
// API tokens, DECIDED (owner A/A/A): 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>
|
||
);
|
||
}
|
||
|
||
// ── 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() {
|
||
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={l === "1 év"} onClick={noop}>
|
||
{l}
|
||
</Chip>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
// ── 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>
|
||
);
|
||
}
|
||
|
||
// ── The round ───────────────────────────────────────────────────────────────────────────────────
|
||
|
||
export function TokensRound() {
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<Reference
|
||
id="tokens-t1"
|
||
code="T1"
|
||
title="The token list (Settings → API tokens)"
|
||
rule="A row per token in three lines: name + prefix (the “soon expires” badge beside the name), then every folder with its rights, then last use and expiry — nothing truncated at the ~36rem Settings column, and it fits a phone as is. Revoke is in the row's ⋯ menu, behind a confirm."
|
||
>
|
||
<SettingsFrame>
|
||
<ListRows />
|
||
</SettingsFrame>
|
||
</Reference>
|
||
|
||
<Reference
|
||
id="tokens-t2"
|
||
code="T2"
|
||
title="Making a token"
|
||
rule="The dialog asks the kind first. Hub csere: one tile, the name filled in, Create — the common token never shows a matrix to get wrong. Egyedi: 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). Expiry is a segmented control (DESIGN.md “A choice between a few values”): 30 days, 90 days, 1 year (the default) or never, which warns that the token opens the folder until revoked."
|
||
>
|
||
<div className="grid items-start gap-4 xl:grid-cols-2">
|
||
<Sample label="Hub csere">
|
||
<PresetFirst custom={false} />
|
||
</Sample>
|
||
<Sample label="Egyedi">
|
||
<PresetFirst custom />
|
||
</Sample>
|
||
</div>
|
||
</Reference>
|
||
|
||
<Reference
|
||
id="tokens-t3"
|
||
code="T3"
|
||
title="The token, shown once"
|
||
rule="Only its fingerprint is stored, so the token shows once, as the dialog's last step: the token with a Copy, a curl line to try it, and Kész. Closing the dialog is plainly the moment it is gone."
|
||
>
|
||
<Sample label="after Létrehozás">
|
||
<ShownInDialog />
|
||
</Sample>
|
||
</Reference>
|
||
</div>
|
||
);
|
||
}
|