Files
HealthLog/src/components/settings/password-card.tsx
T
Marc-André Bombeck 99f0607a69 fix(auth): let every fresh-factor refusal open the re-proof dialog
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
2026-10-17 09:17:03 +02:00

213 lines
7.0 KiB
TypeScript

"use client";
import { useState } from "react";
import { Loader2, Save, Shield } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
statusText,
type StatusMessage,
} from "@/components/settings/account-section/account-section-utils";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
throwIfReproofRequired,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
/**
* Change-password card.
*
* It used to sit at the bottom of Settings → Account, under the profile form
* and the identity fields. Changing a password is not a profile edit — it is
* the same decision as enrolling a second factor or adding a passkey, and
* those live in Settings → Security. The card moved to sit with them so
* "how I get into this account" reads as one place.
*
* The status hint stores the i18n KEY, not the translated string, so a locale
* switch re-renders it in the new language — the settings-wide convention
* `statusText` exists for.
*/
export function PasswordCard() {
const { t } = useTranslations();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<StatusMessage | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const recentProof = useRecentProof({
description: t("settings.reproof.descriptionChange"),
});
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
setMsgType(null);
if (newPassword !== confirmPassword) {
setMsg({ key: "settings.passwordMismatch" });
setMsgType("error");
setSaving(false);
return;
}
try {
// An account with a second factor changes its password only with that
// factor proved within five minutes; the shared dialog collects it when
// the server asks and the change is sent again.
const body = JSON.stringify({
currentPassword,
newPassword,
confirmPassword,
});
const res = await recentProof.run(async () =>
throwIfReproofRequired(
await apiFetchRaw("/api/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
}),
),
);
const json = await res.json();
if (!res.ok) {
setMsg(
json.error ? { text: json.error } : { key: "settings.savingError" },
);
setMsgType("error");
return;
}
setMsg({ key: "settings.passwordUpdated" });
setMsgType("success");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch (err) {
const message = recentProofErrorMessage(err, "");
if (message === null) return;
setMsg(message ? { text: message } : { key: "common.networkError" });
setMsgType("error");
} finally {
setSaving(false);
}
}
return (
<>
{recentProof.dialog}
{/* The action is this card's only one, so it sits in the card's action
row like every other primary. It stays full-width under `sm` — the
German "Passwort ändern" is long enough that a right-hugging button
used to break through the card's right border on a narrow phone. */}
<SettingsCard data-slot="settings-password-card">
<SettingsCardHeader
anchor="password"
icon={Shield}
title={t("settings.passwordTitle")}
description={t("settings.changePasswordDescription")}
/>
<SettingsCardActions>
<Button
type="button"
onClick={() => setDialogOpen(true)}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
</Button>
</SettingsCardActions>
</SettingsCard>
<ResponsiveSheet
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setMsg(null);
setMsgType(null);
}
}}
title={t("settings.passwordTitle")}
description={t("settings.changePasswordDescription")}
className="sm:max-w-xl"
>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="grid gap-3 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="current-password">
{t("settings.currentPassword")}
</Label>
<PasswordInput
id="current-password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
<PasswordInput
id="new-password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">
{t("settings.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
{msg && (
<p
role="alert"
className={`text-sm ${
msgType === "success" ? "text-success" : "text-destructive"
}`}
>
{statusText(msg, t)}
</p>
)}
<Button
type="submit"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={saving}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.changePassword")}
</Button>
</form>
</ResponsiveSheet>
</>
);
}