mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
A Guardian signed in through single sign-on with a second factor enrolled could not hand a managed profile over once five minutes had passed since sign-in: the handover answered 401 auth.stepup.required, and the panel printed "Confirm your second factor, then try again" with no way to do so. The same held for every action behind requireFreshMfa: creating, editing and ending a managed profile, adding and removing a Guardian, disabling the second factor, rotating recovery codes, removing a security key or a passkey, changing the password, the data wipe, account deletion and the key rotation. The recent-proof routes had the dialog; these had text. - requireFreshMfa names the proofs that clear a stale stamp in meta.methods (the possession proofs the account holds, never a password, which stamps reproofAt only). - The re-proof flow treats that refusal like auth.reproof.required: open the dialog, post the proof to /api/auth/reproof, send the request once more. The flow moves into a React-free module so it can be driven in tests; the hook binds it to the dialog. - The managed-profile mutation hooks take a required proof guard, and every caller passes recentProof.run. The other surfaces wrap their requests in recentProof.run; disabling the second factor asks before its confirmation opens, so the code typed there is not spent twice. - An account with nothing to re-prove in place (single sign-on only, no factor) is told to sign in again when adding a factor, instead of being offered a password it does not have. - step-up-web-caller-guard freezes the gated route methods and fails when a web request to one of them runs outside the flow. An account whose only second factor is the identity provider's is still asked for an app factor on the managed-profile acts: the app cannot see the provider's factor. Enrolling right after a sign-in needs no further proof, and the confirmation stamps the session, so the act follows. Refs #959
506 lines
18 KiB
TypeScript
506 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
AlertTriangle,
|
|
Check,
|
|
Fingerprint,
|
|
KeyRound,
|
|
Loader2,
|
|
Pencil,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { SettingsCard } from "@/components/settings/settings-card";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
|
import { EmptyState } from "@/components/ui/empty-state";
|
|
import { formatDate } from "@/lib/format";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import {
|
|
ApiError,
|
|
apiDelete,
|
|
apiGet,
|
|
apiPatch,
|
|
apiPost,
|
|
} from "@/lib/api/api-fetch";
|
|
import { describePasskeyError } from "@/lib/passkey-errors";
|
|
import { describeStepUp } from "./security-keys-card";
|
|
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
|
|
|
|
interface PasskeyInfo {
|
|
id: string;
|
|
name: string;
|
|
credentialDeviceType: string;
|
|
credentialBackedUp: boolean;
|
|
createdAt: string;
|
|
lastUsedAt: string | null;
|
|
}
|
|
|
|
type ReauthMethod = "password" | "passkey" | "totp" | "webauthn";
|
|
|
|
type ExistingFactorProof =
|
|
| { method: "password"; password: string }
|
|
| { method: "totp"; code: string }
|
|
| {
|
|
method: "passkey" | "webauthn";
|
|
challengeId: string;
|
|
credential: unknown;
|
|
};
|
|
|
|
/**
|
|
* Primary-passkey management: list (name, device, backup, created, last used),
|
|
* add (registration ceremony), rename, and delete. The passwordless-primary
|
|
* passkey home — distinct from the second-factor security keys above.
|
|
*/
|
|
export function PasskeyListSection({
|
|
isAuthenticated,
|
|
}: {
|
|
isAuthenticated: boolean;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editName, setEditName] = useState("");
|
|
const [reauthOpen, setReauthOpen] = useState(false);
|
|
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>("password");
|
|
const [reauthSecret, setReauthSecret] = useState("");
|
|
|
|
const { data: passkeys } = useQuery({
|
|
queryKey: queryKeys.passkeys(),
|
|
queryFn: async () => apiGet<PasskeyInfo[]>("/api/auth/passkeys"),
|
|
enabled: isAuthenticated,
|
|
});
|
|
|
|
const add = useMutation({
|
|
mutationFn: async ({
|
|
method,
|
|
secret,
|
|
}: {
|
|
method: ReauthMethod;
|
|
secret: string;
|
|
}) => {
|
|
const webauthn = await import("@simplewebauthn/browser");
|
|
let freshFactor: ExistingFactorProof;
|
|
|
|
if (method === "password") {
|
|
freshFactor = { method, password: secret };
|
|
} else if (method === "totp") {
|
|
freshFactor = { method, code: secret };
|
|
} else {
|
|
const assertionChallenge = await apiPost<{
|
|
options: Parameters<
|
|
typeof webauthn.startAuthentication
|
|
>[0]["optionsJSON"];
|
|
challengeId: string;
|
|
}>("/api/auth/passkey/register-options", { method });
|
|
const credential = await webauthn.startAuthentication({
|
|
optionsJSON: assertionChallenge.options,
|
|
});
|
|
freshFactor = {
|
|
method,
|
|
challengeId: assertionChallenge.challengeId,
|
|
credential,
|
|
};
|
|
}
|
|
|
|
const { options, challengeId } = await apiPost<{
|
|
options: Parameters<
|
|
typeof webauthn.startRegistration
|
|
>[0]["optionsJSON"];
|
|
challengeId: string;
|
|
}>("/api/auth/passkey/register-options", freshFactor);
|
|
const credential = await webauthn.startRegistration({
|
|
optionsJSON: options,
|
|
});
|
|
await apiPost("/api/auth/passkey/register-verify", {
|
|
challengeId,
|
|
credential,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
setMsg(null);
|
|
setReauthOpen(false);
|
|
setReauthSecret("");
|
|
setReauthMethod("password");
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
|
},
|
|
onError: (err) => {
|
|
if (err instanceof ApiError) {
|
|
setMsg(err.message || t("settings.passkeyRegistrationFailed"));
|
|
} else {
|
|
const { key, params } = describePasskeyError(err);
|
|
setMsg(t(key, params));
|
|
}
|
|
},
|
|
});
|
|
|
|
const rename = useMutation({
|
|
mutationFn: async ({ id, name }: { id: string; name: string }) => {
|
|
await apiPatch(`/api/auth/passkeys/${id}`, { name });
|
|
},
|
|
onSuccess: () => {
|
|
setEditingId(null);
|
|
setMsg(null);
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
|
},
|
|
onError: (err: Error) => setMsg(err.message),
|
|
});
|
|
|
|
// Removal is step-up gated: a second factor, or on a passkey-only account a
|
|
// passkey, proved within five minutes. The server names what clears it and
|
|
// the shared dialog collects that, then the removal runs again.
|
|
const recentProof = useRecentProof({
|
|
description: t("settings.reproof.descriptionChange"),
|
|
});
|
|
const remove = useMutation({
|
|
mutationFn: async (id: string) => {
|
|
await recentProof.run(() => apiDelete(`/api/auth/passkeys/${id}`));
|
|
},
|
|
onSuccess: () => {
|
|
setMsg(null);
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
|
},
|
|
// A refusal the dialog could not take up keeps its own sentence. The
|
|
// server's prose says "second-factor", which is wrong for the passkey-only
|
|
// account this card mostly serves, so it names the recovery that always
|
|
// exists on the web: sign in again.
|
|
onError: (err: Error) => {
|
|
if (err instanceof ReproofCancelledError) return;
|
|
setMsg(
|
|
describeStepUp(err, err.message, t("settings.passkeyStepUpRequired")),
|
|
);
|
|
},
|
|
});
|
|
|
|
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
|
singleDevice: t("settings.singleDevice"),
|
|
multiDevice: t("settings.multiDevice"),
|
|
};
|
|
|
|
return (
|
|
<SettingsCard>
|
|
{recentProof.dialog}
|
|
<SettingsCardHeader
|
|
anchor="passkeys"
|
|
icon={Fingerprint}
|
|
title={t("settings.passkeys")}
|
|
description={t("settings.passkeysDescription")}
|
|
/>
|
|
|
|
<div className="space-y-4">
|
|
{!passkeys || passkeys.length === 0 ? (
|
|
<EmptyState
|
|
variant="plain"
|
|
size="compact"
|
|
title={t("settings.noPasskeys")}
|
|
/>
|
|
) : (
|
|
<ul className="space-y-2" data-testid="passkeys-list">
|
|
{passkeys.map((pk) => (
|
|
<SettingsCard as="li" key={pk.id}>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
{editingId === pk.id ? (
|
|
<form
|
|
className="flex items-center gap-2"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
rename.mutate({ id: pk.id, name: editName.trim() });
|
|
}}
|
|
>
|
|
<Input
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
maxLength={64}
|
|
aria-label={t("settings.passkeyName")}
|
|
autoFocus
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="min-h-11 min-w-11 shrink-0 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
|
disabled={
|
|
rename.isPending || editName.trim().length === 0
|
|
}
|
|
aria-label={t("common.save")}
|
|
>
|
|
{rename.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<Check className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="min-h-11 min-w-11 shrink-0 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
|
onClick={() => setEditingId(null)}
|
|
aria-label={t("common.cancel")}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</form>
|
|
) : (
|
|
<>
|
|
<p className="truncate text-sm font-medium">
|
|
{pk.name}
|
|
</p>
|
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
|
|
<Badge variant="outline" className="text-xs">
|
|
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
|
pk.credentialDeviceType}
|
|
</Badge>
|
|
<Badge
|
|
variant={
|
|
pk.credentialBackedUp ? "secondary" : "outline"
|
|
}
|
|
className="text-xs"
|
|
>
|
|
{pk.credentialBackedUp
|
|
? t("settings.backedUp")
|
|
: t("common.no")}
|
|
</Badge>
|
|
<span className="text-muted-foreground">
|
|
{pk.lastUsedAt
|
|
? t("settings.security.keys.lastUsed", {
|
|
date: formatDate(pk.lastUsedAt),
|
|
})
|
|
: t("settings.security.keys.neverUsed")}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{editingId !== pk.id && (
|
|
<div className="flex shrink-0 gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
|
onClick={() => {
|
|
setEditingId(pk.id);
|
|
setEditName(pk.name);
|
|
}}
|
|
aria-label={t("settings.security.keys.rename")}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
|
disabled={remove.isPending}
|
|
aria-label={t("settings.deletePasskey")}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("settings.deletePasskey")}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("settings.deletePasskeyDescription")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>
|
|
{t("common.cancel")}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
disabled={remove.isPending}
|
|
aria-busy={remove.isPending || undefined}
|
|
onClick={() => remove.mutate(pk.id)}
|
|
>
|
|
{remove.isPending && (
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("common.delete")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SettingsCard>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{msg && !reauthOpen && (
|
|
<div
|
|
role="alert"
|
|
className="text-destructive flex items-center gap-2 text-sm"
|
|
>
|
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
|
{msg}
|
|
</div>
|
|
)}
|
|
|
|
<AlertDialog
|
|
open={reauthOpen}
|
|
onOpenChange={(open) => {
|
|
if (add.isPending) return;
|
|
setReauthOpen(open);
|
|
if (open) setMsg(null);
|
|
}}
|
|
>
|
|
<SettingsCardActions>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
className="min-h-11 sm:min-h-9"
|
|
disabled={add.isPending}
|
|
>
|
|
{add.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<KeyRound className="h-4 w-4" />
|
|
)}
|
|
{t("settings.addPasskey")}
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
</SettingsCardActions>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("settings.passkeyReauth.title")}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("settings.passkeyReauth.description")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
|
|
<div
|
|
className="grid grid-cols-2 gap-2"
|
|
role="group"
|
|
aria-label={t("settings.passkeyReauth.methodLabel")}
|
|
>
|
|
{(
|
|
[
|
|
["password", t("settings.passkeyReauth.methods.password")],
|
|
["totp", t("settings.passkeyReauth.methods.totp")],
|
|
["passkey", t("settings.passkeyReauth.methods.passkey")],
|
|
["webauthn", t("settings.passkeyReauth.methods.webauthn")],
|
|
] satisfies Array<[ReauthMethod, string]>
|
|
).map(([method, label]) => (
|
|
<Button
|
|
key={method}
|
|
type="button"
|
|
variant={reauthMethod === method ? "secondary" : "outline"}
|
|
aria-pressed={reauthMethod === method}
|
|
disabled={
|
|
add.isPending ||
|
|
(method === "passkey" && (passkeys?.length ?? 0) === 0)
|
|
}
|
|
onClick={() => {
|
|
setReauthMethod(method);
|
|
setReauthSecret("");
|
|
setMsg(null);
|
|
}}
|
|
>
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
{reauthMethod === "password" && (
|
|
<Input
|
|
type="password"
|
|
autoComplete="current-password"
|
|
value={reauthSecret}
|
|
onChange={(event) => setReauthSecret(event.target.value)}
|
|
placeholder={t("settings.passkeyReauth.currentPassword")}
|
|
aria-label={t("settings.passkeyReauth.currentPassword")}
|
|
autoFocus
|
|
/>
|
|
)}
|
|
{reauthMethod === "totp" && (
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
maxLength={6}
|
|
value={reauthSecret}
|
|
onChange={(event) =>
|
|
setReauthSecret(event.target.value.replace(/\D/g, ""))
|
|
}
|
|
placeholder={t(
|
|
"settings.passkeyReauth.authenticatorPlaceholder",
|
|
)}
|
|
aria-label={t("settings.passkeyReauth.authenticatorCode")}
|
|
autoFocus
|
|
/>
|
|
)}
|
|
|
|
{msg && (
|
|
<div
|
|
role="alert"
|
|
className="text-destructive flex items-center gap-2 text-sm"
|
|
>
|
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
|
{msg}
|
|
</div>
|
|
)}
|
|
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={add.isPending}>
|
|
{t("common.cancel")}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
disabled={
|
|
add.isPending ||
|
|
((reauthMethod === "password" || reauthMethod === "totp") &&
|
|
reauthSecret.length === 0)
|
|
}
|
|
aria-busy={add.isPending || undefined}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
add.mutate({
|
|
method: reauthMethod,
|
|
secret: reauthSecret,
|
|
});
|
|
}}
|
|
>
|
|
{add.isPending && (
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("settings.passkeyReauth.verifyAndAdd")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</SettingsCard>
|
|
);
|
|
}
|