feat(coach,admin): finish the self-context and invite surfaces end to end

Invites move into their own admin section with a real ledger: status
badges, expiry, uses, a redemption history of who joined when (new
redemption table, revocation is now soft so history survives), and an
honest one-time link/QR moment. The assistant self-context grows
structured fields for conditions, allergies and focus — encrypted like
the free text — merges age and gender from the profile into one fenced
prompt block, and asks up to three targeted follow-up questions that
surface as tappable chips above the coach composer. A fourth nudge
trigger gently suggests a check-up when the self-context is stale and
the coach is actually in use.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 16:18:48 +02:00
1 parent 9fe5673251
commit 5b75184649
32 files changed
+2648 -423

No files matched your search

@@ -0,0 +1,53 @@
/**
* v1.16.0 — admin invite table: status-derivation precedence.
*
* revoked > exhausted > expired > active. Revocation must win even on
* a row that is also expired/exhausted, because the badge documents
* the admin's action; exhaustion beats expiry because "it was fully
* used" is the more informative fact.
*/
import { describe, expect, it } from "vitest";
import { deriveInviteStatus } from "../invite-tokens-section";
const NOW = new Date("2026-06-10T12:00:00Z");
const FUTURE = new Date(NOW.getTime() + 86_400_000).toISOString();
const PAST = new Date(NOW.getTime() - 86_400_000).toISOString();
describe("deriveInviteStatus", () => {
it("revoked wins over every other state", () => {
expect(
deriveInviteStatus(
{ uses: 5, maxUses: 1, expiresAt: PAST, revokedAt: PAST },
NOW,
),
).toBe("revoked");
});
it("exhausted beats expired", () => {
expect(
deriveInviteStatus(
{ uses: 1, maxUses: 1, expiresAt: PAST, revokedAt: null },
NOW,
),
).toBe("exhausted");
});
it("expired when past expiry with uses left", () => {
expect(
deriveInviteStatus(
{ uses: 0, maxUses: 1, expiresAt: PAST, revokedAt: null },
NOW,
),
).toBe("expired");
});
it("active otherwise", () => {
expect(
deriveInviteStatus(
{ uses: 0, maxUses: 1, expiresAt: FUTURE, revokedAt: null },
NOW,
),
).toBe("active");
});
});
+8
View File
@@ -32,6 +32,7 @@ import {
ShieldAlert,
SlidersHorizontal,
Sparkles,
Ticket,
Users,
type LucideIcon,
} from "lucide-react";
@@ -104,6 +105,13 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
titleKey: "admin.section.users.title",
icon: Users,
},
// v1.16.0 — invites as their own section, directly after Users
// (admission lives next to the accounts it creates).
{
slug: "invites",
titleKey: "admin.section.invites.title",
icon: Ticket,
},
{
slug: "api-tokens",
titleKey: "admin.section.api-tokens.title",
+614 -163
View File
@@ -1,12 +1,23 @@
"use client";
/**
* v1.15.20 — admin card for registration invites (Users section).
* v1.16.0 — Admin → Invites. Full management surface for registration
* invite links (formerly a card inside the Users section).
*
* Mint an invite (7 / 14 / 30-day lifetime), hand it over as a link or
* QR code, see the open invites, revoke one. The raw token + URL + QR
* appear exactly once — straight from the POST response — and are gone
* after a reload; the list endpoint only ever returns metadata.
* 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`
@@ -14,7 +25,16 @@
*/
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Copy, Loader2, Ticket, Trash2 } from "lucide-react";
import {
Ban,
Check,
Copy,
Loader2,
Plus,
ShieldCheck,
Ticket,
Users,
} from "lucide-react";
import QRCode from "qrcode";
import { toast } from "sonner";
@@ -28,21 +48,53 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/format";
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";
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 {
@@ -53,21 +105,79 @@ interface MintedInvite {
qrDataUrl: string;
}
type InviteStatus = "active" | "expired" | "exhausted";
type InviteStatus = "active" | "expired" | "exhausted" | "revoked";
function deriveStatus(invite: AdminInvite, now: Date): InviteStatus {
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;
/** 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-dracula-green",
chip: "border-dracula-green/30 bg-dracula-green/10 text-dracula-green",
},
expired: {
dot: "bg-muted-foreground/60",
chip: "border-border bg-muted/40 text-muted-foreground",
},
exhausted: {
dot: "bg-dracula-cyan",
chip: "border-dracula-cyan/30 bg-dracula-cyan/10 text-dracula-cyan",
},
revoked: {
dot: "bg-dracula-red",
chip: "border-dracula-red/30 bg-dracula-red/10 text-dracula-red",
},
};
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 [deleteTarget, setDeleteTarget] = useState<AdminInvite | null>(null);
const [copied, setCopied] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<AdminInvite | null>(null);
const { data: invites, isError } = useQuery({
queryKey: queryKeys.adminInvites(),
@@ -80,11 +190,14 @@ export function InviteTokensSection() {
const create = useMutation({
mutationKey: queryKeys.adminInvites(),
mutationFn: async (expiresInDays: number): Promise<MintedInvite> => {
mutationFn: async (input: {
expiresInDays: number;
maxUses: number;
}): Promise<MintedInvite> => {
const res = await fetch("/api/admin/invites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expiresInDays }),
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()).data as Omit<MintedInvite, "qrDataUrl">;
@@ -104,206 +217,544 @@ export function InviteTokensSection() {
},
});
const remove = useMutation({
const revoke = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/admin/invites/${id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
},
onSuccess: (_data, id) => {
toast.success(t("admin.invites.deletedToast"));
if (minted?.id === id) setMinted(null);
onSuccess: () => {
toast.success(t("admin.invites.revokedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
},
onError: () => {
toast.error(t("admin.invites.deleteError"));
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() {
const parsedMax = Number.parseInt(maxUses, 10);
const safeMax =
Number.isFinite(parsedMax) && parsedMax >= 1
? Math.min(parsedMax, MAX_USES_CAP)
: 1;
create.mutate({ expiresInDays: ttlDays, maxUses: safeMax });
}
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 (
<div
className="bg-card border-border rounded-xl border p-4 sm:p-6"
<section
aria-labelledby="admin-invites-title"
className="bg-card border-border rounded-xl border"
data-testid="admin-invites-card"
>
<div className="flex items-center gap-2">
<Ticket className="text-primary h-5 w-5" aria-hidden="true" />
<h2 className="text-lg font-semibold">{t("admin.invites.title")}</h2>
</div>
<p className="text-muted-foreground mt-1 text-sm">
{t("admin.invites.description")}
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">
{t("admin.invites.createLabel")}
</span>
{TTL_CHOICES.map((days) => (
<Button
key={days}
type="button"
variant="outline"
size="sm"
disabled={create.isPending}
onClick={() => create.mutate(days)}
data-testid={`admin-invites-create-${days}`}
>
{create.isPending ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
{t("admin.invites.createDays", { days })}
</Button>
))}
</div>
{minted && (
<div
className="border-border bg-background mt-4 space-y-3 rounded-lg border p-4"
data-testid="admin-invites-minted"
>
<p className="text-sm font-medium">{t("admin.invites.tokenTitle")}</p>
<p className="text-muted-foreground text-xs">
{t("admin.invites.tokenOnceHint")}
</p>
<p className="bg-muted overflow-x-auto rounded-md p-2 font-mono text-xs break-all">
{minted.url}
</p>
<div className="flex flex-wrap items-start 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={160}
height={160}
className="rounded-md bg-white p-2"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copyUrl(minted.url)}
>
<Copy className="h-4 w-4" aria-hidden="true" />
{t("admin.invites.copyLink")}
</Button>
{/* 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-primary 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-sm">
{t("admin.invites.description")}
</p>
<p className="text-muted-foreground/80 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>
)}
<div className="mt-6 space-y-2">
{isError && (
<p className="text-destructive text-sm">
{t("admin.invites.loadError")}
</p>
)}
{invites && invites.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("admin.invites.listEmpty")}
</p>
)}
{invites?.map((invite) => {
const status = deriveStatus(invite, now);
// Literal keys so the i18n call-site coverage guard sees them.
const statusLabel =
status === "active"
? t("admin.invites.statusActive")
: status === "expired"
? t("admin.invites.statusExpired")
: t("admin.invites.statusExhausted");
return (
<div
key={invite.id}
className="border-border flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3"
>
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-center gap-2 text-sm">
<Badge
variant={status === "active" ? "default" : "secondary"}
>
{statusLabel}
</Badge>
<span className="text-muted-foreground text-xs">
{t("admin.invites.expires", {
date: formatDate(invite.expiresAt),
})}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{t("admin.invites.uses", {
used: invite.uses,
max: invite.maxUses,
})}
</span>
</div>
<p className="text-muted-foreground text-xs">
{t("admin.invites.createdMeta", {
date: formatDate(invite.createdAt),
by: invite.creator?.username ?? "—",
})}
{invite.consumer
? ` · ${t("admin.invites.usedBy", {
username: invite.consumer.username,
})}`
: ""}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive"
aria-label={t("admin.invites.deleteAria")}
disabled={remove.isPending}
onClick={() => setDeleteTarget(invite)}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
);
})}
</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-dracula-red hover:text-dracula-red"
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>
<AlertDialog
open={deleteTarget !== null}
{/* 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-dracula-red hover:text-dracula-red"
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) => {
if (!open) setDeleteTarget(null);
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>
<div className="space-y-5">
<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-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
: "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}
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
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="button"
disabled={create.isPending}
onClick={submitCreate}
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>
</>
) : (
<>
<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-dracula-purple/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-dracula-green 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.deleteConfirmTitle")}
{t("admin.invites.revokeConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.invites.deleteConfirmBody")}
{t("admin.invites.revokeConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("admin.invites.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deleteTarget) remove.mutate(deleteTarget.id);
setDeleteTarget(null);
if (revokeTarget) revoke.mutate(revokeTarget.id);
setRevokeTarget(null);
}}
>
{t("admin.invites.deleteConfirmAction")}
{t("admin.invites.revokeConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</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>
<ul className="mt-2 space-y-2">
{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>
);
}
+5
View File
@@ -24,6 +24,11 @@ export const ADMIN_SECTION_SLUGS = [
"feedback",
"reminders",
"users",
// v1.16.0 — registration invites get their own section. They started
// as a card inside Users, but the invite table (status, redemptions,
// revocation) is a workflow of its own and was drowning under the
// account list; "who may join" sits right next to "who is here".
"invites",
"api-tokens",
"login-overview",
"app-logs",
+231 -46
View File
@@ -1,37 +1,54 @@
"use client";
/**
* v1.15.20 — Settings → AI "About me" panel.
* v1.15.20 — Settings → AI "About me" panel (free text only).
* v1.16.0 — structured self-context: alongside the free text the user
* answers three short questions (chronic conditions, allergies /
* intolerances, what the Coach should watch). All four fields are
* encrypted at rest; age/gender stay on the profile and are merged
* into the prompt server-side — the panel says so instead of asking
* again.
*
* Free-text self-description the user writes for the AI surfaces. The
* server stores it encrypted (`UserHealthProfile.aboutMeEncrypted`) and
* injects it into the Coach system prompt + the daily briefing as a
* delimited, user-provided context block.
* GET /api/coach/about-me — full structured payload + pending questions
* PUT /api/coach/about-me — writes all four fields (empty string clears)
*
* GET /api/coach/about-me — { aboutMe, updatedAt, maxChars }
* PUT /api/coach/about-me — { aboutMe } (empty string clears)
* After a save the server derives up to 3 clarifying questions (AI
* when a provider + budget allow, deterministic hints otherwise); the
* panel surfaces them in a quiet callout and the Coach composer
* renders them as tappable chips.
*
* Plain text only — the value is rendered exclusively through a
* `<textarea>` and React text children; no markdown anywhere.
* Plain text only — every value renders exclusively through inputs and
* React text children; no markdown anywhere.
*/
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, NotebookPen, Trash2 } from "lucide-react";
import { Loader2, MessageCircleQuestion, NotebookPen, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
const FALLBACK_MAX_CHARS = 4000;
const FALLBACK_FIELD_MAX_CHARS = 500;
interface AboutMeData {
aboutMe: string | null;
conditions: string | null;
allergies: string | null;
coachFocus: string | null;
pendingQuestions: string[];
updatedAt: string | null;
maxChars: number;
fieldMaxChars: number;
}
type StructuredKey = "conditions" | "allergies" | "coachFocus";
async function fetchAboutMe(): Promise<AboutMeData> {
const res = await fetch("/api/coach/about-me");
if (!res.ok) {
@@ -40,6 +57,9 @@ async function fetchAboutMe(): Promise<AboutMeData> {
return (await res.json()).data as AboutMeData;
}
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none";
export function AboutMeSection({
isAuthenticated,
}: {
@@ -47,7 +67,12 @@ export function AboutMeSection({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [draft, setDraft] = useState<string | null>(null);
const [drafts, setDrafts] = useState<{
aboutMe: string | null;
conditions: string | null;
allergies: string | null;
coachFocus: string | null;
}>({ aboutMe: null, conditions: null, allergies: null, coachFocus: null });
const query = useQuery({
queryKey: queryKeys.coachAboutMe(),
@@ -55,64 +80,189 @@ export function AboutMeSection({
enabled: isAuthenticated,
});
const saved = query.data?.aboutMe ?? "";
const saved = {
aboutMe: query.data?.aboutMe ?? "",
conditions: query.data?.conditions ?? "",
allergies: query.data?.allergies ?? "",
coachFocus: query.data?.coachFocus ?? "",
};
const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
const value = draft ?? saved;
// A draft equal to the server value renders identically and keeps the
// save button disabled, so no effect is needed to clear it — the
// save mutation's onSuccess resets the draft after a round-trip.
const dirty = draft !== null && draft !== saved;
const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
const pendingQuestions = query.data?.pendingQuestions ?? [];
const value = {
aboutMe: drafts.aboutMe ?? saved.aboutMe,
conditions: drafts.conditions ?? saved.conditions,
allergies: drafts.allergies ?? saved.allergies,
coachFocus: drafts.coachFocus ?? saved.coachFocus,
};
const dirty =
value.aboutMe !== saved.aboutMe ||
value.conditions !== saved.conditions ||
value.allergies !== saved.allergies ||
value.coachFocus !== saved.coachFocus;
const hasAnyContent =
saved.aboutMe.length > 0 ||
saved.conditions.length > 0 ||
saved.allergies.length > 0 ||
saved.coachFocus.length > 0;
// Quiet completeness meter: one segment per answered field. Reads
// the SAVED state (not the draft) so it only moves on persistence.
const answered = [
saved.conditions,
saved.allergies,
saved.coachFocus,
saved.aboutMe,
].filter((v) => v.length > 0).length;
const save = useMutation({
mutationKey: queryKeys.coachAboutMe(),
mutationFn: async (aboutMe: string) => {
mutationFn: async (input: {
aboutMe: string;
conditions: string;
allergies: string;
coachFocus: string;
}) => {
const res = await fetch("/api/coach/about-me", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ aboutMe }),
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return ((await res.json()).data as AboutMeData).aboutMe ?? "";
return (await res.json()).data as AboutMeData;
},
onSuccess: (nextSaved) => {
onSuccess: (next) => {
const cleared =
!next.aboutMe &&
!next.conditions &&
!next.allergies &&
!next.coachFocus;
toast.success(
nextSaved.length > 0
? t("settings.ai.aboutMe.savedToast")
: t("settings.ai.aboutMe.clearedToast"),
cleared
? t("settings.ai.aboutMe.clearedToast")
: t("settings.ai.aboutMe.savedToast"),
);
setDraft(null);
setDrafts({
aboutMe: null,
conditions: null,
allergies: null,
coachFocus: null,
});
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMeQuestions(),
});
},
onError: () => {
toast.error(t("settings.ai.aboutMe.saveError"));
},
});
const disabled = !isAuthenticated || query.isLoading || save.isPending;
function structuredField(
key: StructuredKey,
label: string,
placeholder: string,
) {
return (
<div className="space-y-1.5">
<Label htmlFor={`settings-about-me-${key}`}>{label}</Label>
<textarea
id={`settings-about-me-${key}`}
data-testid={`settings-about-me-${key}`}
className={cn(FIELD_CLASSES, "min-h-16 resize-y")}
rows={2}
value={value[key]}
maxLength={fieldMaxChars}
placeholder={placeholder}
disabled={disabled}
onChange={(e) =>
setDrafts((d) => ({ ...d, [key]: e.target.value }))
}
/>
</div>
);
}
return (
<section
aria-labelledby="settings-ai-about-me-title"
data-testid="settings-about-me-card"
className="bg-card border-border space-y-4 rounded-xl border p-6"
className="bg-card border-border space-y-5 rounded-xl border p-6"
>
<SettingsCardHeader
icon={NotebookPen}
title={t("settings.ai.aboutMe.title")}
titleId="settings-ai-about-me-title"
description={t("settings.ai.aboutMe.description")}
/>
<div className="flex flex-wrap items-start justify-between gap-3">
<SettingsCardHeader
icon={NotebookPen}
title={t("settings.ai.aboutMe.title")}
titleId="settings-ai-about-me-title"
description={t("settings.ai.aboutMe.description")}
/>
{/* Completeness — four quiet segments, no percentage shouting. */}
<div
className="flex items-center gap-1.5"
aria-label={t("settings.ai.aboutMe.completeness", {
answered,
total: 4,
})}
title={t("settings.ai.aboutMe.completeness", {
answered,
total: 4,
})}
>
{[0, 1, 2, 3].map((i) => (
<span
key={i}
aria-hidden="true"
className={cn(
"h-1 w-5 rounded-full transition-colors",
i < answered ? "bg-dracula-purple" : "bg-border",
)}
/>
))}
</div>
</div>
<textarea
data-testid="settings-about-me-textarea"
className="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring min-h-36 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none"
value={value}
maxLength={maxChars}
placeholder={t("settings.ai.aboutMe.placeholder")}
disabled={!isAuthenticated || query.isLoading || save.isPending}
onChange={(e) => setDraft(e.target.value)}
aria-label={t("settings.ai.aboutMe.title")}
/>
<div className="grid gap-4 sm:grid-cols-2">
{structuredField(
"conditions",
t("settings.ai.aboutMe.conditionsLabel"),
t("settings.ai.aboutMe.conditionsPlaceholder"),
)}
{structuredField(
"allergies",
t("settings.ai.aboutMe.allergiesLabel"),
t("settings.ai.aboutMe.allergiesPlaceholder"),
)}
<div className="sm:col-span-2">
{structuredField(
"coachFocus",
t("settings.ai.aboutMe.focusLabel"),
t("settings.ai.aboutMe.focusPlaceholder"),
)}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="settings-about-me-freetext">
{t("settings.ai.aboutMe.freeTextLabel")}
</Label>
<textarea
id="settings-about-me-freetext"
data-testid="settings-about-me-textarea"
className={cn(FIELD_CLASSES, "min-h-36 resize-y")}
value={value.aboutMe}
maxLength={maxChars}
placeholder={t("settings.ai.aboutMe.placeholder")}
disabled={disabled}
onChange={(e) =>
setDrafts((d) => ({ ...d, aboutMe: e.target.value }))
}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<p
@@ -120,19 +270,26 @@ export function AboutMeSection({
className="text-muted-foreground text-xs tabular-nums"
>
{t("settings.ai.aboutMe.charCount", {
used: value.length,
used: value.aboutMe.length,
max: maxChars,
})}
</p>
<div className="flex items-center gap-2">
{saved.length > 0 && (
{hasAnyContent && (
<Button
type="button"
variant="outline"
size="sm"
data-testid="settings-about-me-clear"
disabled={!isAuthenticated || save.isPending}
onClick={() => save.mutate("")}
onClick={() =>
save.mutate({
aboutMe: "",
conditions: "",
allergies: "",
coachFocus: "",
})
}
>
<Trash2 className="size-4" aria-hidden />
{t("settings.ai.aboutMe.clear")}
@@ -156,6 +313,34 @@ export function AboutMeSection({
</div>
</div>
{pendingQuestions.length > 0 && (
<div
data-testid="settings-about-me-questions"
className="border-dracula-purple/30 bg-dracula-purple/5 rounded-lg border p-4"
>
<p className="flex items-center gap-2 text-sm font-medium">
<MessageCircleQuestion
className="text-dracula-purple size-4 shrink-0"
aria-hidden="true"
/>
{t("settings.ai.aboutMe.questionsTitle")}
</p>
<ul className="text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm">
{pendingQuestions.map((q) => (
<li key={q}>{q}</li>
))}
</ul>
<p className="mt-3 text-xs">
<Link
href="/insights/coach"
className="text-dracula-purple underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
</p>
</div>
)}
{query.isError && (
<p role="status" aria-live="polite" className="text-destructive text-xs">
{t("settings.ai.aboutMe.loadError")}
@@ -163,7 +348,7 @@ export function AboutMeSection({
)}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.aboutMe.hint")}
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
</section>
);