Files
jellytau/src/lib/stores/auth.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

594 lines
18 KiB
TypeScript

// Authentication state store with Rust backend
//
// All business logic (session management, verification, credential storage) is handled by Rust.
// This file is a thin Svelte store wrapper that calls Rust commands and listens to events.
//
// TRACES: UR-009, UR-012 | IR-009, IR-014
import { writable, derived, get } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import { RepositoryClient } from "$lib/api/repository-client";
import type { User, AuthResult } from "$lib/api/types";
import type { Session, AuthServerInfo as ServerInfo } from "$lib/api/bindings";
import { connectivity } from "./connectivity";
import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("Auth");
interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
user: User | null;
serverUrl: string | null;
serverName: string | null;
error: string | null;
securityWarning: string | null;
/** Whether session needs re-authentication (e.g., token expired) */
needsReauth: boolean;
/** Whether session verification is in progress */
isVerifying: boolean;
/** Whether the session is known to be valid (verified with server) */
sessionVerified: boolean;
}
function createAuthStore() {
const initialState: AuthState = {
isAuthenticated: false,
isLoading: true,
user: null,
serverUrl: null,
serverName: null,
error: null,
securityWarning: null,
needsReauth: false,
isVerifying: false,
sessionVerified: false,
};
const { subscribe, set, update } = writable<AuthState>(initialState);
// RepositoryClient provides cache-first access with automatic background refresh via Rust
let repository: RepositoryClient | null = null;
// Store unlisten functions for cleanup
let unlistenSessionVerified: (() => void) | null = null;
let unlistenNeedsReauth: (() => void) | null = null;
let unlistenNetworkError: (() => void) | null = null;
function getRepository(): RepositoryClient {
if (!repository) {
throw new Error("Not connected to a server");
}
return repository;
}
/**
* Initialize event listeners from Rust backend.
* These should be called once during app initialization.
*/
async function initializeEventListeners(): Promise<void> {
if (typeof window === "undefined") return;
try {
unlistenSessionVerified = await listen<{ user: User }>("auth:session-verified", (event) => {
log.debug("Session verified:", event.payload.user.name);
update((s) => ({
...s,
sessionVerified: true,
needsReauth: false,
isVerifying: false,
user: event.payload.user,
}));
});
} catch (e) {
log.error("Failed to listen to session-verified event:", e);
}
try {
unlistenNeedsReauth = await listen<{ reason: string }>("auth:needs-reauth", (event) => {
log.debug("Session needs re-authentication:", event.payload.reason);
update((s) => ({
...s,
sessionVerified: false,
needsReauth: true,
isVerifying: false,
error: event.payload.reason,
}));
});
} catch (e) {
log.error("Failed to listen to needs-reauth event:", e);
}
try {
unlistenNetworkError = await listen<{ message: string }>("auth:network-error", (event) => {
log.debug("Network error during verification:", event.payload.message);
// Network errors don't trigger re-auth - just log them
update((s) => ({ ...s, isVerifying: false }));
});
} catch (e) {
log.error("Failed to listen to network-error event:", e);
}
}
/**
* Cleanup event listeners.
* Should be called when the app is destroyed.
*/
function cleanupEventListeners(): void {
if (unlistenSessionVerified) {
unlistenSessionVerified();
unlistenSessionVerified = null;
}
if (unlistenNeedsReauth) {
unlistenNeedsReauth();
unlistenNeedsReauth = null;
}
if (unlistenNetworkError) {
unlistenNetworkError();
unlistenNetworkError = null;
}
}
/**
* Initialize auth state from Rust backend.
* This function does NOT require network access - session is restored immediately.
*/
async function initialize() {
// Initialize event listeners first
await initializeEventListeners();
update((s) => ({ ...s, isLoading: true, error: null }));
try {
// Check security status — fire-and-forget. It only sets a warning banner,
// so it must not sit in front of session restore (and thus first paint).
void (async () => {
try {
const securityStatus = await commands.storageGetSecurityStatus();
log.debug("Security status:", securityStatus);
if (!securityStatus.usingKeyring) {
update((s) => ({
...s,
securityWarning:
"Credentials are stored with reduced security (encrypted file instead of system keyring).",
}));
}
} catch (error) {
log.warn("Failed to get security status:", error);
}
})();
// Initialize auth manager and get session
log.debug("Initializing auth manager...");
const session = await commands.authInitialize();
log.debug("Session retrieval result:", session ? "Session found" : "No session found");
if (session) {
log.debug("Restoring session for user:", session.username, "on server:", session.serverUrl);
// Create RepositoryClient for cache-first access. This IS required before
// we mark authenticated — the first screen (library overview) reads
// through it — so keep it awaited.
repository = new RepositoryClient();
await repository.create(
session.serverUrl,
session.userId,
session.accessToken,
session.serverId,
);
// Configure the Rust player for playback reporting. This is NOT needed to
// render the first screen (it only matters once playback starts), so run
// it fire-and-forget instead of blocking first paint on two more IPC
// round-trips (getDeviceId + playerConfigureJellyfin).
void (async () => {
try {
const deviceId = await getDeviceId();
await commands.playerConfigureJellyfin(
session.serverUrl,
session.accessToken,
session.userId,
deviceId,
);
log.debug("Rust player configured for automatic playback reporting");
} catch (error) {
log.error("Failed to configure Rust player:", error);
}
})();
// Set authenticated immediately (offline-first)
set({
isAuthenticated: true,
isLoading: false,
user: { id: session.userId, name: session.username, serverId: session.serverId } as User,
serverUrl: session.serverUrl,
serverName: session.serverName,
error: null,
securityWarning: initialState.securityWarning,
needsReauth: session.needsReauth,
isVerifying: false,
sessionVerified: session.verified,
});
// Start connectivity monitoring early to avoid appearing offline on startup
log.debug("Starting early connectivity monitoring...");
connectivity
.startMonitoring(session.serverUrl, {
onServerReconnected: () => {
// Retry session verification when server becomes reachable
retryVerification();
// Resume downloads queued while offline, then refresh the catalog.
// Lazy import to avoid an auth <-> offlineCatalog import cycle.
import("$lib/services/offlineCatalog")
.then((m) => m.onReconnected())
.catch((err) => log.warn("Catalog reconnect failed:", err));
},
})
.catch((error) => {
log.error("Failed to start connectivity monitoring:", error);
});
// Start background session verification — fire-and-forget. This is
// already asynchronous work (results arrive via the auth:* events wired
// above), so awaiting getDeviceId + authStartVerification here only
// delayed first paint by two IPC round-trips for no UI benefit.
void (async () => {
try {
const verifyDeviceId = await getDeviceId();
await commands.authStartVerification(verifyDeviceId);
log.debug("Background verification started");
} catch (error) {
log.error("Failed to start verification:", error);
}
})();
} else {
// No stored session
log.debug("No active session found");
set({
isAuthenticated: false,
isLoading: false,
user: null,
serverUrl: null,
serverName: null,
error: null,
securityWarning: initialState.securityWarning,
needsReauth: false,
isVerifying: false,
sessionVerified: false,
});
}
} catch (error) {
log.error("Failed to initialize:", error);
update((s) => ({
...s,
isLoading: false,
error: error instanceof Error ? error.message : String(error),
}));
}
}
/**
* Connect to a Jellyfin server and retrieve server info.
* Rust will normalize the URL (add https:// if missing, remove trailing slash).
*
* TRACES: UR-009 | IR-009
*/
async function connectToServer(serverUrl: string): Promise<ServerInfo> {
update((s) => ({ ...s, isLoading: true, error: null }));
try {
log.debug("Connecting to server:", serverUrl);
const serverInfo = await commands.authConnectToServer(serverUrl);
log.debug("Connected to server:", serverInfo.name, serverInfo.version);
log.debug("Normalized URL:", serverInfo.normalizedUrl);
update((s) => ({ ...s, isLoading: false }));
return serverInfo;
} catch (error) {
log.error("Failed to connect to server:", error);
update((s) => ({
...s,
isLoading: false,
error: error instanceof Error ? error.message : String(error),
}));
throw error;
}
}
/**
* Login with username and password.
*
* TRACES: UR-009, UR-012 | IR-009, IR-014
*/
async function login(username: string, password: string, serverUrl: string, serverName: string) {
update((s) => ({ ...s, isLoading: true, error: null }));
try {
const deviceId = await getDeviceId();
log.debug("Logging in as:", username);
const authResult = await commands.authLogin(serverUrl, username, password, deviceId);
log.debug("Login successful:", authResult.user);
// Save to storage
await commands.storageSaveServer(authResult.serverId, serverName, serverUrl, null);
await commands.storageSaveUser(
authResult.user.id,
authResult.serverId,
authResult.user.name,
authResult.accessToken,
);
await commands.storageSetActiveUser(authResult.user.id, authResult.serverId);
// Set session in auth manager with server name
await commands.authSetSession({
userId: authResult.user.id,
username: authResult.user.name,
serverId: authResult.serverId,
serverUrl,
serverName,
accessToken: authResult.accessToken,
verified: true,
needsReauth: false,
});
// Create RepositoryClient
repository = new RepositoryClient();
await repository.create(
serverUrl,
authResult.user.id,
authResult.accessToken,
authResult.serverId,
);
// Configure Rust player
try {
const playerDeviceId = await getDeviceId();
await commands.playerConfigureJellyfin(
serverUrl,
authResult.accessToken,
authResult.user.id,
playerDeviceId,
);
log.debug("Rust player configured for playback reporting");
} catch (error) {
log.error("Failed to configure Rust player:", error);
}
// Update state
set({
isAuthenticated: true,
isLoading: false,
user: authResult.user,
serverUrl,
serverName,
error: null,
securityWarning: initialState.securityWarning,
needsReauth: false,
isVerifying: false,
sessionVerified: true,
});
// Start background verification
try {
const verifyDeviceId = await getDeviceId();
await commands.authStartVerification(verifyDeviceId);
} catch (error) {
log.error("Failed to start verification:", error);
}
return authResult;
} catch (error) {
log.error("Login failed:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
throw error;
}
}
/**
* Re-authenticate with password (when session expired).
*/
async function reauthenticate(password: string) {
update((s) => ({ ...s, isLoading: true, error: null, needsReauth: false }));
try {
const deviceId = await getDeviceId();
log.debug("Re-authenticating...");
const authResult = await commands.authReauthenticate(password, deviceId);
log.debug("Re-authentication successful");
// Update storage
await commands.storageSaveUser(
authResult.user.id,
authResult.serverId,
authResult.user.name,
authResult.accessToken,
);
// Recreate repository with new credentials
if (repository) {
await repository.destroy();
const session = await commands.authGetSession();
if (session) {
await repository.create(
session.serverUrl,
authResult.user.id,
authResult.accessToken,
authResult.serverId,
);
}
}
// Reconfigure player
try {
const playerDeviceId = await getDeviceId();
await commands.playerConfigureJellyfin(
repository ? await getCurrentSessionServerUrl() : "",
authResult.accessToken,
authResult.user.id,
playerDeviceId,
);
} catch (error) {
log.error("Failed to reconfigure player:", error);
}
// Update state
update((s) => ({
...s,
isLoading: false,
needsReauth: false,
sessionVerified: true,
user: authResult.user,
error: null,
}));
return authResult;
} catch (error) {
log.error("Re-authentication failed:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
throw error;
}
}
/**
* Logout and clear session.
*
* TRACES: UR-012 | IR-014
*/
async function logout() {
try {
const session = await commands.authGetSession();
if (session) {
const deviceId = await getDeviceId();
await commands.authLogout(session.serverUrl, session.accessToken, deviceId);
}
// Disable Jellyfin reporting in player
try {
await commands.playerDisableJellyfin();
} catch (error) {
log.error("Failed to disable player reporting:", error);
}
// Clear repository
if (repository) {
await repository.destroy();
}
repository = null;
set({
isAuthenticated: false,
isLoading: false,
user: null,
serverUrl: null,
serverName: null,
error: null,
securityWarning: null,
needsReauth: false,
isVerifying: false,
sessionVerified: false,
});
// Clear device ID cache on logout
clearDeviceIdCache();
} catch (error) {
log.error("Logout error (continuing anyway):", error);
set(initialState);
clearDeviceIdCache();
}
}
/**
* Clear error state.
*/
function clearError() {
update((s) => ({ ...s, error: null }));
}
/**
* Get current session from Rust backend.
*/
async function getCurrentSession() {
try {
return await commands.authGetSession();
} catch (error) {
log.error("Failed to get current session:", error);
return null;
}
}
/**
* Get current user ID.
*/
function getUserId(): string | null {
const state = get({ subscribe });
return state.user?.id || null;
}
/**
* Get server URL.
*/
function getServerUrl(): string | null {
const state = get({ subscribe });
return state.serverUrl;
}
/**
* Helper to get server URL from current session.
*/
async function getCurrentSessionServerUrl(): Promise<string> {
const session = await commands.authGetSession();
return session?.serverUrl || "";
}
/**
* Retry session verification (called when server becomes reachable again).
*/
async function retryVerification() {
try {
const deviceId = await getDeviceId();
log.debug("Retrying session verification after reconnection");
await commands.authStartVerification(deviceId);
} catch (error) {
log.error("Failed to retry verification:", error);
}
}
return {
subscribe,
initialize,
connectToServer,
login,
reauthenticate,
logout,
clearError,
getRepository,
getCurrentSession,
getUserId,
getServerUrl,
retryVerification,
cleanupEventListeners,
};
}
export const auth = createAuthStore();
export const isAuthenticated = derived(auth, ($auth) => $auth.isAuthenticated);
export const isLoading = derived(auth, ($auth) => $auth.isLoading);
export const currentUser = derived(auth, ($auth) => $auth.user);
export const needsReauth = derived(auth, ($auth) => $auth.needsReauth);
export const securityWarning = derived(auth, ($auth) => $auth.securityWarning);
export const authError = derived(auth, ($auth) => $auth.error);
export const isVerifying = derived(auth, ($auth) => $auth.isVerifying);
export const sessionVerified = derived(auth, ($auth) => $auth.sessionVerified);
export const serverName = derived(auth, ($auth) => $auth.serverName);
export const serverUrl = derived(auth, ($auth) => $auth.serverUrl);