mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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.
214 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|