Files
HealthLog/src/components/admin/invite-tokens-section.tsx
T
Marc-André Bombeck 5445bfc1f1 refactor(ui): give Settings and Admin one card rhythm and one page header
The two consoles used the same primitives and still read as two systems,
because everything without a primitive had drifted: the header-to-body
step existed in three competing techniques at once (`mt-4` on the first
body child, `space-y-4` on the card, `mb-4` on the header), the body
started either at the card edge or 28 px in, and both shells hand-rolled
the page title instead of calling `PageHeader`.

Move the decisions into the primitives so a call site cannot re-open them:

- `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The
  header-to-body step is the container's, not a margin somebody remembers.
  A dense card still overrides the gap on the card itself.
- The body starts at the card edge. `pl-7` is gone — off the spacing scale,
  and it mis-aligned action rows against their own forms wherever two
  neighbouring cards disagreed about it.
- The card header's description slot loses its `space-y-1`. That was the
  licence call sites used to stack explainer paragraphs into a muted
  `text-xs` slot; the slot holds one sentence.
- New `SettingsCardActions`: one action row, trailing edge, last thing in
  the card. `align="start"` spells the one exception, a card whose only
  actions are links out.
- New `StatTile` replaces four hand-rolled label/value boxes in Admin.
- `SectionHeading` moves from `insights/` to `ui/`. It is the heading above
  a group of cards, which is what Settings needs; Settings had been using a
  card header with no card, which reads as a card that failed to paint.

Both shells now render their heading through `PageHeader`, which gained
`headingAs="div"` for the second copy each shell paints (mobile above the
nav strip, desktop inside the grid) so only one is a real `<h1>`. The
page explainer is muted everywhere: the primitive said foreground, the
standards and every hand-rolled copy said muted, so pages disagreed with
each other. Medications, Vorsorge, the labs / custom-metric / illness
detail pages and the clinician view drop their hand-rolled title blocks
too, including the mid-word truncation on the medications subtitle.

Tests pin the parts that can be checked exactly: the card's gap, the
description slot's missing rhythm, the action row's two alignments, the
`<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
2026-08-07 19:05:15 +02:00

