Files
HealthLog/src/components/settings/security-activity-card.tsx
T
Marc-André Bombeck b2e1aa0cde refactor(settings): one header, one action row, one integration card
The sweep that follows from the primitives. Nothing here is new behaviour;
every change removes a second way of saying something the surface already
said one way somewhere else.

Headers. The two collapsible security cards stop rebuilding
`SettingsCardHeader` by hand to carry a chevron — the chevron rides inside
the title button, and the description moves out of the collapsed region, so
the first paint is no longer a bare title. The import card shell, the export
and import section headings, and the four free-standing group headers stop
inventing their own type scale: a heading above a GROUP of cards is
`SectionHeading` (`text-base`), a card title is `SettingsCardHeader`
(`text-lg`), and the step between them is what says which is which. The
targets card gets a title instead of a naked icon.

Actions. Every card-level row is `SettingsCardActions`. The primary action
comes out of the header's status slot in six cards (password, web push,
pillar order, coach memory, and the two danger-zone triggers) and out of the
middle of the card in three more; the reset that hung in the dashboard-layout
header joins save as a secondary in the same row. Result and status lines
move ABOVE the row — sources' cross-link, the invite form's two status lines
— because nothing follows a card's actions. Revoking a share link is
`variant="destructive"` rather than an `outline` hand-tinted red in two
different ways. The import row had four variants for four buttons; it has
two. Export downloads are the card's primary, not an outline link.

Integrations. One description sentence with the setup-guide link inline: the
second paragraph was byte-identical across six of eight cards, which is the
tell that it said nothing about the provider it sat under. The `<hr>` under
the header — which existed only in this folder — is gone. So is the hint that
pointed at a guide 40 px above it, and the second format for "not
configured". Connect is one button everywhere: primary, `sm`, in the action
row, instead of primary-and-small in three cards and outline-and-large in
four.

Plus the standards leftovers the audit listed: two `pl-5` list gutters, one
`scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four
hand-rolled callout shells that `SettingsInfoTile` already paints, and the
two inline buttons that never had the mobile tap floor at all.
2026-08-07 19:39:11 +02:00

150 lines
5.4 KiB
TypeScript

"use client";
/**
* v1.23 — Settings → account → "Security activity".
*
* A read-only feed of the user's recent account-security events (sign-ins, MFA
* changes, password changes, session revocations, exports, deletions) from the
* shared `GET /api/auth/me/security-activity` endpoint. Each row shows a
* readable action label, the resolved location, the masked IP, and the time.
*/
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
interface ActivityRow {
action: string;
createdAt: string;
location: string | null;
ipMasked: string | null;
carrier: string | null;
}
/** Map an audit action onto a stable, translatable label key. */
function actionLabelKey(action: string): string {
if (action === "auth.login.new_device")
return "settings.security.action.newDevice";
if (action === "auth.login.failed")
return "settings.security.action.loginFailed";
if (action.startsWith("auth.login") || action === "auth.register")
return "settings.security.action.login";
if (action.startsWith("auth.password"))
return "settings.security.action.passwordChange";
if (action.startsWith("auth.mfa")) return "settings.security.action.mfa";
if (action.startsWith("auth.session"))
return "settings.security.action.sessionRevoke";
if (action.startsWith("auth.token") || action.startsWith("auth.bearer"))
return "settings.security.action.token";
if (action === "user.account.delete")
return "settings.security.action.accountDelete";
if (action === "user.data.clear") return "settings.security.action.dataClear";
if (action.includes("export")) return "settings.security.action.export";
return "settings.security.action.other";
}
export function SecurityActivityCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
// Collapsed by default — the feed opens only on demand, keeping the security
// surface skimmable. UI-only state; nothing is persisted across reloads.
const [open, setOpen] = useState(false);
const regionId = useId();
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.securityActivity(),
queryFn: () =>
apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"),
enabled: isAuthenticated,
});
const events = data?.events ?? [];
return (
<SettingsCard data-slot="settings-security-activity-card">
<SettingsCardHeader
icon={ShieldCheck}
title={
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="settings-security-activity-toggle"
className="hover:text-foreground focus-visible:ring-ring/50 -m-1 flex min-h-11 items-center gap-2 rounded-md p-1 text-left focus-visible:ring-2 focus-visible:outline-none"
>
{t("settings.security.activityTitle")}
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</button>
}
description={t("settings.security.activityDescription")}
/>
<div id={regionId} hidden={!open} className="space-y-4">
{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.activityLoadError")}
</p>
)}
{!isLoading && !isError && events.length === 0 && (
<EmptyState
variant="plain"
size="compact"
title={t("settings.security.activityEmpty")}
/>
)}
{events.length > 0 && (
<ul className="divide-border divide-y">
{events.map((e, idx) => (
<li
key={`${e.action}-${e.createdAt}-${idx}`}
className="flex items-center justify-between gap-3 py-2"
data-slot="security-activity-row"
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium">
{t(actionLabelKey(e.action))}
</p>
<p className="text-muted-foreground truncate text-xs">
{[
e.location ?? t("settings.security.unknownLocation"),
e.ipMasked,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<p className="text-muted-foreground shrink-0 text-xs">
{fmt.dateTime(e.createdAt)}
</p>
</li>
))}
</ul>
)}
</div>
</SettingsCard>
);
}