mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +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
213 lines
7.0 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|