diff --git a/src/lib/components/settings/ProfileSecuritySettings.svelte b/src/lib/components/settings/ProfileSecuritySettings.svelte new file mode 100644 index 00000000..a54e4f77 --- /dev/null +++ b/src/lib/components/settings/ProfileSecuritySettings.svelte @@ -0,0 +1,258 @@ + + + +
+ {#if !active} +

No profile is signed in.

+ {:else} +
+
+

+ PIN for {active.username} +

+

+ {#if hasPin} + This profile asks for a PIN before anyone can switch to it. + {:else} + This profile opens with one tap — right for a child's account, and what you want unless + there is something to keep out. + {/if} +

+
+ + {hasPin ? "On" : "Off"} + +
+ + {#if notice} +

{notice}

+ {/if} + + {#if intent === null} +
+ {#if hasPin} + + + {:else} + + {/if} +
+ {:else} +
+ {#if hasPin} +
+ + +
+ {/if} + + {#if intent !== "clear"} +
+ + +
+
+ + +
+ {:else} +

+ Turning the PIN off means anyone using this device can switch to + {active.username} in one tap. +

+ {/if} + + {#if error} +
+ {error} +
+ {:else if validationError && (currentPin || newPin || confirmPin)} +

{validationError}

+ {/if} + +
+ + +
+
+ {/if} + + {#if others.length > 0} +
+

Other profiles on this device

+ +

+ A profile's PIN can only be changed from that profile. Sign in as them to set one. +

+
+ {/if} + +
+ +
+ {/if} +
diff --git a/src/lib/utils/pinForm.test.ts b/src/lib/utils/pinForm.test.ts new file mode 100644 index 00000000..67cddf56 --- /dev/null +++ b/src/lib/utils/pinForm.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { validatePinForm, pinActionLabel, type PinFormState } from "./pinForm"; + +function form(overrides: Partial = {}): PinFormState { + return { + intent: "set", + hasPin: false, + currentPin: "", + newPin: "", + confirmPin: "", + ...overrides, + }; +} + +describe("validatePinForm", () => { + it("accepts a well-formed new PIN", () => { + expect(validatePinForm(form({ newPin: "1234", confirmPin: "1234" }))).toBeNull(); + }); + + it("requires the current PIN whenever one is set", () => { + expect( + validatePinForm(form({ intent: "change", hasPin: true, newPin: "5678", confirmPin: "5678" })), + ).toBe("Enter your current PIN."); + }); + + it("does not ask for a current PIN when none is set", () => { + expect(validatePinForm(form({ hasPin: false, newPin: "1234", confirmPin: "1234" }))).toBeNull(); + }); + + it("rejects non-digits", () => { + expect(validatePinForm(form({ newPin: "12a4", confirmPin: "12a4" }))).toBe( + "A PIN can only contain digits.", + ); + }); + + it("enforces the length range at both ends", () => { + expect(validatePinForm(form({ newPin: "123", confirmPin: "123" }))).toBe( + "A PIN must be 4–8 digits.", + ); + expect(validatePinForm(form({ newPin: "123456789", confirmPin: "123456789" }))).toBe( + "A PIN must be 4–8 digits.", + ); + }); + + it("catches a mistyped confirmation", () => { + expect(validatePinForm(form({ newPin: "1234", confirmPin: "1235" }))).toBe( + "The two PINs do not match.", + ); + }); + + it("asks for confirmation before comparing", () => { + expect(validatePinForm(form({ newPin: "1234", confirmPin: "" }))).toBe("Confirm your new PIN."); + }); + + it("rejects a change that changes nothing", () => { + expect( + validatePinForm( + form({ + intent: "change", + hasPin: true, + currentPin: "1234", + newPin: "1234", + confirmPin: "1234", + }), + ), + ).toBe("The new PIN is the same as the current one."); + }); + + it("needs only the current PIN to turn one off", () => { + expect(validatePinForm(form({ intent: "clear", hasPin: true, currentPin: "1234" }))).toBeNull(); + }); + + it("will not turn off a PIN without the current one", () => { + expect(validatePinForm(form({ intent: "clear", hasPin: true, currentPin: "" }))).toBe( + "Enter your current PIN.", + ); + }); + + it("reports one problem at a time, most blocking first", () => { + // Both the current PIN is missing *and* the new one is too short; the + // current-PIN prompt is the one that comes back. + expect( + validatePinForm(form({ intent: "change", hasPin: true, newPin: "1", confirmPin: "2" })), + ).toBe("Enter your current PIN."); + }); +}); + +describe("pinActionLabel", () => { + it("names each intent", () => { + expect(pinActionLabel("set")).toBe("Set PIN"); + expect(pinActionLabel("change")).toBe("Change PIN"); + expect(pinActionLabel("clear")).toBe("Turn off PIN"); + }); +}); diff --git a/src/lib/utils/pinForm.ts b/src/lib/utils/pinForm.ts new file mode 100644 index 00000000..fd660f33 --- /dev/null +++ b/src/lib/utils/pinForm.ts @@ -0,0 +1,85 @@ +/** + * 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"; + } +} diff --git a/src/routes/profiles/+page.svelte b/src/routes/profiles/+page.svelte index 77f80bd5..8fb241ea 100644 --- a/src/routes/profiles/+page.svelte +++ b/src/routes/profiles/+page.svelte @@ -12,6 +12,7 @@