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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user