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.
594 lines
18 KiB
TypeScript
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);
|