/** * Validation for the PIN settings form. * * Pure, and separate from the component, so the rules can be tested without * mounting anything — the same reason `episodeStrip.ts` exists. * * This is *not* the security boundary. Rust validates the PIN shape again in * `pin::validate_pin` and is the only thing that ever compares one. What lives * here is the difference between a form that tells you what is wrong before you * submit and one that bounces you off a backend error. * * TRACES: UR-083 | DR-268, DR-276 */ /** Mirrors `pin::MAX_ATTEMPTS`-adjacent shape rules in Rust. */ export const PIN_MIN_LENGTH = 4; export const PIN_MAX_LENGTH = 8; export type PinIntent = "set" | "change" | "clear"; export interface PinFormState { intent: PinIntent; /** Whether the profile currently has a PIN — decides if `currentPin` is required. */ hasPin: boolean; currentPin: string; newPin: string; confirmPin: string; } /** * The reason this form cannot be submitted yet, or `null` when it can. * * Returns one message rather than a list: a PIN form has at most one thing * wrong with it worth saying, and stacking "too short" under "doesn't match" * reads as nagging. * * TRACES: UR-083 | DR-276 */ export function validatePinForm(state: PinFormState): string | null { if (state.hasPin && state.currentPin.length === 0) { return "Enter your current PIN."; } if (state.intent === "clear") { return null; } if (state.newPin.length === 0) { return "Choose a PIN."; } if (!/^[0-9]+$/.test(state.newPin)) { return "A PIN can only contain digits."; } if (state.newPin.length < PIN_MIN_LENGTH || state.newPin.length > PIN_MAX_LENGTH) { return `A PIN must be ${PIN_MIN_LENGTH}–${PIN_MAX_LENGTH} digits.`; } if (state.confirmPin.length === 0) { return "Confirm your new PIN."; } if (state.newPin !== state.confirmPin) { return "The two PINs do not match."; } if (state.intent === "change" && state.currentPin === state.newPin) { return "The new PIN is the same as the current one."; } return null; } /** * What the button should say. Derived rather than hardcoded per branch so the * three intents cannot drift apart in the markup. * * TRACES: UR-083 | DR-276 */ export function pinActionLabel(intent: PinIntent): string { switch (intent) { case "set": return "Set PIN"; case "change": return "Change PIN"; case "clear": return "Turn off PIN"; } }