+ {#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}
+
+ 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 @@