799 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* v1.16.0 — Admin → Invites. Full management surface for registration
* invite links (formerly a card inside the Users section).
*
* Layout:
* - Header row: explanatory copy + the single "create" action. TTL +
* max-uses selection moved into the create dialog so the section
* header stays calm (no 7/14/30 button wall).
* - The freshly minted link + QR render INSIDE the dialog as a
* one-time "ticket": only the HMAC hash is persisted, so the URL is
* copyable exactly now and never again. The UI says so honestly.
* - Desktop (md+): a real table — status, created, expiry
* (relative + absolute), uses, redemption history, revoke.
* - Mobile: stacked cards carrying the same facts.
*
* Status model (derived client-side from the row):
* revoked > exhausted > expired > active. Revocation is soft
* (DELETE sets `revokedAt`), so history rows never disappear.
*
* The QR data-URL is rendered inside the mutation (not in an effect):
* `qrcode`'s `toDataURL` is async, so folding it into `mutationFn`
* keeps the component free of setState-in-effect churn. The library
* itself loads via dynamic `import()` at that call site (v1.16.1) —
* it is only needed the moment an admin mints an invite, so it stays
* out of the admin route bundle entirely.
*/
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Ban,
Check,
Copy,
Loader2,
Plus,
ShieldCheck,
Ticket,
Users,
} from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
interface InviteRedemptionEntry {
id: string;
redeemedAt: string;
user: { id: string; username: string; email: string | null } | null;
}
interface AdminInvite {
id: string;
createdAt: string;
expiresAt: string;
usedAt: string | null;
revokedAt: string | null;
uses: number;
maxUses: number;
creator: { id: string; username: string } | null;
consumer: { id: string; username: string } | null;
redemptions: InviteRedemptionEntry[];
}
interface MintedInvite {
id: string;
token: string;
url: string;
expiresAt: string;
qrDataUrl: string;
}
type InviteStatus = "active" | "expired" | "exhausted" | "revoked";
export function deriveInviteStatus(
invite: Pick<AdminInvite, "uses" | "maxUses" | "expiresAt" | "revokedAt">,
now: Date,
): InviteStatus {
if (invite.revokedAt !== null) return "revoked";
if (invite.uses >= invite.maxUses) return "exhausted";
if (new Date(invite.expiresAt).getTime() <= now.getTime()) return "expired";
return "active";
}
const TTL_CHOICES = [7, 14, 30] as const;
const MAX_USES_CAP = 50;
/**
* Clamp the raw max-uses field to the mintable range (integer 1–50).
* Shared by the submit path and the blur echo so the admin always sees
* exactly what the POST will carry. Exported for unit tests.
*/
export function clampMaxUses(raw: string): number {
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed >= 1
? Math.min(parsed, MAX_USES_CAP)
: 1;
}
/** Whole days (ceil) from `now` until `iso`; negative when past. */
function daysUntil(iso: string, now: Date): number {
const ms = new Date(iso).getTime() - now.getTime();
return Math.ceil(ms / (24 * 60 * 60 * 1000));
}
const STATUS_STYLES: Record<InviteStatus, { dot: string; chip: string }> = {
// Quiet tinted chips with a colour dot — the dot carries the state at
// a glance, the tint stays low so a table of mixed states reads calm.
active: {
dot: "bg-success",
chip: "border-success/30 bg-success/10 text-success",
},
expired: {
dot: "bg-muted-foreground/60",
chip: "border-border bg-muted/40 text-muted-foreground",
},
exhausted: {
dot: "bg-info",
chip: "border-info/30 bg-info/10 text-info",
},
revoked: {
dot: "bg-destructive",
chip: "border-destructive/30 bg-destructive/10 text-destructive",
},
};
function StatusChip({
status,
label,
}: {
status: InviteStatus;
label: string;
}) {
const styles = STATUS_STYLES[status];
return (
<span
data-testid={`invite-status-${status}`}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium whitespace-nowrap",
styles.chip,
)}
>
<span
aria-hidden="true"
className={cn("size-1.5 rounded-full", styles.dot)}
/>
{label}
</span>
);
}
export function InviteTokensSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [ttlDays, setTtlDays] = useState<number>(7);
const [maxUses, setMaxUses] = useState<string>("1");
const [minted, setMinted] = useState<MintedInvite | null>(null);
const [copied, setCopied] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<AdminInvite | null>(null);
const { data: invites, isError } = useQuery({
queryKey: queryKeys.adminInvites(),
queryFn: async () => {
return apiGet<AdminInvite[]>("/api/admin/invites");
},
});
const create = useMutation({
mutationKey: queryKeys.adminInvites(),
mutationFn: async (input: {
expiresInDays: number;
maxUses: number;
}): Promise<MintedInvite> => {
const data = await apiPost<Omit<MintedInvite, "qrDataUrl">>(
"/api/admin/invites",
input,
);
// Lazy chunk: qrcode is only ever needed right here, at mint time.
const QRCode = (await import("qrcode")).default;
const qrDataUrl = await QRCode.toDataURL(data.url, {
width: 240,
margin: 1,
});
return { ...data, qrDataUrl };
},
onSuccess: (invite) => {
setMinted(invite);
toast.success(t("admin.invites.createdToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
},
onError: () => {
toast.error(t("admin.invites.createError"));
},
});
const revoke = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/admin/invites/${id}`);
},
onSuccess: () => {
toast.success(t("admin.invites.revokedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
},
onError: () => {
toast.error(t("admin.invites.revokeError"));
},
});
async function copyUrl(url: string) {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
toast.success(t("admin.invites.copiedToast"));
window.setTimeout(() => setCopied(false), 2_000);
} catch {
toast.error(t("admin.invites.copyError"));
}
}
function openCreateDialog() {
setMinted(null);
setCopied(false);
setTtlDays(7);
setMaxUses("1");
setCreateOpen(true);
}
function submitCreate() {
create.mutate({ expiresInDays: ttlDays, maxUses: clampMaxUses(maxUses) });
}
const now = new Date();
function statusLabel(status: InviteStatus): string {
// Literal keys so the i18n call-site coverage guard sees them.
switch (status) {
case "active":
return t("admin.invites.statusActive");
case "expired":
return t("admin.invites.statusExpired");
case "exhausted":
return t("admin.invites.statusExhausted");
case "revoked":
return t("admin.invites.statusRevoked");
}
}
function expiryRelative(invite: AdminInvite, status: InviteStatus): string {
if (status !== "active") return "—";
const days = daysUntil(invite.expiresAt, now);
if (days <= 0) return "—";
return days === 1
? t("admin.invites.expiresInDaysOne")
: t("admin.invites.expiresInDaysOther", { count: days });
}
function redemptionSummary(invite: AdminInvite): string {
if (invite.redemptions.length === 0) return "—";
const first = invite.redemptions[0];
const name = first.user?.username ?? t("admin.invites.redeemerDeleted");
if (invite.redemptions.length === 1) return name;
return t("admin.invites.redeemedByMore", {
username: name,
count: invite.redemptions.length - 1,
});
}
return (
<section
aria-labelledby="admin-invites-title"
className="bg-card border-border rounded-xl border"
data-testid="admin-invites-card"
>
{/* Header — copy left, the one action right. */}
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Ticket
className="text-muted-foreground h-5 w-5"
aria-hidden="true"
/>
<h2 id="admin-invites-title" className="text-lg font-semibold">
{t("admin.invites.title")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.invites.description")}
</p>
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 text-xs">
<ShieldCheck className="size-3.5 shrink-0" aria-hidden="true" />
{t("admin.invites.adminOnlyHint")}
</p>
</div>
<Button
type="button"
onClick={openCreateDialog}
data-testid="admin-invites-open-create"
className="shrink-0"
>
<Plus className="size-4" aria-hidden="true" />
{t("admin.invites.create")}
</Button>
</div>
{isError && (
<p className="text-destructive px-4 pb-4 text-sm sm:px-6">
{t("admin.invites.loadError")}
</p>
)}
{invites && invites.length === 0 && (
<div className="px-4 pb-4 sm:px-6 sm:pb-6">
<EmptyState
icon={<Ticket className="size-6" aria-hidden="true" />}
title={t("admin.invites.emptyTitle")}
description={t("admin.invites.emptyDescription")}
action={
<Button
type="button"
variant="outline"
onClick={openCreateDialog}
>
<Plus className="size-4" aria-hidden="true" />
{t("admin.invites.create")}
</Button>
}
/>
</div>
)}
{invites && invites.length > 0 && (
<>
{/* Desktop table (md+) */}
<div className="hidden px-2 pb-2 md:block">
<Table data-testid="admin-invites-table">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>{t("admin.invites.colStatus")}</TableHead>
<TableHead>{t("admin.invites.colCreated")}</TableHead>
<TableHead>{t("admin.invites.colExpires")}</TableHead>
<TableHead className="text-right">
{t("admin.invites.colUses")}
</TableHead>
<TableHead>{t("admin.invites.colRedeemedBy")}</TableHead>
<TableHead className="text-right">
<span className="sr-only">
{t("admin.invites.colActions")}
</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((invite) => {
const status = deriveInviteStatus(invite, now);
const relative = expiryRelative(invite, status);
return (
<TableRow
key={invite.id}
data-testid="admin-invite-row"
className={cn(
status !== "active" && "text-muted-foreground",
)}
>
<TableCell>
<StatusChip
status={status}
label={statusLabel(status)}
/>
</TableCell>
<TableCell>
<div className="leading-tight">
<div className="text-sm tabular-nums">
{formatDate(invite.createdAt)}
</div>
<div className="text-muted-foreground text-xs">
{t("admin.invites.byCreator", {
by: invite.creator?.username ?? "—",
})}
</div>
</div>
</TableCell>
<TableCell>
<div className="leading-tight">
<div className="text-sm tabular-nums">
{relative !== "—"
? relative
: formatDate(invite.expiresAt)}
</div>
{relative !== "—" && (
<div className="text-muted-foreground text-xs tabular-nums">
{formatDate(invite.expiresAt)}
</div>
)}
</div>
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{invite.uses}
<span className="text-muted-foreground">
{" "}
/ {invite.maxUses}
</span>
</TableCell>
<TableCell>
<RedemptionsCell
invite={invite}
summary={redemptionSummary(invite)}
/>
</TableCell>
<TableCell className="text-right">
{status === "active" && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
aria-label={t("admin.invites.revokeAria")}
disabled={revoke.isPending}
onClick={() => setRevokeTarget(invite)}
data-testid="admin-invite-revoke"
>
<Ban className="size-4" aria-hidden="true" />
{t("admin.invites.revoke")}
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* Mobile cards (<md) */}
<ul className="space-y-2 px-4 pb-4 md:hidden">
{invites.map((invite) => {
const status = deriveInviteStatus(invite, now);
const relative = expiryRelative(invite, status);
return (
<li
key={invite.id}
className="border-border rounded-lg border p-3"
data-testid="admin-invite-row"
>
<div className="flex items-center justify-between gap-2">
<StatusChip status={status} label={statusLabel(status)} />
<span className="text-sm tabular-nums">
{invite.uses}
<span className="text-muted-foreground">
{" "}
/ {invite.maxUses}
</span>
</span>
</div>
<dl className="text-muted-foreground mt-2 space-y-1 text-xs">
<div className="flex justify-between gap-2">
<dt>{t("admin.invites.colCreated")}</dt>
<dd className="text-right tabular-nums">
{formatDate(invite.createdAt)}
</dd>
</div>
<div className="flex justify-between gap-2">
<dt>{t("admin.invites.colExpires")}</dt>
<dd className="text-right tabular-nums">
{relative !== "—" ? `${relative} · ` : ""}
{formatDate(invite.expiresAt)}
</dd>
</div>
{invite.redemptions.length > 0 && (
<div className="flex justify-between gap-2">
<dt>{t("admin.invites.colRedeemedBy")}</dt>
<dd className="text-foreground text-right">
{redemptionSummary(invite)}
</dd>
</div>
)}
</dl>
{status === "active" && (
<div className="mt-2 flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={revoke.isPending}
onClick={() => setRevokeTarget(invite)}
>
<Ban className="size-4" aria-hidden="true" />
{t("admin.invites.revoke")}
</Button>
</div>
)}
</li>
);
})}
</ul>
</>
)}
{/* Create dialog — selection first, then the one-time ticket. */}
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) setMinted(null);
}}
>
<DialogContent className="sm:max-w-md">
{minted === null ? (
<>
<DialogHeader>
<DialogTitle>{t("admin.invites.createTitle")}</DialogTitle>
<DialogDescription>
{t("admin.invites.createDescription")}
</DialogDescription>
</DialogHeader>
{/* v1.16.4 — a real form so Enter in the max-uses field
submits; mirrors the intake-edit dialog convention. */}
<form
onSubmit={(e) => {
e.preventDefault();
if (create.isPending) return;
submitCreate();
}}
className="space-y-4"
>
<div className="space-y-4">
<fieldset>
<legend className="text-sm font-medium">
{t("admin.invites.ttlLegend")}
</legend>
<div
className="mt-2 grid grid-cols-3 gap-2"
role="radiogroup"
>
{TTL_CHOICES.map((days) => (
<button
key={days}
type="button"
role="radio"
aria-checked={ttlDays === days}
onClick={() => setTtlDays(days)}
data-testid={`admin-invites-ttl-${days}`}
className={cn(
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
ttlDays === days
? "border-primary/50 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
{t("admin.invites.createDays", { days })}
</button>
))}
</div>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="admin-invites-max-uses">
{t("admin.invites.maxUsesLabel")}
</Label>
<Input
id="admin-invites-max-uses"
type="number"
inputMode="numeric"
min={1}
max={MAX_USES_CAP}
step={1}
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
onBlur={() => {
// Echo the submit-time clamp (1–50, integer) into
// the field so the admin sees what will be minted.
setMaxUses(String(clampMaxUses(maxUses)));
}}
className="w-24 tabular-nums"
data-testid="admin-invites-max-uses"
/>
<p className="text-muted-foreground text-xs">
{t("admin.invites.maxUsesHint")}
</p>
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={create.isPending}
aria-busy={create.isPending || undefined}
data-testid="admin-invites-submit-create"
>
{create.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Ticket className="size-4" aria-hidden="true" />
)}
{t("admin.invites.createConfirm")}
</Button>
</DialogFooter>
</form>
</>
) : (
<>
<DialogHeader>
<DialogTitle>{t("admin.invites.tokenTitle")}</DialogTitle>
<DialogDescription>
{t("admin.invites.tokenOnceHint")}
</DialogDescription>
</DialogHeader>
{/* The ticket — dashed edge marks the one-time nature. */}
<div
className="border-primary/40 bg-background rounded-xl border border-dashed p-4"
data-testid="admin-invites-minted"
>
<div className="flex flex-col items-center gap-4">
{/* eslint-disable-next-line @next/next/no-img-element -- data-URL QR, no remote loader involved */}
<img
src={minted.qrDataUrl}
alt={t("admin.invites.qrAlt")}
width={176}
height={176}
className="rounded-lg bg-white p-2"
/>
<p className="bg-muted w-full overflow-x-auto rounded-md p-2 text-center font-mono text-xs break-all select-all">
{minted.url}
</p>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => copyUrl(minted.url)}
data-testid="admin-invites-copy"
>
{copied ? (
<Check
className="text-success size-4"
aria-hidden="true"
/>
) : (
<Copy className="size-4" aria-hidden="true" />
)}
{t("admin.invites.copyLink")}
</Button>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("admin.invites.hashExplainer")}
</p>
</>
)}
</DialogContent>
</Dialog>
{/* Revoke confirmation */}
<AlertDialog
open={revokeTarget !== null}
onOpenChange={(open) => {
if (!open) setRevokeTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.invites.revokeConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.invites.revokeConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("admin.invites.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (revokeTarget) revoke.mutate(revokeTarget.id);
setRevokeTarget(null);
}}
>
{t("admin.invites.revokeConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
/**
* "Redeemed by" cell: single redeemer renders inline; multiple open a
* popover listing every redemption with username, email, and timestamp
* (the `InviteRedemption` ledger — `usedBy` alone only knows the last).
*/
function RedemptionsCell({
invite,
summary,
}: {
invite: AdminInvite;
summary: string;
}) {
const { t } = useTranslations();
if (invite.redemptions.length === 0) {
return <span className="text-muted-foreground text-sm">—</span>;
}
if (invite.redemptions.length === 1) {
const entry = invite.redemptions[0];
return (
<div className="leading-tight">
<div className="text-sm">
{entry.user?.username ?? t("admin.invites.redeemerDeleted")}
</div>
<div className="text-muted-foreground text-xs tabular-nums">
{formatDateTime(entry.redeemedAt)}
</div>
</div>
);
}
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="hover:text-foreground inline-flex min-h-11 items-center gap-1.5 text-sm underline-offset-4 hover:underline"
data-testid="admin-invite-redemptions-trigger"
>
<Users className="size-3.5" aria-hidden="true" />
{summary}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72">
<p className="text-sm font-medium">
{t("admin.invites.redemptionsTitle", {
count: invite.redemptions.length,
})}
</p>
{/* v1.16.5 — a 50-use invite lists up to 50 signups; cap the
list height so the popover never outgrows the viewport. */}
<ul
className="mt-2 max-h-64 space-y-2 overflow-y-auto pr-1"
data-testid="admin-invite-redemptions-list"
>
{invite.redemptions.map((entry) => (
<li key={entry.id} className="text-sm leading-tight">
<div>
{entry.user?.username ?? t("admin.invites.redeemerDeleted")}
{entry.user?.email && (
<span className="text-muted-foreground">
{" "}
· {entry.user.email}
</span>
)}
</div>
<div className="text-muted-foreground text-xs tabular-nums">
{formatDateTime(entry.redeemedAt)}
</div>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}