// Friendly copy for WebAuthn ceremony failures — pure and DOM-free so it's // unit-testable in Node (home/test/ceremony-errors.test.mjs) and shared by the // home pages (home.js, enroll.js, restore.js). // // The browser's ceremony errors are famously opaque (NotAllowedError is the // catch-all for "cancelled", "timed out", "blocked", and "no credential"), so // members read a specific, human sentence instead of an exception name. The // technical detail (error name + the phase tag backup-core.js attaches) is // deliberately kept — parenthesised after the sentence — because it's the only // signal we have when debugging a member's report. // friendlyCeremonyError maps a ceremony failure to member-facing copy. // `ceremony` is the intent the user pressed: "signin" (credentials.get) or // "create" (credentials.create). Returns { message, detail }: message is the // friendly sentence, detail the technical crumb (e.g. "NotAllowedError [create]"). export function friendlyCeremonyError(err, ceremony) { const name = (err && err.name) || ""; const phase = err && err.phase ? " [" + err.phase + "]" : ""; const detail = (name || "Error") + phase; const creating = ceremony === "create"; switch (name) { case "NotAllowedError": // Cancelled, timed out, or the browser refused — indistinguishable by // design, so the copy covers both the deliberate and the accidental read. return { message: creating ? "Creating your sign-in was cancelled. If you didn't cancel, try again." : "Sign in was cancelled. If you didn't cancel, try again.", detail, }; case "InvalidStateError": // Duplicate registration: the device already holds a matching sign-in. if (creating) return { message: "That's already set up. Try signing in instead.", detail }; break; case "ConstraintError": case "NotSupportedError": // In practice: user verification is required but the device has nothing // to verify with — no screen lock set up. return { message: "This device needs a screen lock, PIN, fingerprint, or face unlock before it can sign in this way.", detail, }; case "SecurityError": // RP id / origin mismatch — a deployment problem, not the member's. return { message: "Sign-in isn't configured for this website address.", detail }; case "AbortError": return { message: "Sign-in was interrupted. Try again.", detail }; } // Anything else (network errors, our own thrown Errors) already carries a // human message — pass it through under the same shape. return { message: (err && err.message) || String(err), detail }; } // formatCeremonyError composes the one-line status string every caller renders: // the friendly sentence plus the kept-on-purpose technical detail. export function formatCeremonyError(err, ceremony) { const { message, detail } = friendlyCeremonyError(err, ceremony); return message + " (" + detail + ")"; }