mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The address is what single sign-on links an existing account by, and changing it took the password or a session younger than five minutes on every account. A remembered browser skips the second factor at sign-in and still makes a fresh session, so the password alone could move the address of an account the second factor was meant to protect. On an account with a second factor only a second factor or passkey completed in the last five minutes counts now; the password is not checked. The refusal names it (second_factor_required in rejectedFields, or 401 profile.update.emailSecondFactorRequired when the address was all that changed), and the profile form opens the re-proof dialog with the factors the account holds, then saves again, no longer offering the password field. Accounts without a second factor keep the password or a recent sign-in. A token cannot change the address of such an account; the app does not change it.
123 lines
5.3 KiB
TypeScript
123 lines
5.3 KiB
TypeScript
/**
|
|
* Naming the profile fields a save declined.
|
|
*
|
|
* `applyProfileUpdate` (`src/lib/auth/profile-update.ts`) writes every
|
|
* field that validated and reports the rest back: `data.rejectedFields`
|
|
* on a partial success, `details.issues` when nothing could be written
|
|
* at all. Both carry the schema key (`heightCm`, `gender`, ...) and a
|
|
* machine-readable code — never a sentence a person should read.
|
|
*
|
|
* Turning that key into words is the client's job, and every screen
|
|
* that writes the profile has to do it the same way, or the same
|
|
* rejection reads differently depending on where the person happened
|
|
* to be standing. This module is that one way: the settings account
|
|
* form and the onboarding baseline step both resolve the key through
|
|
* it, each passing the labels its own inputs already carry.
|
|
*/
|
|
|
|
/** A single rejected-field entry from `applyProfileUpdate`'s wire shape
|
|
* (`details.issues` on failure, `data.rejectedFields` on a partial
|
|
* success) — `path` is the schema field name, never shown verbatim. */
|
|
export interface RejectedProfileField {
|
|
path: string;
|
|
code: string;
|
|
message?: string;
|
|
}
|
|
|
|
/**
|
|
* Maps a rejected field's schema `path` to the same i18n label already
|
|
* shown next to that input on the settings account form. The server
|
|
* only ever knows the field by its schema key — naming it in a
|
|
* person-facing sentence is the client's job, using labels that already
|
|
* exist and are already localized for that form.
|
|
*
|
|
* A screen with different labels for the same fields (the onboarding
|
|
* baseline step calls them by its own names) passes its own map to
|
|
* `describeRejectedProfileField` instead.
|
|
*/
|
|
export const PROFILE_FIELD_LABEL_KEYS: Record<string, string> = {
|
|
email: "auth.email",
|
|
heightCm: "settings.height",
|
|
dateOfBirth: "settings.dateOfBirth",
|
|
gender: "settings.gender",
|
|
fullName: "settings.identity.fullName",
|
|
insurerName: "settings.identity.insurer",
|
|
insuranceNumber: "settings.identity.insuranceNumber",
|
|
};
|
|
|
|
/**
|
|
* Renders the first rejected field's label, falling back to its raw
|
|
* schema key for a field the calling screen has no input for
|
|
* (defensive — every field a form submits is expected in its map).
|
|
*/
|
|
export function describeRejectedProfileField(
|
|
fields: RejectedProfileField[] | undefined,
|
|
t: (key: string) => string,
|
|
labelKeys: Record<string, string> = PROFILE_FIELD_LABEL_KEYS,
|
|
): string | null {
|
|
const first = fields?.[0];
|
|
if (!first) return null;
|
|
const labelKey = labelKeys[first.path];
|
|
return labelKey ? t(labelKey) : first.path;
|
|
}
|
|
|
|
/**
|
|
* The sentence a person reads under the input, keyed by the validator
|
|
* code the server sent back. The code is the only reason that crosses
|
|
* the wire — `message` is the validator's own prose and is deliberately
|
|
* never shown — so each code gets one plain sentence saying what was
|
|
* wrong with the value, in the person's language.
|
|
*
|
|
* A code with no entry here falls back to `other`, which says the field
|
|
* was not accepted without inventing a reason. New Zod codes therefore
|
|
* degrade to something honest instead of to silence.
|
|
*/
|
|
const REJECTION_REASON_KEYS: Record<string, string> = {
|
|
// Not a Zod code: the profile helper refuses an email-address change that
|
|
// has spent the account's hourly budget this way, so the rest of the save
|
|
// still lands. Without an entry it would fall back to "check it and try
|
|
// again", which is wrong — the value was fine, the timing was not.
|
|
rate_limited: "settings.profileRejection.rateLimited",
|
|
// Not Zod codes either: a new email address asks for a fresh proof, and
|
|
// these say whether none was given or the one given was wrong.
|
|
reproof_required: "settings.profileRejection.reproofRequired",
|
|
reproof_failed: "settings.profileRejection.reproofFailed",
|
|
second_factor_required: "settings.profileRejection.secondFactorRequired",
|
|
too_big: "settings.profileRejection.tooBig",
|
|
too_small: "settings.profileRejection.tooSmall",
|
|
invalid_value: "settings.profileRejection.invalidValue",
|
|
invalid_format: "settings.profileRejection.invalidFormat",
|
|
invalid_type: "settings.profileRejection.invalidType",
|
|
};
|
|
|
|
const REJECTION_FALLBACK_KEY = "settings.profileRejection.other";
|
|
|
|
/**
|
|
* Turn a rejection list into one sentence per field, keyed by the
|
|
* schema path so a form can drop each one into the slot under the
|
|
* input it belongs to.
|
|
*
|
|
* Every refused field is carried, not just the first: a submission with
|
|
* two bad values that only names one sends the person round the loop
|
|
* twice. Where a field is refused for several reasons at once the first
|
|
* one wins — the others are restatements of the same wrong value.
|
|
*
|
|
* No label map is needed: the sentence sits under the input, which
|
|
* already carries the label, and the key is the schema path so the
|
|
* calling screen can place it without a rename. A path the screen has
|
|
* no input for still gets an entry rather than being dropped, so the
|
|
* caller can decide what to do with it.
|
|
*/
|
|
export function describeRejectedProfileFields(
|
|
fields: RejectedProfileField[] | undefined,
|
|
t: (key: string, params?: Record<string, string | number>) => string,
|
|
): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const field of fields ?? []) {
|
|
if (out[field.path]) continue;
|
|
const key = REJECTION_REASON_KEYS[field.code] ?? REJECTION_FALLBACK_KEY;
|
|
out[field.path] = t(key);
|
|
}
|
|
return out;
|
|
}
|