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:
@@ -0,0 +1,258 @@
|
||||
<!--
|
||||
PIN management for the profile you are signed in as.
|
||||
|
||||
Deliberately scoped to the *active* profile. Letting a signed-in session set a
|
||||
PIN on someone else's profile would be an escalation path with no real use —
|
||||
a PIN-less profile could be locked by anyone standing at the device, and the
|
||||
owner would be pushed down the password route to get back into their own
|
||||
account. Other profiles are listed read-only so a parent can see at a glance
|
||||
which are protected; adding and removing them lives on the picker.
|
||||
|
||||
Nothing here compares a PIN or counts an attempt. The form validates shape so
|
||||
it can say what is wrong before submitting; Rust validates again and is the
|
||||
only thing that ever verifies one.
|
||||
|
||||
TRACES: UR-082, UR-083 | DR-268, DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import {
|
||||
validatePinForm,
|
||||
pinActionLabel,
|
||||
PIN_MAX_LENGTH,
|
||||
type PinIntent,
|
||||
} from "$lib/utils/pinForm";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ProfileSecurity");
|
||||
|
||||
let intent = $state<PinIntent | null>(null);
|
||||
let currentPin = $state("");
|
||||
let newPin = $state("");
|
||||
let confirmPin = $state("");
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let notice = $state<string | null>(null);
|
||||
|
||||
const active = $derived($profiles.profiles.find((p) => p.isActive) ?? null);
|
||||
const others = $derived($profiles.profiles.filter((p) => !p.isActive));
|
||||
const hasPin = $derived(active?.unlockMethod === "pin");
|
||||
|
||||
const validationError = $derived(
|
||||
intent === null ? null : validatePinForm({ intent, hasPin, currentPin, newPin, confirmPin }),
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
void profiles.refresh();
|
||||
});
|
||||
|
||||
function begin(next: PinIntent) {
|
||||
intent = next;
|
||||
currentPin = "";
|
||||
newPin = "";
|
||||
confirmPin = "";
|
||||
error = null;
|
||||
notice = null;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
intent = null;
|
||||
currentPin = "";
|
||||
newPin = "";
|
||||
confirmPin = "";
|
||||
error = null;
|
||||
}
|
||||
|
||||
async function submit(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!active || intent === null || validationError) return;
|
||||
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
await profiles.setPin(
|
||||
active.userId,
|
||||
hasPin ? currentPin : null,
|
||||
intent === "clear" ? null : newPin,
|
||||
);
|
||||
notice =
|
||||
intent === "clear" ? "PIN turned off. This profile now opens with one tap." : "PIN saved.";
|
||||
cancel();
|
||||
} catch (e) {
|
||||
log.error("Could not update PIN:", e);
|
||||
error = e instanceof Error ? e.message : "Could not update the PIN";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
{#if !active}
|
||||
<p class="text-sm text-gray-400">No profile is signed in.</p>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
PIN for {active.username}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
{#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}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 text-xs px-2 py-1 rounded-full {hasPin
|
||||
? 'bg-green-900/60 text-green-300'
|
||||
: 'bg-gray-700 text-gray-300'}"
|
||||
>
|
||||
{hasPin ? "On" : "Off"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if notice}
|
||||
<p class="text-sm text-green-400">{notice}</p>
|
||||
{/if}
|
||||
|
||||
{#if intent === null}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#if hasPin}
|
||||
<button
|
||||
onclick={() => begin("change")}
|
||||
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Change PIN
|
||||
</button>
|
||||
<button
|
||||
onclick={() => begin("clear")}
|
||||
class="px-4 py-2 border border-gray-600 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Turn off PIN
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => begin("set")}
|
||||
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Set a PIN
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={submit} class="space-y-4 max-w-sm">
|
||||
{#if hasPin}
|
||||
<div>
|
||||
<label for="current-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Current PIN
|
||||
</label>
|
||||
<input
|
||||
id="current-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength={PIN_MAX_LENGTH}
|
||||
bind:value={currentPin}
|
||||
disabled={busy}
|
||||
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if intent !== "clear"}
|
||||
<div>
|
||||
<label for="new-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
New PIN (4–8 digits)
|
||||
</label>
|
||||
<input
|
||||
id="new-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength={PIN_MAX_LENGTH}
|
||||
bind:value={newPin}
|
||||
disabled={busy}
|
||||
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Confirm new PIN
|
||||
</label>
|
||||
<input
|
||||
id="confirm-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength={PIN_MAX_LENGTH}
|
||||
bind:value={confirmPin}
|
||||
disabled={busy}
|
||||
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400">
|
||||
Turning the PIN off means anyone using this device can switch to
|
||||
{active.username} in one tap.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
{:else if validationError && (currentPin || newPin || confirmPin)}
|
||||
<p class="text-sm text-amber-400">{validationError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancel}
|
||||
disabled={busy}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-600 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || validationError !== null}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
{pinActionLabel(intent)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if others.length > 0}
|
||||
<div class="border-t border-gray-700 pt-5">
|
||||
<h3 class="text-sm font-semibold text-white mb-3">Other profiles on this device</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each others as profile (profile.userId)}
|
||||
<li class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-300">{profile.username}</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{profile.unlockMethod === "pin" ? "PIN" : "No PIN"}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="text-xs text-gray-500 mt-3">
|
||||
A profile's PIN can only be changed from that profile. Sign in as them to set one.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border-t border-gray-700 pt-5">
|
||||
<button
|
||||
onclick={() => goto("/profiles?manage=1")}
|
||||
class="text-sm text-[var(--color-jellyfin)] hover:underline"
|
||||
>
|
||||
Add or remove profiles
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validatePinForm, pinActionLabel, type PinFormState } from "./pinForm";
|
||||
|
||||
function form(overrides: Partial<PinFormState> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import { isAuthenticated } from "$lib/stores/auth";
|
||||
import { navigateBack } from "$lib/utils/navigation";
|
||||
@@ -40,6 +41,10 @@
|
||||
const ordered = $derived(orderProfiles($profiles.profiles));
|
||||
|
||||
onMount(() => {
|
||||
// Settings deep-links here for add/remove rather than duplicating the tile
|
||||
// grid: the tiles are the natural place to act on a profile, and two copies
|
||||
// of that list would drift.
|
||||
if ($page.url.searchParams.get("manage") === "1") mode = "manage";
|
||||
void profiles.refresh();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057, UR-076 | DR-030, DR-048, DR-077, DR-086, DR-132, DR-209 -->
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import ProfileSecuritySettings from "$lib/components/settings/ProfileSecuritySettings.svelte";
|
||||
import type {
|
||||
AudioSettings,
|
||||
CacheConfig,
|
||||
@@ -1022,16 +1022,12 @@
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-700 pt-5">
|
||||
<p class="text-sm text-gray-400 mb-3">
|
||||
<ProfileSecuritySettings />
|
||||
<p class="text-xs text-gray-500 mt-4">
|
||||
A PIN controls who can switch to an account on this device. What each account is
|
||||
allowed to watch is set on the Jellyfin server, not here.
|
||||
allowed to watch is set on the Jellyfin server, not here. Switching profile lives in
|
||||
the account menu.
|
||||
</p>
|
||||
<button
|
||||
onclick={() => goto("/profiles")}
|
||||
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Switch or manage profiles
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user