First working POC
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
// 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.
|
||||
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { RepositoryClient } from "$lib/api/repository-client";
|
||||
import type { User, AuthResult } from "$lib/api/types";
|
||||
import { connectivity } from "./connectivity";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
userId: string;
|
||||
username: string;
|
||||
serverId: string;
|
||||
serverUrl: string;
|
||||
serverName: string;
|
||||
accessToken: string;
|
||||
verified: boolean;
|
||||
needsReauth: boolean;
|
||||
}
|
||||
|
||||
interface ServerInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
id: string;
|
||||
normalizedUrl: string;
|
||||
}
|
||||
|
||||
interface SecurityStatus {
|
||||
usingKeyring: boolean;
|
||||
storageType: string;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
function getRepository(): RepositoryClient {
|
||||
if (!repository) {
|
||||
throw new Error("Not connected to a server");
|
||||
}
|
||||
return repository;
|
||||
}
|
||||
|
||||
// Listen to auth events from Rust
|
||||
if (typeof window !== "undefined") {
|
||||
listen<{ user: User }>("auth:session-verified", (event) => {
|
||||
console.log("[Auth] Session verified:", event.payload.user.name);
|
||||
update((s) => ({
|
||||
...s,
|
||||
sessionVerified: true,
|
||||
needsReauth: false,
|
||||
isVerifying: false,
|
||||
user: event.payload.user,
|
||||
}));
|
||||
});
|
||||
|
||||
listen<{ reason: string }>("auth:needs-reauth", (event) => {
|
||||
console.log("[Auth] Session needs re-authentication:", event.payload.reason);
|
||||
update((s) => ({
|
||||
...s,
|
||||
sessionVerified: false,
|
||||
needsReauth: true,
|
||||
isVerifying: false,
|
||||
error: event.payload.reason,
|
||||
}));
|
||||
});
|
||||
|
||||
listen<{ message: string }>("auth:network-error", (event) => {
|
||||
console.log("[Auth] Network error during verification:", event.payload.message);
|
||||
// Network errors don't trigger re-auth - just log them
|
||||
update((s) => ({ ...s, isVerifying: false }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auth state from Rust backend.
|
||||
* This function does NOT require network access - session is restored immediately.
|
||||
*/
|
||||
async function initialize() {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Check security status
|
||||
try {
|
||||
const securityStatus = await invoke<SecurityStatus>("storage_get_security_status");
|
||||
console.log("[Auth] Security status:", securityStatus);
|
||||
if (!securityStatus.usingKeyring) {
|
||||
update((s) => ({
|
||||
...s,
|
||||
securityWarning:
|
||||
"Credentials are stored with reduced security (encrypted file instead of system keyring).",
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Auth] Failed to get security status:", error);
|
||||
}
|
||||
|
||||
// Initialize auth manager and get session
|
||||
console.log("[Auth] Initializing auth manager...");
|
||||
const session = await invoke<Session | null>("auth_initialize");
|
||||
console.log("[Auth] Session retrieval result:", session ? "Session found" : "No session found");
|
||||
|
||||
if (session) {
|
||||
console.log("[Auth] Restoring session for user:", session.username, "on server:", session.serverUrl);
|
||||
|
||||
// Create RepositoryClient for cache-first access
|
||||
repository = new RepositoryClient();
|
||||
await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId);
|
||||
|
||||
// Configure Jellyfin client in Rust player for automatic playback reporting
|
||||
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||
try {
|
||||
console.log("[Auth] Configuring Rust player with restored session...");
|
||||
await invoke("player_configure_jellyfin", {
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
userId: session.userId,
|
||||
deviceId: deviceId,
|
||||
});
|
||||
console.log("[Auth] Rust player configured for automatic playback reporting");
|
||||
} catch (error) {
|
||||
console.error("[Auth] 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
|
||||
console.log("[Auth] Starting early connectivity monitoring...");
|
||||
connectivity.startMonitoring(session.serverUrl, {
|
||||
onServerReconnected: () => {
|
||||
// Retry session verification when server becomes reachable
|
||||
retryVerification();
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error("[Auth] Failed to start connectivity monitoring:", error);
|
||||
});
|
||||
|
||||
// Start background session verification
|
||||
try {
|
||||
await invoke("auth_start_verification", { deviceId });
|
||||
console.log("[Auth] Background verification started");
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to start verification:", error);
|
||||
}
|
||||
} else {
|
||||
// No stored session
|
||||
console.log("[Auth] 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) {
|
||||
console.error("[Auth] 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).
|
||||
*/
|
||||
async function connectToServer(serverUrl: string): Promise<ServerInfo> {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
console.log("[Auth] Connecting to server:", serverUrl);
|
||||
const serverInfo = await invoke<ServerInfo>("auth_connect_to_server", { serverUrl });
|
||||
console.log("[Auth] Connected to server:", serverInfo.name, serverInfo.version);
|
||||
console.log("[Auth] Normalized URL:", serverInfo.normalizedUrl);
|
||||
|
||||
update((s) => ({ ...s, isLoading: false }));
|
||||
return serverInfo;
|
||||
} catch (error) {
|
||||
console.error("[Auth] 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.
|
||||
*/
|
||||
async function login(username: string, password: string, serverUrl: string, serverName: string) {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||
console.log("[Auth] Logging in as:", username);
|
||||
|
||||
const authResult = await invoke<AuthResult>("auth_login", {
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
console.log("[Auth] Login successful:", authResult.user);
|
||||
|
||||
// Save to storage
|
||||
await invoke("storage_save_server", {
|
||||
id: authResult.serverId,
|
||||
name: serverName,
|
||||
url: serverUrl,
|
||||
version: null,
|
||||
});
|
||||
|
||||
await invoke("storage_save_user", {
|
||||
id: authResult.user.id,
|
||||
serverId: authResult.serverId,
|
||||
username: authResult.user.name,
|
||||
accessToken: authResult.accessToken,
|
||||
});
|
||||
|
||||
await invoke("storage_set_active_user", {
|
||||
userId: authResult.user.id,
|
||||
serverId: authResult.serverId,
|
||||
});
|
||||
|
||||
// Set session in auth manager with server name
|
||||
await invoke("auth_set_session", {
|
||||
session: {
|
||||
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 {
|
||||
await invoke("player_configure_jellyfin", {
|
||||
serverUrl,
|
||||
accessToken: authResult.accessToken,
|
||||
userId: authResult.user.id,
|
||||
deviceId,
|
||||
});
|
||||
console.log("[Auth] Rust player configured for playback reporting");
|
||||
} catch (error) {
|
||||
console.error("[Auth] 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 {
|
||||
await invoke("auth_start_verification", { deviceId });
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to start verification:", error);
|
||||
}
|
||||
|
||||
return authResult;
|
||||
} catch (error) {
|
||||
console.error("[Auth] 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 = localStorage.getItem("jellytau_device_id") || "";
|
||||
console.log("[Auth] Re-authenticating...");
|
||||
|
||||
const authResult = await invoke<AuthResult>("auth_reauthenticate", {
|
||||
password,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
console.log("[Auth] Re-authentication successful");
|
||||
|
||||
// Update storage
|
||||
await invoke("storage_save_user", {
|
||||
id: authResult.user.id,
|
||||
serverId: authResult.serverId,
|
||||
username: authResult.user.name,
|
||||
accessToken: authResult.accessToken,
|
||||
});
|
||||
|
||||
// Recreate repository with new credentials
|
||||
if (repository) {
|
||||
await repository.destroy();
|
||||
const session = await invoke<Session | null>("auth_get_session");
|
||||
if (session) {
|
||||
await repository.create(session.serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId);
|
||||
}
|
||||
}
|
||||
|
||||
// Reconfigure player
|
||||
try {
|
||||
await invoke("player_configure_jellyfin", {
|
||||
serverUrl: repository ? await getCurrentSessionServerUrl() : "",
|
||||
accessToken: authResult.accessToken,
|
||||
userId: authResult.user.id,
|
||||
deviceId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Auth] 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) {
|
||||
console.error("[Auth] 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.
|
||||
*/
|
||||
async function logout() {
|
||||
try {
|
||||
const session = await invoke<Session | null>("auth_get_session");
|
||||
if (session) {
|
||||
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||
await invoke("auth_logout", {
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
// Disable Jellyfin reporting in player
|
||||
try {
|
||||
await invoke("player_disable_jellyfin");
|
||||
} catch (error) {
|
||||
console.error("[Auth] 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,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Auth] Logout error (continuing anyway):", error);
|
||||
set(initialState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state.
|
||||
*/
|
||||
function clearError() {
|
||||
update((s) => ({ ...s, error: null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session from Rust backend.
|
||||
*/
|
||||
async function getCurrentSession() {
|
||||
try {
|
||||
return await invoke<Session | null>("auth_get_session");
|
||||
} catch (error) {
|
||||
console.error("[Auth] 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 invoke<Session | null>("auth_get_session");
|
||||
return session?.serverUrl || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry session verification (called when server becomes reachable again).
|
||||
*/
|
||||
async function retryVerification() {
|
||||
try {
|
||||
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||
console.log("[Auth] Retrying session verification after reconnection");
|
||||
await invoke("auth_start_verification", { deviceId });
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to retry verification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
initialize,
|
||||
connectToServer,
|
||||
login,
|
||||
reauthenticate,
|
||||
logout,
|
||||
clearError,
|
||||
getRepository,
|
||||
getCurrentSession,
|
||||
getUserId,
|
||||
getServerUrl,
|
||||
retryVerification,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user