feat(profiles): manage PINs from settings

Settings had only an ask-on-start toggle and a button that bounced to the
picker, so there was nowhere to actually turn a PIN on or off -- the one
part of this feature that genuinely is configuration rather than a
front-door action.

Scoped to the active profile on purpose. Letting a signed-in session set
a PIN on someone else's profile is an escalation path with no real use:
a PIN-less profile could be locked by whoever is standing at the device,
and its owner pushed down the password route to get back into their own
account. Other profiles are listed read-only so a parent can see which
are protected; adding and removing them stays on the picker, which
settings now deep-links into with ?manage=1 rather than growing a second
copy of the tile list.

Form validation is extracted to pinForm.ts and unit-tested -- shape only,
so the form can say what is wrong before submitting. Rust validates again
and remains the only thing that ever compares a PIN.
This commit is contained in:
2026-08-30 19:42:35 +02:00
parent f902caa07f
commit 27a995f877
5 changed files with 447 additions and 9 deletions
+85
View File
@@ -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";
}
}