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,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