Merge branch 'fix/v1393-review-security' into release/v1.39.3

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 00:59:23 +02:00
commit 3cbfe7f7f9
76 files changed
+2751 -409

No files matched your search

@@ -70,15 +70,13 @@ describe("grant invitation lapse date", () => {
),
"utf8",
);
const onError = source.slice(
source.indexOf("onError: (err) => setError"),
source.indexOf(
" );\n };",
source.indexOf("onError: (err) => setError"),
),
);
// The failure arm of the submit: everything from its `catch` to the end
// of the handler.
const start = source.indexOf("} catch (err) {");
expect(start).toBeGreaterThan(-1);
const onError = source.slice(start, source.indexOf("\n };", start));
expect(onError).toContain("setError(t(inviteErrorKey(err)))");
expect(onError).toContain("t(inviteErrorKey(err))");
expect(onError).not.toContain("setIdentifier");
expect(onError).not.toContain("setExpiresOn");
expect(source).toContain('role="alert"');
@@ -18,6 +18,10 @@ import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useInviteGrant } from "@/lib/queries/use-account-grants";
import {
ReproofCancelledError,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
@@ -79,6 +83,7 @@ import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
export function GrantInviteCard() {
const { t } = useTranslations();
const invite = useInviteGrant();
const recentProof = useRecentProof();
const ownerTimezone = useInvitationOwnerTimezone();
const [identifier, setIdentifier] = useState("");
const [expiresOn, setExpiresOn] = useState("");
@@ -118,31 +123,40 @@ export function GrantInviteCard() {
setError(null);
};
const submit = (event: React.FormEvent) => {
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const value = identifier.trim();
if (value.length === 0 || choice.blocked) return;
setError(null);
setInvited(null);
invite.mutate(
{
identifier: value,
access,
scope: choice.scope,
expiresAt: endOfDayIso(expiresOn, ownerTimezone),
},
{
onSuccess: (grant) => {
setIdentifier("");
setExpiresOn("");
setAccess("READ");
setNarrowed(null);
setSections([]);
setInvited(grant.account.username);
},
onError: (err) => setError(t(inviteErrorKey(err))),
},
);
// Offering access hands somebody a standing way into the record, so the
// server asks for a fresh proof unless the session signed in or re-proved
// within five minutes; the dialog collects it and the invitation is sent
// again.
try {
const grant = await recentProof.run(() =>
invite.mutateAsync({
identifier: value,
access,
scope: choice.scope,
expiresAt: endOfDayIso(expiresOn, ownerTimezone),
}),
);
setIdentifier("");
setExpiresOn("");
setAccess("READ");
setNarrowed(null);
setSections([]);
setInvited(grant.account.username);
} catch (err) {
if (err instanceof ReproofCancelledError) return;
setError(
err instanceof ApiError &&
err.meta?.errorCode === "auth.reproof.sign_in_again"
? err.message
: t(inviteErrorKey(err)),
);
}
};
return (
@@ -159,7 +173,7 @@ export function GrantInviteCard() {
"recordSharing.invite.needsAccount",
)}`}
/>
<form onSubmit={submit} className="space-y-3">
<form onSubmit={(event) => void submit(event)} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="grant-invite-identifier">
{t("recordSharing.invite.identifierLabel")}
@@ -373,6 +387,7 @@ export function GrantInviteCard() {
</Button>
</SettingsCardActions>
</form>
{recentProof.dialog}
</SettingsCard>
);
}
@@ -34,7 +34,11 @@ import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import {
describeRejectedProfileField,
describeRejectedProfileFields,
@@ -75,6 +79,12 @@ export function AccountSection() {
// in within the last few minutes; the server decides and names the field if
// it wanted one. Never stored beyond this form.
const [emailPassword, setEmailPassword] = useState("");
// On an account with a second factor the password does not move the
// address: the server asks for the factor instead (`second_factor_required`)
// and the save runs once more after the re-proof dialog. Once that is
// known the password field is not offered, since it could only be refused.
const [emailNeedsFactor, setEmailNeedsFactor] = useState(false);
const recentProof = useRecentProof();
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
@@ -193,8 +203,46 @@ export function AccountSection() {
email.trim() !== "" &&
email.trim().toLowerCase() !== profileSeed.email.trim().toLowerCase();
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault();
/**
* Did the server keep the new address back until a second factor is
* proved? Either as the one rejected field of a partial save, or as the
* whole answer when the address was all that changed.
*/
function needsSecondFactor(
rejected: RejectedProfileField[] | undefined,
errorCode: string | undefined,
): boolean {
return (
errorCode === "profile.update.emailSecondFactorRequired" ||
(rejected ?? []).some(
(r) => r.path === "email" && r.code === "second_factor_required",
)
);
}
/**
* Ask for the second factor (or a passkey) through the shared re-proof
* dialog, then save once more. Returns false when the person closed the
* dialog or could not confirm, leaving the message to show.
*/
async function proveFactorThenSave(): Promise<boolean> {
setEmailNeedsFactor(true);
try {
await recentProof.run(() => apiGet("/api/auth/reproof"));
} catch (err) {
const message = recentProofErrorMessage(err, "");
if (message) {
setSaveMsg({ text: message });
setSaveMsgType("error");
}
return false;
}
await handleSaveProfile(undefined, true);
return true;
}
async function handleSaveProfile(e?: React.FormEvent, afterFactor = false) {
e?.preventDefault();
setSaving(true);
setSaveMsg(null);
setSaveMsgType(null);
@@ -255,6 +303,10 @@ export function AccountSection() {
} | null;
const rejected = profileJson?.data?.rejectedFields;
if (!afterFactor && needsSecondFactor(rejected, undefined)) {
if (await proveFactorThenSave()) return;
}
if (rejected && rejected.length > 0) {
// The write is field-independent: everything but the rejected
// field(s) landed. Name the field instead of implying full
@@ -304,6 +356,9 @@ export function AccountSection() {
details?: { issues?: RejectedProfileField[] };
} | null;
const errorCode = json?.meta?.errorCode;
if (!afterFactor && needsSecondFactor(undefined, errorCode)) {
if (await proveFactorThenSave()) return;
}
const field = describeRejectedProfileField(json?.details?.issues, t);
setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
if (errorCode === "profile.update.nothingSaved" && field) {
@@ -350,7 +405,10 @@ export function AccountSection() {
<SettingsCard>
<SettingsCardHeader icon={User} title={t("settings.profile")} />
<form onSubmit={handleSaveProfile} className="space-y-4">
<form
onSubmit={(event) => void handleSaveProfile(event)}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
@@ -386,7 +444,7 @@ export function AccountSection() {
aria-describedby={fieldErrors.email ? "email-error" : undefined}
/>
<FieldError id="email-error" message={fieldErrors.email} />
{emailChanged && (
{emailChanged && !emailNeedsFactor && (
<div className="space-y-2 pt-1">
<Label htmlFor="email-current-password">
{t("settings.emailChangePassword")}
@@ -636,6 +694,7 @@ export function AccountSection() {
</Button>
</SettingsCardActions>
</form>
{recentProof.dialog}
</SettingsCard>
{/* v1.18.0 (S5) — injection-site exclusions moved to the dedicated
@@ -48,6 +48,11 @@ import { ReportVisitOffer } from "./report-visit-offer";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
throwIfReproofRequired,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -67,6 +72,9 @@ type ExportFormat = "pdf" | "fhir" | "package";
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
const PRESET_RANGES = [30, 90, 180, 365] as const;
/** A refusal already worded for the panel; shown as it is. */
class ReportRequestError extends Error {}
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
@@ -119,18 +127,40 @@ export function HealthRecordExportPanel() {
onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
});
// The report is built from the whole record, so it asks for a fresh proof
// unless the session signed in or re-proved within five minutes.
const recentProof = useRecentProof();
async function handleGenerate() {
setBusy(true);
setError(null);
try {
const range =
customRange && startDate && endDate
? {
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
}
: { days };
const res = await apiFetchRaw("/api/export/health-record", {
await recentProof.run(generateOnce);
} catch (err) {
if (err instanceof ReportRequestError) {
setError(err.message);
return;
}
const message = recentProofErrorMessage(
err,
err instanceof Error ? err.message : String(err),
);
if (message) setError(message);
} finally {
setBusy(false);
}
}
async function generateOnce() {
const range =
customRange && startDate && endDate
? {
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
}
: { days };
const res = await throwIfReproofRequired(
await apiFetchRaw("/api/export/health-record", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
@@ -144,38 +174,33 @@ export function HealthRecordExportPanel() {
includeCharts,
selection: { v: 2, leaves: orderLeaves(selected) },
}),
});
if (!res.ok) {
setError(
res.status === 429
? t("settings.healthRecord.errorRateLimit")
: res.status === 403
? t("settings.healthRecord.errorModuleDisabled")
: t("settings.healthRecord.errorGeneric"),
);
return;
}
const blob = await res.blob();
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `healthlog-health-record-${new Date()
.toISOString()
.slice(0, 10)}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// The route just persisted the practice name and the selection, so the
// cached `/me` payload is stale. Refresh it, or a remount would seed from
// the previous values.
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}),
);
if (!res.ok) {
throw new ReportRequestError(
res.status === 429
? t("settings.healthRecord.errorRateLimit")
: res.status === 403
? t("settings.healthRecord.errorModuleDisabled")
: t("settings.healthRecord.errorGeneric"),
);
}
const blob = await res.blob();
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `healthlog-health-record-${new Date()
.toISOString()
.slice(0, 10)}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// The route just persisted the practice name and the selection, so the
// cached `/me` payload is stale. Refresh it, or a remount would seed from
// the previous values.
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
}
return (
@@ -397,6 +422,7 @@ export function HealthRecordExportPanel() {
</p>
)}
</div>
{recentProof.dialog}
</SettingsCard>
);
}
@@ -44,6 +44,28 @@ export function isReproofRequired(err: unknown): boolean {
);
}
const REAUTH_METHODS: readonly ReauthMethod[] = [
"password",
"totp",
"passkey",
"webauthn",
];
/**
* The proofs the refusal says this account can give, or null when it names
* none. On an account with a second factor the server leaves the password out,
* so the dialog does not offer a choice that can only be refused.
*/
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
if (!(err instanceof ApiError)) return null;
const raw = err.meta?.methods;
if (!Array.isArray(raw)) return null;
const offered = raw.filter((m): m is ReauthMethod =>
REAUTH_METHODS.includes(m as ReauthMethod),
);
return offered.length > 0 ? offered : null;
}
/**
* Collect a fresh proof of a credential the account already holds, before a
* new factor is added. A passkey or security-key assertion begins at the
@@ -36,6 +36,7 @@ import { formatDate } from "@/lib/format";
import {
ExistingFactorReauthDialog,
isReproofRequired,
offeredReauthMethods,
type ExistingFactorProof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
@@ -98,8 +99,14 @@ export function SecurityKeysCard({
// options call is retried with it from the dialog.
const [reauthOpen, setReauthOpen] = useState(false);
const [reauthError, setReauthError] = useState<string | null>(null);
const reauthMethods: ReauthMethod[] = [
"password",
// An account with a second factor confirms with that factor or a passkey;
// the server refuses a password there and names what it will take.
const hasSecondFactor = totpEnabled || keys.length > 0;
const [offeredMethods, setOfferedMethods] = useState<ReauthMethod[] | null>(
null,
);
const reauthMethods: ReauthMethod[] = offeredMethods ?? [
...(hasSecondFactor ? [] : (["password"] as const)),
...(totpEnabled ? (["totp"] as const) : []),
"passkey",
...(keys.length > 0 ? (["webauthn"] as const) : []),
@@ -126,6 +133,7 @@ export function SecurityKeysCard({
},
onError: (err, proof) => {
if (isReproofRequired(err)) {
setOfferedMethods(offeredReauthMethods(err));
setError(null);
setReauthError(null);
setReauthOpen(true);
@@ -36,7 +36,9 @@ import { RecoveryCodesPanel } from "./recovery-codes-panel";
import {
ExistingFactorReauthDialog,
isReproofRequired,
offeredReauthMethods,
type ExistingFactorProof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
interface SetupData {
@@ -128,6 +130,11 @@ export function TotpCard({
// call is retried with it from the dialog.
const [reauthOpen, setReauthOpen] = useState(false);
const [reauthError, setReauthError] = useState<string | null>(null);
const [reauthMethods, setReauthMethods] = useState<ReauthMethod[]>([
"password",
"passkey",
"webauthn",
]);
const beginSetup = useMutation({
mutationFn: async (proof?: ExistingFactorProof) => {
@@ -148,6 +155,8 @@ export function TotpCard({
},
onError: (err, proof) => {
if (isReproofRequired(err)) {
const offered = offeredReauthMethods(err);
if (offered) setReauthMethods(offered);
setError(null);
setReauthError(null);
setReauthOpen(true);
@@ -278,7 +287,7 @@ export function TotpCard({
setReauthOpen(open);
if (!open) setReauthError(null);
}}
methods={["password", "passkey", "webauthn"]}
methods={reauthMethods}
pending={beginSetup.isPending}
error={reauthError}
onProof={(proof) => beginSetup.mutate(proof)}
@@ -16,6 +16,7 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
@@ -23,8 +24,22 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
import { useRevokeGrant } from "@/lib/queries/use-account-grants";
import { cn } from "@/lib/utils";
/** An accepted grant sign-out-everywhere left standing (`grantsKept`). */
interface KeptGrant {
id: string;
account: { id: string; username: string; displayName: string | null };
access: "READ" | "WRITE" | "MANAGE";
}
const ACCESS_LABEL_KEY: Record<KeptGrant["access"], string> = {
READ: "recordSharing.invite.accessReadLabel",
WRITE: "recordSharing.invite.accessWriteLabel",
MANAGE: "recordSharing.invite.accessManageLabel",
};
interface SessionRow {
id: string;
device: string;
@@ -54,6 +69,12 @@ export function SecuritySessionsCard({
// state; nothing is persisted across reloads.
const [open, setOpen] = useState(false);
const regionId = useId();
// The people who can still read this record after "sign out everywhere".
// Their access is not ended by it (a carer who still needs the record would
// be cut off by a button labelled "sign out"), so they are shown here with
// a one-click end instead. Held only until the card unmounts.
const [keptGrants, setKeptGrants] = useState<KeptGrant[]>([]);
const revokeGrant = useRevokeGrant();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.sessions(),
@@ -75,17 +96,32 @@ export function SecuritySessionsCard({
const revokeOthers = useMutation({
mutationFn: () =>
apiDelete<{ sessionsRevoked: number }>(
apiDelete<{
sessionsRevoked: number;
pendingInvitesRevoked?: number;
grantsKept?: KeptGrant[];
}>(
endShareLinks
? "/api/auth/me/sessions"
: "/api/auth/me/sessions?keepShareLinks=1",
),
onSuccess: (res) => {
const invites = res?.pendingInvitesRevoked ?? 0;
setStatus(
t("settings.security.signOutEverywhereDone", {
count: res?.sessionsRevoked ?? 0,
}),
[
t("settings.security.signOutEverywhereDone", {
count: res?.sessionsRevoked ?? 0,
}),
invites > 0
? t("settings.security.signOutEverywhereInvitesDone", {
count: invites,
})
: null,
]
.filter(Boolean)
.join(" "),
);
setKeptGrants(res?.grantsKept ?? []);
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() });
void queryClient.invalidateQueries({
queryKey: queryKeys.securityActivity(),
@@ -206,17 +242,24 @@ export function SecuritySessionsCard({
onConfirm={() => revokeOthers.mutate()}
pending={revokeOthers.isPending}
extra={
<label className="flex items-start gap-2 text-sm">
<Checkbox
data-testid="sign-out-everywhere-share-links"
checked={endShareLinks}
onCheckedChange={(next) => setEndShareLinks(next === true)}
className="mt-0.5"
/>
<span>
{t("settings.security.signOutEverywhereShareLinks")}
</span>
</label>
<div className="space-y-3">
<label className="flex items-start gap-2 text-sm">
<Checkbox
data-testid="sign-out-everywhere-share-links"
checked={endShareLinks}
onCheckedChange={(next) =>
setEndShareLinks(next === true)
}
className="mt-0.5"
/>
<span>
{t("settings.security.signOutEverywhereShareLinks")}
</span>
</label>
<p className="text-sm">
{t("settings.security.signOutEverywhereInvites")}
</p>
</div>
}
/>
</SettingsCardActions>
@@ -227,6 +270,57 @@ export function SecuritySessionsCard({
{status}
</p>
)}
{keptGrants.length > 0 && (
<div
data-slot="sign-out-everywhere-grants-kept"
className="space-y-2 border-t pt-4"
>
<p className="text-sm font-medium">
{t("settings.security.grantsKeptTitle")}
</p>
<p className="text-muted-foreground text-sm">
{t("settings.security.grantsKeptBody")}
</p>
<ul className="divide-y">
{keptGrants.map((grant) => (
<li
key={grant.id}
className="flex items-center justify-between gap-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm">
{grant.account.displayName ?? grant.account.username}
</p>
<p className="text-muted-foreground text-xs">
{t(ACCESS_LABEL_KEY[grant.access])}
</p>
</div>
<Button
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
disabled={revokeGrant.isPending}
onClick={() =>
revokeGrant.mutate(grant.id, {
onSuccess: () =>
setKeptGrants((current) =>
current.filter((g) => g.id !== grant.id),
),
onError: () =>
setStatus(
t("recordSharing.actionError.revokeFailed"),
),
})
}
>
{t("recordSharing.given.revoke")}
</Button>
</li>
))}
</ul>
</div>
)}
</div>
</SettingsCard>
);