diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9aeb0e65d..20fecd825 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23019,8 +23019,12 @@ components: a second factor in the last five minutes needs nothing more, otherwise this field must carry the current password. Without an accepted proof the address is not changed and comes back in `rejectedFields` as `reproof_required` or `reproof_failed` (or, when the address was the only field sent, 401 - `auth.reproof.required` / `auth.reproof.failed`). Attempts draw on the account's shared re-proof budget of - five per fifteen minutes. Never stored." + `auth.reproof.required` / `auth.reproof.failed`). On an account with a second factor the password is not + accepted and not checked: only a second factor or passkey completed in the last five minutes counts (at + sign-in or at POST /api/auth/reproof), and without one the address comes back as `second_factor_required` + (or 401 `profile.update.emailSecondFactorRequired` when it was the only field). A token cannot change the + address of such an account. Attempts draw on the account's shared re-proof budget of five per fifteen + minutes. Never stored." type: string maxLength: 512 description: Partial profile update. Every field is optional and an omitted field is left untouched. An explicit null @@ -31522,29 +31526,29 @@ components: onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner — practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, - profile.update.invalidBody, profile.update.nothingSaved; record_write — record_write.rate_limited; - report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, - report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, - share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, - share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, - share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, - sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, - sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only, - sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, - sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, - sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — - source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens — - tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached; vaccination — - vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, - vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, - vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — - workout.batch.invalid, workout.batch.payload_too_large, workout.batch.too_large; (unprefixed) — - about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, - health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, - json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, - modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, - private_origin_not_approved, private_origin_not_grantable, rate_limited_self, too_many_rows, - unreadable_json, upstream_invalid_json, vapid_not_configured" + profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; + record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, + report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link + — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, + share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, + share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — + sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, + sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, + sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, + sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, + sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, + source-priority.body.invalid_shape; tokens — tokens.documents.ceiling_reached, + tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid, + vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, + workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, + dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, + invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, + missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, + notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable, + rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" type: string capability: description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, diff --git a/messages/de.json b/messages/de.json index 7ccfb920c..481e1d053 100644 --- a/messages/de.json +++ b/messages/de.json @@ -126,7 +126,8 @@ "nothingSaved": "Es wurde nichts gespeichert. Korrigiere das markierte Feld und versuche es erneut.", "invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.", "emailInUse": "Diese E-Mail-Adresse wird bereits verwendet.", - "emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut." + "emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut.", + "emailSecondFactorRequired": "Bestätige mit deinem zweiten Faktor oder einem Passkey, um die E-Mail-Adresse zu ändern." } }, "auth": { @@ -5568,7 +5569,8 @@ "rateLimited": "Zu oft geändert. Warte kurz und versuche es dann erneut.", "other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen.", "reproofRequired": "Gib dein aktuelles Passwort ein, um die E-Mail-Adresse zu ändern.", - "reproofFailed": "Das Passwort stimmt nicht, die E-Mail-Adresse wurde nicht geändert." + "reproofFailed": "Das Passwort stimmt nicht, die E-Mail-Adresse wurde nicht geändert.", + "secondFactorRequired": "Bestätige mit deinem zweiten Faktor oder einem Passkey, um die E-Mail-Adresse zu ändern." }, "avatar": { "title": "Profilfoto", diff --git a/messages/en.json b/messages/en.json index eeedef816..245380e7e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -126,7 +126,8 @@ "nothingSaved": "Nothing was saved. Check the highlighted field and try again.", "invalidBody": "The profile update could not be read.", "emailInUse": "This email address is already in use.", - "emailRateLimited": "Too many email-address changes. Try again later." + "emailRateLimited": "Too many email-address changes. Try again later.", + "emailSecondFactorRequired": "Confirm with your second factor or a passkey to change the email address." } }, "auth": { @@ -5568,7 +5569,8 @@ "rateLimited": "Changed too often. Wait a little and try again.", "other": "This value was not accepted. Check it and try again.", "reproofRequired": "Enter your current password to change the email address.", - "reproofFailed": "That password is not correct, so the email address was not changed." + "reproofFailed": "That password is not correct, so the email address was not changed.", + "secondFactorRequired": "Confirm with your second factor or a passkey to change the email address." }, "avatar": { "title": "Profile photo", diff --git a/messages/es.json b/messages/es.json index 5856435ad..f7120439c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -126,7 +126,8 @@ "nothingSaved": "No se guardó nada. Revisa el campo marcado e inténtalo de nuevo.", "invalidBody": "No se pudo leer la actualización del perfil.", "emailInUse": "Esta dirección de correo ya está en uso.", - "emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde." + "emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde.", + "emailSecondFactorRequired": "Confirma con tu segundo factor o una clave de acceso para cambiar el correo electrónico." } }, "auth": { @@ -5568,7 +5569,8 @@ "rateLimited": "Se ha cambiado demasiadas veces. Espera un poco e inténtalo de nuevo.", "other": "No se aceptó este valor. Revísalo e inténtalo de nuevo.", "reproofRequired": "Introduce tu contraseña actual para cambiar la dirección de correo.", - "reproofFailed": "La contraseña no es correcta, así que la dirección de correo no se ha cambiado." + "reproofFailed": "La contraseña no es correcta, así que la dirección de correo no se ha cambiado.", + "secondFactorRequired": "Confirma con tu segundo factor o una clave de acceso para cambiar el correo electrónico." }, "avatar": { "title": "Foto de perfil", diff --git a/messages/fr.json b/messages/fr.json index 50abc764c..bab109f9c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -126,7 +126,8 @@ "nothingSaved": "Rien n'a été enregistré. Corrigez le champ signalé et réessayez.", "invalidBody": "La mise à jour du profil n'a pas pu être lue.", "emailInUse": "Cette adresse e-mail est déjà utilisée.", - "emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard." + "emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard.", + "emailSecondFactorRequired": "Confirmez avec votre second facteur ou une clé d'accès pour changer l'adresse e-mail." } }, "auth": { @@ -5568,7 +5569,8 @@ "rateLimited": "Modifié trop souvent. Patientez un peu et réessayez.", "other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez.", "reproofRequired": "Saisissez votre mot de passe actuel pour changer l'adresse e-mail.", - "reproofFailed": "Ce mot de passe est incorrect, l'adresse e-mail n'a donc pas été modifiée." + "reproofFailed": "Ce mot de passe est incorrect, l'adresse e-mail n'a donc pas été modifiée.", + "secondFactorRequired": "Confirmez avec votre second facteur ou une clé d'accès pour changer l'adresse e-mail." }, "avatar": { "title": "Photo de profil", diff --git a/messages/it.json b/messages/it.json index 941033101..061bf3ab3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -126,7 +126,8 @@ "nothingSaved": "Non è stato salvato nulla. Controlla il campo evidenziato e riprova.", "invalidBody": "Impossibile leggere l'aggiornamento del profilo.", "emailInUse": "Questo indirizzo email è già in uso.", - "emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi." + "emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi.", + "emailSecondFactorRequired": "Conferma con il tuo secondo fattore o una passkey per cambiare l'indirizzo email." } }, "auth": { @@ -5568,7 +5569,8 @@ "rateLimited": "Modificato troppe volte. Aspetta un momento e riprova.", "other": "Questo valore non è stato accettato. Controllalo e riprova.", "reproofRequired": "Inserisci la password attuale per cambiare l'indirizzo email.", - "reproofFailed": "La password non è corretta, quindi l'indirizzo email non è stato cambiato." + "reproofFailed": "La password non è corretta, quindi l'indirizzo email non è stato cambiato.", + "secondFactorRequired": "Conferma con il tuo secondo fattore o una passkey per cambiare l'indirizzo email." }, "avatar": { "title": "Foto del profilo", diff --git a/messages/ko.json b/messages/ko.json index f1349726d..1bcd9ab1c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -126,7 +126,8 @@ "nothingSaved": "저장된 내용이 없어요. 표시된 항목을 확인하고 다시 시도해 주세요.", "invalidBody": "프로필 변경 내용을 읽지 못했어요.", "emailInUse": "이미 사용 중인 이메일 주소예요.", - "emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요." + "emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요.", + "emailSecondFactorRequired": "이메일 주소를 바꾸려면 2단계 인증 수단이나 패스키로 확인해 주세요." } }, "auth": { @@ -5568,7 +5569,8 @@ "rateLimited": "너무 자주 바꿨어요. 잠시 후 다시 시도해 주세요.", "other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요.", "reproofRequired": "이메일 주소를 변경하려면 현재 비밀번호를 입력하세요.", - "reproofFailed": "비밀번호가 올바르지 않아 이메일 주소가 변경되지 않았습니다." + "reproofFailed": "비밀번호가 올바르지 않아 이메일 주소가 변경되지 않았습니다.", + "secondFactorRequired": "이메일 주소를 바꾸려면 2단계 인증 수단이나 패스키로 확인해 주세요." }, "avatar": { "title": "프로필 사진", diff --git a/messages/pl.json b/messages/pl.json index b71f77fd3..1fe698c79 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -126,7 +126,8 @@ "nothingSaved": "Nic nie zostało zapisane. Sprawdź oznaczone pole i spróbuj ponownie.", "invalidBody": "Nie udało się odczytać aktualizacji profilu.", "emailInUse": "Ten adres e-mail jest już używany.", - "emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później." + "emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później.", + "emailSecondFactorRequired": "Potwierdź drugim składnikiem lub kluczem dostępu, aby zmienić adres e-mail." } }, "auth": { @@ -5568,7 +5569,8 @@ "rateLimited": "Zmieniane zbyt często. Odczekaj chwilę i spróbuj ponownie.", "other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie.", "reproofRequired": "Wpisz obecne hasło, aby zmienić adres e-mail.", - "reproofFailed": "Hasło jest nieprawidłowe, więc adres e-mail nie został zmieniony." + "reproofFailed": "Hasło jest nieprawidłowe, więc adres e-mail nie został zmieniony.", + "secondFactorRequired": "Potwierdź drugim składnikiem lub kluczem dostępu, aby zmienić adres e-mail." }, "avatar": { "title": "Zdjęcie profilowe", diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 534b496c2..359be334f 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -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(EMPTY_HEIGHT_DRAFT); const [dateOfBirth, setDateOfBirth] = useState(""); const [gender, setGender] = useState(""); @@ -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 { + 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() { -
+ void handleSaveProfile(event)} + className="space-y-4" + >
@@ -386,7 +444,7 @@ export function AccountSection() { aria-describedby={fieldErrors.email ? "email-error" : undefined} /> - {emailChanged && ( + {emailChanged && !emailNeedsFactor && (