feat(profiles): multi-user profiles with PIN switching

A shared device can hold several accounts from the same server and switch
between them in a couple of taps. A profile can be locked behind a 4-8
digit PIN; one without a PIN is one tap away. Forgetting a PIN falls
through to the account's own Jellyfin password, so there is no reset flow
and no recovery secret to store.

Opt-in by construction: a single account with no PIN starts, plays and
downloads exactly as before, and never sees a picker.

Two decisions worth keeping:

- Switching is not logging out. auth_logout invalidates the token
  server-side, which is precisely what a switch must not do, or every
  switch back would cost a password. The switch runs as a plan
  (profiles/switch.rs) so the teardown *ordering* is unit-testable with
  no player and no server -- a straggler reporting after the active user
  flips would attribute one account's viewing to another, silently.

- The PIN gates switching, not the token at rest. Wrapping each token
  with its PIN would leave a locked profile unable to resume its own
  downloads or drain its own sync queue until somebody typed the code,
  which on a device that reboots nightly costs more than it defends
  against a four-digit secret. auth_initialize does refuse to restore a
  PIN-protected session, so the gate is on the session rather than on
  which screen is shown.

"Child account" is not modelled anywhere -- a child's profile is simply
one with no PIN. The frontend renders an opaque unlockMethod and never
compares a PIN, counts an attempt or infers a role.

Migration 024 adds user_pins, user_item_visibility, user_libraries and
download_grants, and backfills the existing user so an upgrade does not
blank its library. The visibility and grant tables are the schema half of
the cache-scoping and shared-download work; the read-path enforcement is
still to come (see docs/specs/multi-user-profiles.md).
This commit is contained in:
2026-08-30 19:03:59 +02:00
parent 8a04a6fad0
commit da762da55d
22 changed files with 3392 additions and 5 deletions
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import { orderProfiles, initialsFor, tileColour, lockoutMessage } from "./profileTiles";
import type { Profile } from "$lib/api/bindings";
function profile(overrides: Partial<Profile> = {}): Profile {
return {
userId: "u1",
username: "Dad",
serverId: "s1",
avatarTag: null,
unlockMethod: "none",
lastUsedAt: null,
isActive: false,
...overrides,
};
}
describe("orderProfiles", () => {
it("puts the most recently used profile first", () => {
const ordered = orderProfiles([
profile({ userId: "a", lastUsedAt: "2026-01-01T00:00:00Z" }),
profile({ userId: "b", lastUsedAt: "2026-03-01T00:00:00Z" }),
]);
expect(ordered.map((p) => p.userId)).toEqual(["b", "a"]);
});
it("sorts never-used profiles after used ones, then by name", () => {
const ordered = orderProfiles([
profile({ userId: "z", username: "Zoe", lastUsedAt: null }),
profile({ userId: "a", username: "Ann", lastUsedAt: null }),
profile({ userId: "u", username: "Used", lastUsedAt: "2026-01-01T00:00:00Z" }),
]);
expect(ordered.map((p) => p.username)).toEqual(["Used", "Ann", "Zoe"]);
});
it("does not mutate its input", () => {
const input = [
profile({ userId: "a", lastUsedAt: "2026-01-01T00:00:00Z" }),
profile({ userId: "b", lastUsedAt: "2026-03-01T00:00:00Z" }),
];
orderProfiles(input);
expect(input.map((p) => p.userId)).toEqual(["a", "b"]);
});
});
describe("initialsFor", () => {
it("takes two letters from a single name", () => {
expect(initialsFor("Dad")).toBe("DA");
});
it("takes first and last initials from a multi-part name", () => {
expect(initialsFor("Anna Marie Smith")).toBe("AS");
});
it("splits on the separators usernames actually use", () => {
expect(initialsFor("anna_smith")).toBe("AS");
expect(initialsFor("anna.smith")).toBe("AS");
expect(initialsFor("anna-smith")).toBe("AS");
});
it("survives an empty or whitespace name", () => {
expect(initialsFor("")).toBe("?");
expect(initialsFor(" ")).toBe("?");
});
});
describe("tileColour", () => {
it("is stable for the same user", () => {
expect(tileColour("user-123")).toBe(tileColour("user-123"));
});
it("is derived from the id, not the name, so renaming keeps the colour", () => {
// Same id, different display names — the caller only passes the id, which is
// the point: a rename cannot move someone's tile colour.
expect(tileColour("user-123")).toBe(tileColour("user-123"));
expect(tileColour("user-456")).not.toBe("");
});
});
describe("lockoutMessage", () => {
const now = new Date("2026-01-01T00:00:00Z");
it("rounds up to whole minutes", () => {
expect(lockoutMessage("2026-01-01T00:02:30Z", now)).toBe("Try again in 3 minutes");
});
it("phrases under a minute without a number", () => {
expect(lockoutMessage("2026-01-01T00:00:30Z", now)).toBe("Try again in less than a minute");
});
it("treats an elapsed lockout as over", () => {
expect(lockoutMessage("2025-12-31T23:59:00Z", now)).toBe("Try again now");
});
it("does not render NaN when the timestamp is unusable", () => {
expect(lockoutMessage("not-a-date", now)).toBe("Try again now");
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Presentation helpers for the profile picker.
*
* Pure, and separate from the component, because this is the half worth testing
* and a component cannot be unit-tested without mounting it.
*
* Note what is *not* here: nothing decides whether a profile is locked, whether
* a PIN is correct, or how many attempts remain. Those come from the backend as
* an opaque `unlockMethod` and an `UnlockOutcome`. A child's profile is simply
* one with no PIN — the app models no roles and infers no ages.
*
* TRACES: UR-082, UR-083 | DR-276
*/
import type { Profile } from "$lib/api/bindings";
/**
* Order tiles as people expect to find them: whoever used the device last is
* leftmost, and profiles that have never been used sort after those that have.
*
* TRACES: UR-082 | DR-276
*/
export function orderProfiles(profiles: Profile[]): Profile[] {
return [...profiles].sort((a, b) => {
if (a.lastUsedAt && b.lastUsedAt) return b.lastUsedAt.localeCompare(a.lastUsedAt);
if (a.lastUsedAt) return -1;
if (b.lastUsedAt) return 1;
return a.username.localeCompare(b.username);
});
}
/**
* Initials for a tile with no avatar. At most two letters, because three fills
* a circle badly at tile size.
*
* TRACES: UR-082 | DR-276
*/
export function initialsFor(username: string): string {
const words = username
.trim()
.split(/[\s._-]+/)
.filter(Boolean);
if (words.length === 0) return "?";
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
}
/**
* A stable tile colour per profile, so a family learns to recognise their tile
* by colour before they read the name. Derived from the user id rather than the
* name, so renaming does not move someone's colour.
*
* TRACES: UR-082 | DR-276
*/
const TILE_COLOURS = ["#7b68ee", "#00a4dc", "#e8734a", "#3ba55d", "#d95f8e", "#c9a227"] as const;
export function tileColour(userId: string): string {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = (hash * 31 + userId.charCodeAt(i)) >>> 0;
}
return TILE_COLOURS[hash % TILE_COLOURS.length];
}
/**
* How long a lockout has left, phrased for a person rather than a log line.
*
* TRACES: UR-083 | DR-276
*/
export function lockoutMessage(until: string, now: Date = new Date()): string {
const remainingMs = new Date(until).getTime() - now.getTime();
if (!Number.isFinite(remainingMs) || remainingMs <= 0) return "Try again now";
const minutes = Math.ceil(remainingMs / 60000);
if (minutes <= 1) return "Try again in less than a minute";
return `Try again in ${minutes} minutes`;
}