Files
HealthLog/src/components/settings/security-sessions-card.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

214 lines
7.5 KiB
TypeScript

"use client";
/**
* v1.23 — Settings → account → "Active sessions" (issue #64).
*
* Lists the user's active web sessions (device label, masked IP, resolved
* location, last-active time, current-device marker) and offers a per-session
* "sign out" plus a "sign out everywhere else" that revokes every other
* session (and native device logins) while keeping the current one. All reads
* + writes route through the centralised query-key factory.
*/
import { useId, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
interface SessionRow {
id: string;
device: string;
ipMasked: string | null;
location: string | null;
lastActiveAt: string | null;
createdAt: string;
isCurrent: boolean;
}
export function SecuritySessionsCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [status, setStatus] = useState<string | null>(null);
// Collapsed by default — the list opens only when the user asks for it, so
// the security surface stays calm and skimmable on first paint. UI-only
// state; nothing is persisted across reloads.
const [open, setOpen] = useState(false);
const regionId = useId();
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.sessions(),
queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"),
enabled: isAuthenticated,
});
const revokeOne = useMutation({
mutationFn: (id: string) => apiDelete(`/api/auth/me/sessions/${id}`),
onSuccess: () => {
setStatus(null);
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() });
void queryClient.invalidateQueries({
queryKey: queryKeys.securityActivity(),
});
},
onError: () => setStatus(t("settings.security.revokeError")),
});
const revokeOthers = useMutation({
mutationFn: () =>
apiDelete<{ sessionsRevoked: number }>("/api/auth/me/sessions"),
onSuccess: (res) => {
setStatus(
t("settings.security.signOutEverywhereDone", {
count: res?.sessionsRevoked ?? 0,
}),
);
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() });
void queryClient.invalidateQueries({
queryKey: queryKeys.securityActivity(),
});
},
onError: () => setStatus(t("settings.security.revokeError")),
});
const sessions = data?.sessions ?? [];
const hasOthers = sessions.some((s) => !s.isCurrent);
return (
<SettingsCard data-slot="settings-security-sessions-card">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="settings-security-sessions-toggle"
className="hover:bg-muted/40 focus-visible:ring-ring/50 -m-2 mb-2 flex w-full items-start gap-2 rounded-md p-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<MonitorSmartphone
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<h2 className="text-lg font-semibold">
{t("settings.security.sessionsTitle")}
</h2>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</div>
</button>
<div id={regionId} hidden={!open} className="space-y-4">
<p className="text-muted-foreground text-xs">
{t("settings.security.sessionsDescription")}
</p>
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.sessionsLoadError")}
</p>
)}
{!isLoading && !isError && sessions.length === 0 && (
<EmptyState
variant="plain"
size="compact"
title={t("settings.security.sessionsEmpty")}
/>
)}
{sessions.length > 0 && (
<ul className="divide-border divide-y">
{sessions.map((s) => (
<li
key={s.id}
className="flex items-center justify-between gap-3 py-3"
data-slot="security-session-row"
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium">
{s.device}
{s.isCurrent && (
<span className="text-success ml-2 text-xs font-normal">
{t("settings.security.currentSession")}
</span>
)}
</p>
<p className="text-muted-foreground truncate text-xs">
{[
s.location ?? t("settings.security.unknownLocation"),
s.ipMasked,
]
.filter(Boolean)
.join(" · ")}
</p>
{s.lastActiveAt && (
<p className="text-muted-foreground text-xs">
{t("settings.security.lastActive", {
time: fmt.dateTime(s.lastActiveAt),
})}
</p>
)}
</div>
{!s.isCurrent && (
<ConfirmButton
slot="revoke-session"
size="sm"
className="min-h-9 shrink-0"
label={t("settings.security.revokeSession")}
title={t("settings.security.revokeSessionConfirmTitle")}
body={t("settings.security.revokeSessionConfirmBody", {
device: s.device,
})}
confirmLabel={t("settings.security.revokeSession")}
onConfirm={() => revokeOne.mutate(s.id)}
pending={revokeOne.isPending}
/>
)}
</li>
))}
</ul>
)}
{hasOthers && (
<div className="flex justify-end">
<ConfirmButton
slot="sign-out-everywhere"
className="min-h-11 sm:min-h-9"
label={t("settings.security.signOutEverywhere")}
title={t("settings.security.signOutEverywhereConfirmTitle")}
body={t("settings.security.signOutEverywhereConfirmBody")}
confirmLabel={t("settings.security.signOutEverywhere")}
onConfirm={() => revokeOthers.mutate()}
pending={revokeOthers.isPending}
/>
</div>
)}
{status && (
<p role="status" className="text-muted-foreground text-right text-sm">
{status}
</p>
)}
</div>
</SettingsCard>
);
}