Files
Stirling-PDF/frontend/src/desktop/services/authService.ts
James Brunton f4725b98b0 Allow desktop app to connect to selfhosted servers (#4902)
# Description of Changes
Changes the desktop app to allow connections to self-hosted servers on
first startup. This was quite involved and hit loads of CORS issues all
through the stack, but I think it's working now. This also changes the
bundled backend to spawn on an OS-decided port rather than always
spawning on `8080`, which means that the user can have other things
running on port `8080` now and the app will still work fine. There were
quite a few places that needed to be updated to decouple the app from
explicitly using `8080` and I was originally going to split those
changes out into another PR (#4939), but I couldn't get it working
independently in the time I had, so the diff here is just going to be
complex and contian two distinct changes - sorry 🙁
2025-11-20 10:03:34 +00:00

199 lines
5.1 KiB
TypeScript

import { invoke } from '@tauri-apps/api/core';
import axios from 'axios';
export interface UserInfo {
username: string;
email?: string;
}
interface LoginResponse {
token: string;
username: string;
email: string | null;
}
export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing';
export class AuthService {
private static instance: AuthService;
private authStatus: AuthStatus = 'unauthenticated';
private userInfo: UserInfo | null = null;
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void {
this.authListeners.add(listener);
// Immediately notify new listener of current state
listener(this.authStatus, this.userInfo);
return () => {
this.authListeners.delete(listener);
};
}
private notifyListeners() {
this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo));
}
private setAuthStatus(status: AuthStatus, userInfo: UserInfo | null = null) {
this.authStatus = status;
this.userInfo = userInfo;
this.notifyListeners();
}
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
try {
console.log('Logging in to:', serverUrl);
// Call Rust login command (bypasses CORS)
const response = await invoke<LoginResponse>('login', {
serverUrl,
username,
password,
});
const { token, username: returnedUsername, email } = response;
// Save the token to keyring
await invoke('save_auth_token', { token });
// Save user info to store
await invoke('save_user_info', {
username: returnedUsername || username,
email,
});
const userInfo: UserInfo = {
username: returnedUsername || username,
email: email || undefined,
};
this.setAuthStatus('authenticated', userInfo);
console.log('Login successful');
return userInfo;
} catch (error) {
console.error('Login failed:', error);
this.setAuthStatus('unauthenticated', null);
// Rust commands return string errors
if (typeof error === 'string') {
throw new Error(error);
}
throw new Error('Login failed. Please try again.');
}
}
async logout(): Promise<void> {
try {
console.log('Logging out');
// Clear token from keyring
await invoke('clear_auth_token');
// Clear user info from store
await invoke('clear_user_info');
this.setAuthStatus('unauthenticated', null);
console.log('Logged out successfully');
} catch (error) {
console.error('Error during logout:', error);
// Still set status to unauthenticated even if clear fails
this.setAuthStatus('unauthenticated', null);
}
}
async getAuthToken(): Promise<string | null> {
try {
const token = await invoke<string | null>('get_auth_token');
return token || null;
} catch (error) {
console.error('Failed to get auth token:', error);
return null;
}
}
async isAuthenticated(): Promise<boolean> {
const token = await this.getAuthToken();
return token !== null;
}
async getUserInfo(): Promise<UserInfo | null> {
if (this.userInfo) {
return this.userInfo;
}
try {
const userInfo = await invoke<UserInfo | null>('get_user_info');
this.userInfo = userInfo;
return userInfo;
} catch (error) {
console.error('Failed to get user info:', error);
return null;
}
}
async refreshToken(serverUrl: string): Promise<boolean> {
try {
console.log('Refreshing auth token');
this.setAuthStatus('refreshing', this.userInfo);
const currentToken = await this.getAuthToken();
if (!currentToken) {
this.setAuthStatus('unauthenticated', null);
return false;
}
// Call the server's refresh endpoint
const response = await axios.post(
`${serverUrl}/api/v1/auth/refresh`,
{},
{
headers: {
Authorization: `Bearer ${currentToken}`,
},
}
);
const { token } = response.data;
// Save the new token
await invoke('save_auth_token', { token });
const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo);
console.log('Token refreshed successfully');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
this.setAuthStatus('unauthenticated', null);
// Clear stored credentials on refresh failure
await this.logout();
return false;
}
}
async initializeAuthState(): Promise<void> {
const token = await this.getAuthToken();
const userInfo = await this.getUserInfo();
if (token && userInfo) {
this.setAuthStatus('authenticated', userInfo);
} else {
this.setAuthStatus('unauthenticated', null);
}
}
}
export const authService = AuthService.getInstance();