mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Add an opt-in "remember this device" at the second-factor step. On opt-in a random token is minted, set in an httpOnly+Secure+SameSite=Strict cookie, and only its HMAC hash is stored in a new TrustedDevice table. A later sign-in from the same browser still requires the password (factor 1); a matching, unexpired trusted device lets the login skip factor 2 only. The token is never accepted for step-up — a trusted-device login does not stamp mfaVerifiedAt — so a stolen device cookie cannot reach a destructive action. Trusted devices are listed and revocable from the security surface, are revoked wholesale when the second factor is removed or the user signs out everywhere, and cascade away on account deletion. A recovery-code login never trusts the browser. Migration 0204 adds the table (and the instance-wide mfa_required policy column that the enforcement work reads).
173 lines
5.5 KiB
TypeScript
173 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* v1.23 — Settings → "Trusted devices" ("remember this device").
|
|
*
|
|
* Lists the browsers the user opted to trust at the second-factor step. A
|
|
* trusted device skips factor 2 for 30 days (the password is still required);
|
|
* this card makes them visible and revocable. The list is hidden entirely when
|
|
* empty so a user who never opted in sees no clutter.
|
|
*/
|
|
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Loader2, ShieldCheck } from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { SettingsCard } from "@/components/settings/settings-card";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
|
|
|
|
interface TrustedDeviceRow {
|
|
id: string;
|
|
label: string | null;
|
|
createdAt: string;
|
|
lastUsedAt: string;
|
|
expiresAt: string;
|
|
isCurrent: boolean;
|
|
}
|
|
|
|
export function TrustedDevicesCard({
|
|
isAuthenticated,
|
|
}: {
|
|
isAuthenticated: boolean;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [status, setStatus] = useState<string | null>(null);
|
|
|
|
const { data, isLoading, isError } = useQuery({
|
|
queryKey: queryKeys.trustedDevices(),
|
|
queryFn: () =>
|
|
apiGet<{ devices: TrustedDeviceRow[] }>("/api/auth/me/trusted-devices"),
|
|
enabled: isAuthenticated,
|
|
});
|
|
|
|
function invalidate() {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: queryKeys.trustedDevices(),
|
|
});
|
|
void queryClient.invalidateQueries({
|
|
queryKey: queryKeys.securityActivity(),
|
|
});
|
|
}
|
|
|
|
const revokeOne = useMutation({
|
|
mutationFn: (id: string) => apiDelete(`/api/auth/me/trusted-devices/${id}`),
|
|
onSuccess: () => {
|
|
setStatus(null);
|
|
invalidate();
|
|
},
|
|
onError: () => setStatus(t("settings.security.trustedDevices.revokeError")),
|
|
});
|
|
|
|
const revokeAll = useMutation({
|
|
mutationFn: () =>
|
|
apiDelete<{ revoked: number }>("/api/auth/me/trusted-devices"),
|
|
onSuccess: (res) => {
|
|
setStatus(
|
|
t("settings.security.trustedDevices.revokeAllDone", {
|
|
count: res?.revoked ?? 0,
|
|
}),
|
|
);
|
|
invalidate();
|
|
},
|
|
onError: () => setStatus(t("settings.security.trustedDevices.revokeError")),
|
|
});
|
|
|
|
const devices = data?.devices ?? [];
|
|
|
|
// Hide the whole card when the user has no trusted devices — nothing to show
|
|
// and no opt-in is offered here (that happens at the second-factor step).
|
|
if (!isLoading && !isError && devices.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<SettingsCard data-slot="settings-trusted-devices-card">
|
|
<SettingsCardHeader
|
|
icon={ShieldCheck}
|
|
title={t("settings.security.trustedDevices.title")}
|
|
className="mb-4"
|
|
/>
|
|
<div className="space-y-4 pl-7">
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("settings.security.trustedDevices.description")}
|
|
</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.trustedDevices.loadError")}
|
|
</p>
|
|
)}
|
|
|
|
{devices.length > 0 && (
|
|
<ul className="divide-border divide-y">
|
|
{devices.map((d) => (
|
|
<li
|
|
key={d.id}
|
|
className="flex items-center justify-between gap-3 py-3"
|
|
data-slot="trusted-device-row"
|
|
>
|
|
<div className="min-w-0 space-y-0.5">
|
|
<p className="truncate text-sm font-medium">
|
|
{d.label ?? t("settings.security.trustedDevices.unnamed")}
|
|
{d.isCurrent && (
|
|
<span className="text-success ml-2 text-xs font-normal">
|
|
{t("settings.security.trustedDevices.current")}
|
|
</span>
|
|
)}
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("settings.security.trustedDevices.expires", {
|
|
date: new Date(d.expiresAt).toLocaleDateString(),
|
|
})}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="min-h-9 shrink-0"
|
|
onClick={() => revokeOne.mutate(d.id)}
|
|
disabled={revokeOne.isPending}
|
|
>
|
|
{t("settings.security.trustedDevices.revoke")}
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{devices.length > 0 && (
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="min-h-11 sm:min-h-9"
|
|
onClick={() => revokeAll.mutate()}
|
|
disabled={revokeAll.isPending}
|
|
>
|
|
{revokeAll.isPending && (
|
|
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("settings.security.trustedDevices.revokeAll")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{status && (
|
|
<p role="status" className="text-muted-foreground text-right text-sm">
|
|
{status}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</SettingsCard>
|
|
);
|
|
}
|