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:
@@ -1826,6 +1826,107 @@ async playlistGetItems(handle: string, playlistId: string) : Promise<PlaylistEnt
|
||||
async playlistAddItems(handle: string, playlistId: string, itemIds: string[]) : Promise<null> {
|
||||
return await TAURI_INVOKE("playlist_add_items", { handle, playlistId, itemIds });
|
||||
},
|
||||
/**
|
||||
* Add another account from the **current** server to this device.
|
||||
*
|
||||
* Takes no server URL. That is the same-server constraint expressed as a
|
||||
* signature rather than as form validation: there is no way to ask this command
|
||||
* for an account somewhere else.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
async profilesAdd(username: string, password: string, pinCode: string | null, deviceId: string) : Promise<Profile> {
|
||||
return await TAURI_INVOKE("profiles_add", { username, password, pinCode, deviceId });
|
||||
},
|
||||
/**
|
||||
* Read the "ask who's watching on start" setting.
|
||||
*
|
||||
* Separate from [`profiles_startup_target`] on purpose: the target can be
|
||||
* `Picker` for reasons that have nothing to do with this setting — a
|
||||
* PIN-protected last profile always asks — so deriving the toggle's position
|
||||
* from it would show the user a switch that does not describe what it controls.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
async profilesGetAskOnStart() : Promise<boolean> {
|
||||
return await TAURI_INVOKE("profiles_get_ask_on_start");
|
||||
},
|
||||
/**
|
||||
* List the accounts this device knows for the current server.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
async profilesList() : Promise<Profile[]> {
|
||||
return await TAURI_INVOKE("profiles_list");
|
||||
},
|
||||
/**
|
||||
* Forget a profile on this device.
|
||||
*
|
||||
* Does not call Jellyfin's logout endpoint: removing an account from the family
|
||||
* TV should not sign that person out on their phone. The stored token is
|
||||
* deleted locally, which is the part that actually belongs to this device.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
async profilesRemove(userId: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("profiles_remove", { userId });
|
||||
},
|
||||
/**
|
||||
* Turn "ask who's watching on start" on or off.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
async profilesSetAskOnStart(enabled: boolean) : Promise<null> {
|
||||
return await TAURI_INVOKE("profiles_set_ask_on_start", { enabled });
|
||||
},
|
||||
/**
|
||||
* Set, change, or clear a profile's PIN.
|
||||
*
|
||||
* Changing an existing PIN requires the current one. Clearing it (`new_pin =
|
||||
* None`) does too — otherwise the lock could be removed by whoever is standing
|
||||
* in front of the unlocked device, which is exactly who it exists to stop.
|
||||
*
|
||||
* TRACES: UR-083 | DR-268
|
||||
*/
|
||||
async profilesSetPin(userId: string, currentPin: string | null, newPin: string | null) : Promise<null> {
|
||||
return await TAURI_INVOKE("profiles_set_pin", { userId, currentPin, newPin });
|
||||
},
|
||||
/**
|
||||
* Whether startup should resume an account or ask who is watching.
|
||||
*
|
||||
* The decision is backend state, so the frontend asks rather than computes it.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
async profilesStartupTarget() : Promise<StartupTarget> {
|
||||
return await TAURI_INVOKE("profiles_startup_target");
|
||||
},
|
||||
/**
|
||||
* Enter a profile, with its PIN if it has one.
|
||||
*
|
||||
* A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
|
||||
* invent a lock the backend does not have, and cannot skip one it does.
|
||||
*
|
||||
* TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
|
||||
*/
|
||||
async profilesUnlock(userId: string, pinCode: string | null) : Promise<UnlockOutcome> {
|
||||
return await TAURI_INVOKE("profiles_unlock", { userId, pinCode });
|
||||
},
|
||||
/**
|
||||
* Enter a profile with its Jellyfin password, for someone who has forgotten
|
||||
* their PIN.
|
||||
*
|
||||
* There is deliberately no reset token and no recovery secret: the account's
|
||||
* own password is already the authority over it, and a second credential
|
||||
* guarding the same thing would only be a weaker one. A successful password
|
||||
* entry also clears the lockout, which is what makes a forgotten PIN a
|
||||
* detour rather than a dead end.
|
||||
*
|
||||
* TRACES: UR-084 | DR-269
|
||||
*/
|
||||
async profilesUnlockWithPassword(userId: string, password: string, deviceId: string) : Promise<UnlockOutcome> {
|
||||
return await TAURI_INVOKE("profiles_unlock_with_password", { userId, password, deviceId });
|
||||
},
|
||||
/**
|
||||
* Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
|
||||
*/
|
||||
@@ -3198,6 +3299,16 @@ alreadyDownloaded: number;
|
||||
* Number of tracks skipped (no jellyfin ID or other reasons)
|
||||
*/
|
||||
skipped: number }
|
||||
/**
|
||||
* A switchable account on this device.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
export type Profile = { userId: string; username: string; serverId: string;
|
||||
/**
|
||||
* Jellyfin's primary-image tag, for the tile. `None` renders initials.
|
||||
*/
|
||||
avatarTag: string | null; unlockMethod: UnlockMethod; lastUsedAt: string | null; isActive: boolean }
|
||||
/**
|
||||
* One rung of the quality picker, as it applies to *this* media source.
|
||||
*
|
||||
@@ -3358,6 +3469,25 @@ export type SleepTimerState = { mode: SleepTimerMode; remainingSeconds: number }
|
||||
* SmartCache statistics
|
||||
*/
|
||||
export type SmartCacheStats = { total_size: number; storage_limit: number; available_space: number; items_count: number; config: CacheConfig }
|
||||
/**
|
||||
* What the app should do when it starts.
|
||||
*
|
||||
* The decision is backend state (profile count, PIN presence, a stored
|
||||
* setting), so the frontend asks rather than computes. A single account with no
|
||||
* PIN always resumes, which is what keeps this feature invisible until it is
|
||||
* wanted.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
export type StartupTarget =
|
||||
/**
|
||||
* Resume this profile without asking.
|
||||
*/
|
||||
{ type: "resume"; userId: string } |
|
||||
/**
|
||||
* Show the picker.
|
||||
*/
|
||||
{ type: "picker" }
|
||||
/**
|
||||
* Storage statistics for downloads
|
||||
*/
|
||||
@@ -3558,6 +3688,50 @@ export type Transport =
|
||||
* server standing in front of one.
|
||||
*/
|
||||
{ type: "localFile" }
|
||||
/**
|
||||
* How a profile is entered.
|
||||
*
|
||||
* Deliberately not "adult"/"child": the app has no way to know a person's age
|
||||
* and no business encoding one. It knows whether a code is set.
|
||||
*
|
||||
* TRACES: UR-083 | DR-276
|
||||
*/
|
||||
export type UnlockMethod =
|
||||
/**
|
||||
* One tap. No code set.
|
||||
*/
|
||||
"none" |
|
||||
/**
|
||||
* A numeric code gates the switch.
|
||||
*/
|
||||
"pin"
|
||||
/**
|
||||
* The result of an unlock attempt.
|
||||
*
|
||||
* Note the explicit field renames. tauri-specta emits tagged-union *fields*
|
||||
* with their Rust names rather than camelCasing them, so a field that would
|
||||
* differ between the two conventions is renamed here by hand — the same trap
|
||||
* that produced `new_url` on the frontend once already.
|
||||
*
|
||||
* TRACES: UR-083, UR-084 | DR-268, DR-269
|
||||
*/
|
||||
export type UnlockOutcome =
|
||||
/**
|
||||
* Switched. The profile is now active.
|
||||
*/
|
||||
{ type: "ok"; userId: string } |
|
||||
/**
|
||||
* Wrong code, attempts left.
|
||||
*/
|
||||
{ type: "wrongPin"; attemptsRemaining: number } |
|
||||
/**
|
||||
* Too many wrong codes; refused until this RFC3339 instant.
|
||||
*/
|
||||
{ type: "lockedOut"; until: string } |
|
||||
/**
|
||||
* No code is recoverable from here — sign in with the account password.
|
||||
*/
|
||||
{ type: "needsPassword" }
|
||||
/**
|
||||
* User information
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<!--
|
||||
Numeric PIN entry.
|
||||
|
||||
Presentation only: it collects digits and hands them up. It does not know the
|
||||
PIN, does not compare anything, and does not count attempts — the backend
|
||||
returns an `UnlockOutcome` and this renders whatever it says. A pad that could
|
||||
decide would be a lock the webview could pick.
|
||||
|
||||
Sized for a living room: large targets, usable with a remote's arrow keys as
|
||||
well as a touchscreen.
|
||||
|
||||
TRACES: UR-083 | DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/** Digits entered so far. */
|
||||
value: string;
|
||||
/** Message under the dots — wrong PIN, lockout, etc. */
|
||||
error?: string | null;
|
||||
/** Blocks input while an attempt is in flight or the profile is locked out. */
|
||||
disabled?: boolean;
|
||||
maxLength?: number;
|
||||
onsubmit: (pin: string) => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
error = null,
|
||||
disabled = false,
|
||||
maxLength = 8,
|
||||
onsubmit,
|
||||
oncancel,
|
||||
}: Props = $props();
|
||||
|
||||
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "⌫"];
|
||||
|
||||
function press(key: string) {
|
||||
if (disabled) return;
|
||||
if (key === "⌫") {
|
||||
value = value.slice(0, -1);
|
||||
} else if (key && value.length < maxLength) {
|
||||
value = value + key;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
if (/^[0-9]$/.test(event.key)) {
|
||||
event.preventDefault();
|
||||
press(event.key);
|
||||
} else if (event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
press("⌫");
|
||||
} else if (event.key === "Enter" && value.length >= 4) {
|
||||
event.preventDefault();
|
||||
onsubmit(value);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
oncancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<!-- Entered digits, shown as dots. -->
|
||||
<div class="flex gap-3 h-4 items-center" aria-live="polite" aria-label="PIN entry">
|
||||
{#each Array(Math.max(value.length, 4)) as _, i (i)}
|
||||
<div
|
||||
class="rounded-full transition-all {i < value.length
|
||||
? 'w-3 h-3 bg-white'
|
||||
: 'w-3 h-3 bg-gray-600'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 text-sm text-center max-w-xs" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{#each KEYS as key (key)}
|
||||
{#if key === ""}
|
||||
<div></div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => press(key)}
|
||||
{disabled}
|
||||
class="w-18 h-18 min-w-[4.5rem] min-h-[4.5rem] rounded-full bg-[var(--color-surface)] hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-[var(--color-jellyfin)] disabled:opacity-40 disabled:cursor-not-allowed text-2xl font-light transition-colors"
|
||||
aria-label={key === "⌫" ? "Delete" : key}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 w-full max-w-xs">
|
||||
<button
|
||||
type="button"
|
||||
onclick={oncancel}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onsubmit(value)}
|
||||
disabled={disabled || value.length < 4}
|
||||
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"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -606,6 +606,66 @@ function createAuthStore() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild this store's view of the world after the backend has switched
|
||||
* profiles.
|
||||
*
|
||||
* The backend already flipped the active user, adopted the new session and
|
||||
* destroyed the old repository handle — in that order, which is the part that
|
||||
* matters. What is left is the half only the frontend owns: a `RepositoryClient`
|
||||
* bound to the new session, and the player's reporting configuration.
|
||||
*
|
||||
* Deliberately *not* a login: no password is involved and no token is minted,
|
||||
* because switching must leave both profiles able to come back with one tap.
|
||||
*
|
||||
* TRACES: UR-082 | DR-270
|
||||
*/
|
||||
async function adoptSwitchedSession() {
|
||||
const session = await commands.authGetSession();
|
||||
if (!session) throw new Error("No session after profile switch");
|
||||
|
||||
if (repository) {
|
||||
try {
|
||||
await repository.destroy();
|
||||
} catch (error) {
|
||||
log.error("Failed to destroy repository during switch:", error);
|
||||
}
|
||||
}
|
||||
|
||||
repository = new RepositoryClient();
|
||||
await repository.create(
|
||||
session.serverUrl,
|
||||
session.userId,
|
||||
session.accessToken,
|
||||
session.serverId,
|
||||
);
|
||||
|
||||
try {
|
||||
const deviceId = await getDeviceId();
|
||||
await commands.playerConfigureJellyfin(
|
||||
session.serverUrl,
|
||||
session.accessToken,
|
||||
session.userId,
|
||||
deviceId,
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to reconfigure player after switch:", error);
|
||||
}
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: { id: session.userId, name: session.username } as User,
|
||||
serverUrl: session.serverUrl,
|
||||
serverName: session.serverName,
|
||||
error: null,
|
||||
securityWarning: null,
|
||||
needsReauth: false,
|
||||
isVerifying: false,
|
||||
sessionVerified: session.verified,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
initialize,
|
||||
@@ -620,6 +680,7 @@ function createAuthStore() {
|
||||
getUserId,
|
||||
getServerUrl,
|
||||
retryVerification,
|
||||
adoptSwitchedSession,
|
||||
cleanupEventListeners,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Profile switching — a thin wrapper over the Rust `profiles_*` commands.
|
||||
*
|
||||
* There is deliberately no logic here worth the name. This store does not
|
||||
* compare a PIN, count an attempt, decide whether a profile is locked, or work
|
||||
* out whether the picker should appear at startup. All of that is backend state
|
||||
* and arrives as an opaque `unlockMethod`, an `UnlockOutcome` or a
|
||||
* `StartupTarget`. A store that re-derived any of it would be a gate the webview
|
||||
* could skip.
|
||||
*
|
||||
* TRACES: UR-082, UR-083, UR-084 | DR-267, DR-269, DR-274, DR-276
|
||||
*/
|
||||
|
||||
import { writable, get } from "svelte/store";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { Profile, StartupTarget, UnlockOutcome } from "$lib/api/bindings";
|
||||
import { getDeviceId } from "$lib/services/deviceId";
|
||||
import { auth } from "./auth";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("Profiles");
|
||||
|
||||
interface ProfilesState {
|
||||
profiles: Profile[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function createProfilesStore() {
|
||||
const { subscribe, set, update } = writable<ProfilesState>({
|
||||
profiles: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
async function refresh(): Promise<Profile[]> {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
try {
|
||||
const profiles = await commands.profilesList();
|
||||
set({ profiles, isLoading: false, error: null });
|
||||
return profiles;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error("Failed to list profiles:", error);
|
||||
set({ profiles: [], isLoading: false, error: message });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the app should go on launch. Asked, never computed — the answer
|
||||
* depends on PIN presence and a stored setting, neither of which the frontend
|
||||
* should be reasoning about.
|
||||
*/
|
||||
async function startupTarget(): Promise<StartupTarget> {
|
||||
return await commands.profilesStartupTarget();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a profile. On success the backend has already switched; this only
|
||||
* rebuilds the frontend's repository handle.
|
||||
*/
|
||||
async function unlock(userId: string, pin: string | null): Promise<UnlockOutcome> {
|
||||
const outcome = await commands.profilesUnlock(userId, pin);
|
||||
if (outcome.type === "ok") {
|
||||
await auth.adoptSwitchedSession();
|
||||
await refresh();
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* The way back in for someone who has forgotten their PIN. Their ordinary
|
||||
* Jellyfin password is the authority over their own account, so there is
|
||||
* nothing else to reset.
|
||||
*/
|
||||
async function unlockWithPassword(userId: string, password: string): Promise<UnlockOutcome> {
|
||||
const deviceId = await getDeviceId();
|
||||
const outcome = await commands.profilesUnlockWithPassword(userId, password, deviceId);
|
||||
if (outcome.type === "ok") {
|
||||
await auth.adoptSwitchedSession();
|
||||
await refresh();
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Add another account from the server already connected. */
|
||||
async function add(username: string, password: string, pin: string | null): Promise<Profile> {
|
||||
const deviceId = await getDeviceId();
|
||||
const profile = await commands.profilesAdd(username, password, pin, deviceId);
|
||||
await refresh();
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function setPin(userId: string, currentPin: string | null, newPin: string | null) {
|
||||
await commands.profilesSetPin(userId, currentPin, newPin);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function remove(userId: string) {
|
||||
await commands.profilesRemove(userId);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function setAskOnStart(enabled: boolean) {
|
||||
await commands.profilesSetAskOnStart(enabled);
|
||||
}
|
||||
|
||||
/** Whether this device has more than one account — what makes the UI worth showing at all. */
|
||||
function isShared(): boolean {
|
||||
return get({ subscribe }).profiles.length > 1;
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
refresh,
|
||||
startupTarget,
|
||||
unlock,
|
||||
unlockWithPassword,
|
||||
add,
|
||||
setPin,
|
||||
remove,
|
||||
setAskOnStart,
|
||||
isShared,
|
||||
};
|
||||
}
|
||||
|
||||
export const profiles = createProfilesStore();
|
||||
@@ -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`;
|
||||
}
|
||||
+25
-4
@@ -3,6 +3,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { auth, isAuthenticated } from "$lib/stores/auth";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import { home } from "$lib/stores/home";
|
||||
import { library, libraries } from "$lib/stores/library";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
@@ -29,11 +30,31 @@
|
||||
let previousServerReachable = false;
|
||||
let isAndroid = $state(false);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
// Where an unauthenticated app goes depends on what this device holds. A
|
||||
// single account with no PIN goes straight to login exactly as before; a
|
||||
// device with several profiles, or one whose last profile is PIN-protected,
|
||||
// goes to the picker instead. The decision is the backend's — the frontend
|
||||
// asks rather than counting profiles itself, because it also turns on a stored
|
||||
// setting and on which profiles have a PIN. (DR-274)
|
||||
let routingAway = false;
|
||||
$effect(() => {
|
||||
if (!$isAuthenticated) {
|
||||
goto("/login");
|
||||
}
|
||||
if ($isAuthenticated || routingAway) return;
|
||||
routingAway = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const target = await profiles.startupTarget();
|
||||
const found = await profiles.refresh();
|
||||
// The picker is only a picker when there is something to pick. With no
|
||||
// profiles stored it would render an empty room, so first run still
|
||||
// goes to login.
|
||||
await goto(target.type === "picker" && found.length > 0 ? "/profiles" : "/login");
|
||||
} catch (error) {
|
||||
log.error("Could not resolve startup target:", error);
|
||||
await goto("/login");
|
||||
} finally {
|
||||
routingAway = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Load home sections when authenticated
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
<!--
|
||||
"Who's watching" — the profile picker.
|
||||
|
||||
Everything here is presentation. Which profiles exist, whether one is locked,
|
||||
whether a code was right and how many guesses are left all arrive from Rust;
|
||||
this page renders them. In particular there is no notion of an adult or a child
|
||||
account anywhere in this file — a child's profile is simply one whose
|
||||
`unlockMethod` is "none", which is one tap.
|
||||
|
||||
TRACES: UR-082, UR-083, UR-084 | DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import type { Profile, UnlockOutcome } from "$lib/api/bindings";
|
||||
import PinPad from "$lib/components/PinPad.svelte";
|
||||
import { orderProfiles, initialsFor, tileColour, lockoutMessage } from "$lib/utils/profileTiles";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ProfilePicker");
|
||||
|
||||
type Mode = "picker" | "pin" | "password" | "add" | "manage";
|
||||
|
||||
let mode = $state<Mode>("picker");
|
||||
let selected = $state<Profile | null>(null);
|
||||
let pin = $state("");
|
||||
let password = $state("");
|
||||
let entryError = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
// Add-profile form
|
||||
let newUsername = $state("");
|
||||
let newPassword = $state("");
|
||||
let newPin = $state("");
|
||||
let usePinForNew = $state(false);
|
||||
|
||||
const ordered = $derived(orderProfiles($profiles.profiles));
|
||||
|
||||
onMount(() => {
|
||||
void profiles.refresh();
|
||||
});
|
||||
|
||||
function reset() {
|
||||
mode = "picker";
|
||||
selected = null;
|
||||
pin = "";
|
||||
password = "";
|
||||
entryError = null;
|
||||
newUsername = "";
|
||||
newPassword = "";
|
||||
newPin = "";
|
||||
usePinForNew = false;
|
||||
}
|
||||
|
||||
function renderOutcome(outcome: UnlockOutcome): boolean {
|
||||
switch (outcome.type) {
|
||||
case "ok":
|
||||
return true;
|
||||
case "wrongPin":
|
||||
pin = "";
|
||||
entryError =
|
||||
outcome.attemptsRemaining === 1
|
||||
? "Wrong PIN. One more try before this profile locks."
|
||||
: `Wrong PIN. ${outcome.attemptsRemaining} tries left.`;
|
||||
return false;
|
||||
case "lockedOut":
|
||||
pin = "";
|
||||
entryError = `Too many wrong PINs. ${lockoutMessage(outcome.until)}`;
|
||||
return false;
|
||||
case "needsPassword":
|
||||
mode = "password";
|
||||
entryError = "Sign in with your password to continue.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function choose(profile: Profile) {
|
||||
if (profile.unlockMethod === "pin") {
|
||||
selected = profile;
|
||||
pin = "";
|
||||
entryError = null;
|
||||
mode = "pin";
|
||||
return;
|
||||
}
|
||||
await enter(profile, null);
|
||||
}
|
||||
|
||||
async function enter(profile: Profile, code: string | null) {
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
const outcome = await profiles.unlock(profile.userId, code);
|
||||
if (renderOutcome(outcome)) {
|
||||
reset();
|
||||
await goto("/");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Unlock failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Could not switch profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enterWithPassword(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!selected) return;
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
const outcome = await profiles.unlockWithPassword(selected.userId, password);
|
||||
if (renderOutcome(outcome)) {
|
||||
reset();
|
||||
await goto("/");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Password unlock failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Sign-in failed";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addProfile(event: Event) {
|
||||
event.preventDefault();
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
await profiles.add(newUsername, newPassword, usePinForNew ? newPin : null);
|
||||
reset();
|
||||
} catch (error) {
|
||||
log.error("Add profile failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Could not add profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProfile(profile: Profile) {
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
await profiles.remove(profile.userId);
|
||||
} catch (error) {
|
||||
entryError = error instanceof Error ? error.message : "Could not remove profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-full flex items-center justify-center p-6">
|
||||
<div class="w-full max-w-3xl">
|
||||
{#if mode === "picker" || mode === "manage"}
|
||||
<h1 class="text-3xl font-semibold text-center mb-2">
|
||||
{mode === "manage" ? "Manage profiles" : "Who's watching?"}
|
||||
</h1>
|
||||
<p class="text-gray-400 text-center mb-10 text-sm">
|
||||
{#if mode === "manage"}
|
||||
Removing a profile only affects this device. It does not sign that person out elsewhere.
|
||||
{:else}
|
||||
Everyone here signs in to the same server.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if $profiles.error}
|
||||
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{$profiles.error}
|
||||
</div>
|
||||
{/if}
|
||||
{#if entryError}
|
||||
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-8">
|
||||
{#each ordered as profile (profile.userId)}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mode === "manage" ? undefined : choose(profile))}
|
||||
disabled={busy || mode === "manage"}
|
||||
class="relative w-28 h-28 rounded-2xl flex items-center justify-center text-3xl font-semibold text-white/90 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-white disabled:hover:scale-100"
|
||||
style="background-color: {tileColour(profile.userId)}"
|
||||
aria-label="Switch to {profile.username}"
|
||||
>
|
||||
{initialsFor(profile.username)}
|
||||
{#if profile.unlockMethod === "pin"}
|
||||
<span
|
||||
class="absolute bottom-2 right-2 bg-black/50 rounded-full p-1.5"
|
||||
aria-label="PIN required"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<span class="text-sm text-gray-300">{profile.username}</span>
|
||||
{#if mode === "manage" && !profile.isActive}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeProfile(profile)}
|
||||
disabled={busy}
|
||||
class="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
{:else if mode === "manage"}
|
||||
<span class="text-xs text-gray-500">In use</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if mode === "picker"}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
mode = "add";
|
||||
}}
|
||||
class="w-28 h-28 rounded-2xl border-2 border-dashed border-gray-600 hover:border-gray-400 flex items-center justify-center text-gray-500 hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-white"
|
||||
aria-label="Add a profile"
|
||||
>
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-sm text-gray-500">Add profile</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-12">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
mode = mode === "manage" ? "picker" : "manage";
|
||||
}}
|
||||
class="text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
{mode === "manage" ? "Done" : "Manage profiles"}
|
||||
</button>
|
||||
</div>
|
||||
{:else if mode === "pin" && selected}
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div
|
||||
class="w-20 h-20 rounded-2xl flex items-center justify-center text-2xl font-semibold text-white/90"
|
||||
style="background-color: {tileColour(selected.userId)}"
|
||||
>
|
||||
{initialsFor(selected.username)}
|
||||
</div>
|
||||
<h1 class="text-xl font-medium">{selected.username}</h1>
|
||||
</div>
|
||||
|
||||
<PinPad
|
||||
bind:value={pin}
|
||||
error={entryError}
|
||||
disabled={busy}
|
||||
onsubmit={(code) => selected && enter(selected, code)}
|
||||
oncancel={reset}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
password = "";
|
||||
mode = "password";
|
||||
}}
|
||||
class="text-sm text-gray-400 hover:text-white underline"
|
||||
>
|
||||
Forgot your PIN? Use your password
|
||||
</button>
|
||||
</div>
|
||||
{:else if mode === "password" && selected}
|
||||
<form onsubmit={enterWithPassword} class="max-w-sm mx-auto space-y-4">
|
||||
<h1 class="text-2xl font-semibold text-center mb-6">
|
||||
Sign in as {selected.username}
|
||||
</h1>
|
||||
|
||||
<div>
|
||||
<label for="profile-password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
id="profile-password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
autofocus
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if entryError}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !password}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if mode === "add"}
|
||||
<form onsubmit={addProfile} class="max-w-sm mx-auto space-y-4">
|
||||
<h1 class="text-2xl font-semibold text-center mb-2">Add a profile</h1>
|
||||
<p class="text-gray-400 text-sm text-center mb-6">
|
||||
Another account on the server you are already connected to.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label for="new-username" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="new-username"
|
||||
type="text"
|
||||
bind:value={newUsername}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 text-sm text-gray-300">
|
||||
<input type="checkbox" bind:checked={usePinForNew} class="mt-1" disabled={busy} />
|
||||
<span>
|
||||
Protect this profile with a PIN
|
||||
<span class="block text-gray-500 text-xs mt-1">
|
||||
Leave this off for a child's profile so it opens with one tap. A PIN controls who can
|
||||
switch to an account — what each account may watch is set on the Jellyfin server.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{#if usePinForNew}
|
||||
<div>
|
||||
<label for="new-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
PIN (4–8 digits)
|
||||
</label>
|
||||
<input
|
||||
id="new-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
bind:value={newPin}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if entryError}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !newUsername || (usePinForNew && newPin.length < 4)}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +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 type {
|
||||
AudioSettings,
|
||||
CacheConfig,
|
||||
@@ -147,8 +149,13 @@
|
||||
// Promise and Svelte would never invoke it as a teardown.
|
||||
onDestroy(unsubscribeNativeVideo);
|
||||
|
||||
// Mirrors the stored setting; the picker itself always appears for a
|
||||
// PIN-protected profile regardless of this. (DR-274)
|
||||
let askOnStart = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await loadSettings();
|
||||
askOnStart = await commands.profilesGetAskOnStart();
|
||||
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
|
||||
|
||||
// Which update story this platform gets. Android cannot install its own
|
||||
@@ -985,6 +992,50 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Profiles. Deliberately minimal here: adding, removing and PIN changes
|
||||
all live on the picker itself, where the tiles are. What belongs in
|
||||
settings is the one device-wide preference and a way in.
|
||||
TRACES: UR-082, UR-083 | DR-274, DR-276 -->
|
||||
<div class="border-t border-gray-700 pt-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Profiles</h2>
|
||||
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Ask who's watching</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
Show the profile picker when the app starts. A profile with a PIN always asks,
|
||||
whatever this is set to.
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={askOnStart}
|
||||
onchange={() => profiles.setAskOnStart(askOnStart)}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-jellyfin)]"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-700 pt-5">
|
||||
<p class="text-sm text-gray-400 mb-3">
|
||||
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.
|
||||
</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>
|
||||
|
||||
<!-- Search Settings -->
|
||||
<div class="border-t border-gray-700 pt-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Search</h2>
|
||||
|
||||
Reference in New Issue
Block a user