/** * Job-Info Auth Flow - Standalone * * Complete, self-contained authentication for Job-Info app * - Check localStorage for active tokens on load * - Prompt for login if tokens missing/inactive * - Get user display name from PocketBase metadata * - Refresh all tokens to keep them fresh * - Only replace pb-user when we have a new token * - Graph token is optional after first login * - Do not prompt again unless pb-user is missing/inactive */ interface TokenMetadata { value: string; displayName: string; lastFourDigits: string; expiresAt: number; acquiredAt: number; } interface JobInfoAuthState { 'pb-user'?: TokenMetadata; 'graph-user'?: TokenMetadata; userDisplayName: string; } class JobInfoAuthFlow { private state: JobInfoAuthState = { userDisplayName: 'Unknown' }; private pb: any = null; private initialized = false; /** * Initialize and check token status */ async init(): Promise { if (this.initialized) return; console.log('[JobInfoAuth] Starting init'); // Initialize PocketBase const PocketBase = (window as any).PocketBase; if (!PocketBase) { throw new Error('PocketBase SDK not loaded. Include: '); } this.pb = new PocketBase('http://localhost:8090'); console.log('[JobInfoAuth] PocketBase initialized'); // Load persisted tokens from localStorage this.loadPersistedTokens(); // Check token status const pbUserActive = this.isTokenActive('pb-user'); const graphUserActive = this.isTokenActive('graph-user'); console.log('[JobInfoAuth] Token status - pb-user:', pbUserActive, 'graph-user:', graphUserActive); // If pb-user is missing or inactive: prompt for login if (!pbUserActive) { console.log('[JobInfoAuth] pb-user token missing or inactive, prompting login'); await this.promptLogin(); } else { console.log('[JobInfoAuth] pb-user token is active, loading display name'); // Get display name from state or try to refresh if (!this.state.userDisplayName || this.state.userDisplayName === 'Unknown') { const userInfo = this.getUserInfo(); this.state.userDisplayName = userInfo.displayName || 'Unknown'; } } // Refresh all tokens to keep them fresh await this.refreshAllTokens(); this.initialized = true; console.log('[JobInfoAuth] Initialized. State:', this.state); } /** * Prompt for login via PocketBase OAuth */ private async promptLogin(): Promise { console.log('[JobInfoAuth] Prompting for login with OAuth'); try { // Authenticate with PocketBase OAuth (Microsoft provider) const authData = await this.pb.collection('users').authWithOAuth2({ provider: 'microsoft' }); console.log('[JobInfoAuth] OAuth successful, authData:', authData); // Extract tokens const pbUserToken = authData.token; const graphUserToken = this.extractGraphToken(authData.meta || {}); if (!pbUserToken) { throw new Error('No pb-user token in OAuth response'); } // Get user display name const userInfo = this.getUserInfo(); this.state.userDisplayName = userInfo.displayName || 'Unknown'; // Store tokens this.storeToken('pb-user', pbUserToken); if (graphUserToken) { this.storeToken('graph-user', graphUserToken); } console.log('[JobInfoAuth] Login successful. User:', this.state.userDisplayName); } catch (err) { console.error('[JobInfoAuth] Login failed:', err); throw err; } } /** * Extract Graph token from OAuth metadata */ private extractGraphToken(meta: any): string | null { return ( meta?.graphAccessToken || meta?.graph_token || meta?.graphToken || meta?.accessToken || meta?.access_token || meta?.token || meta?.rawToken || meta?.authData?.access_token || null ); } /** * Refresh all tokens to keep them fresh */ private async refreshAllTokens(): Promise { console.log('[JobInfoAuth] Refreshing all tokens'); try { // Refresh pb-user if we have an active session if (this.pb.authStore.isValid) { console.log('[JobInfoAuth] PocketBase session is valid, refreshing'); const authData = await this.pb.collection('users').authRefresh(); const newPbToken = authData.token; const currentPbToken = this.state['pb-user']?.value; // Only replace pb-user if we got a new token if (newPbToken && newPbToken !== currentPbToken) { console.log('[JobInfoAuth] pb-user token refreshed (new token)'); this.storeToken('pb-user', newPbToken); } else if (newPbToken) { console.log('[JobInfoAuth] pb-user token still fresh, keeping existing'); } // Try to get updated graph token const graphToken = this.extractGraphToken(authData.meta || {}); if (graphToken && graphToken !== this.state['graph-user']?.value) { console.log('[JobInfoAuth] graph-user token refreshed'); this.storeToken('graph-user', graphToken); } } } catch (err) { console.error('[JobInfoAuth] Token refresh error:', err); // Don't throw - token refresh is best-effort } } /** * Store token with metadata */ private storeToken(type: 'pb-user' | 'graph-user', token: string): void { const lastFour = token.substring(token.length - 4); const metadata: TokenMetadata = { value: token, displayName: this.state.userDisplayName, lastFourDigits: lastFour, expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // Assume 7 days acquiredAt: Date.now() }; this.state[type] = metadata; // Persist to localStorage const key = `auth:${type}`; localStorage.setItem(key, JSON.stringify(metadata)); console.log(`[JobInfoAuth] Token stored: ${type} (last 4: ${lastFour})`); } /** * Load tokens from localStorage */ private loadPersistedTokens(): void { try { const pbKey = 'auth:pb-user'; const graphKey = 'auth:graph-user'; const pbData = localStorage.getItem(pbKey); const graphData = localStorage.getItem(graphKey); if (pbData) { this.state['pb-user'] = JSON.parse(pbData); this.state.userDisplayName = this.state['pb-user'].displayName || 'Unknown'; console.log('[JobInfoAuth] Loaded pb-user from localStorage'); } if (graphData) { this.state['graph-user'] = JSON.parse(graphData); console.log('[JobInfoAuth] Loaded graph-user from localStorage'); } } catch (err) { console.error('[JobInfoAuth] Error loading persisted tokens:', err); } } /** * Check if a token is active (exists and not expired) */ private isTokenActive(type: 'pb-user' | 'graph-user'): boolean { const token = this.state[type]; if (!token || !token.value) { return false; } // Check if expired if (token.expiresAt && token.expiresAt < Date.now()) { console.log(`[JobInfoAuth] Token expired: ${type}`); return false; } return true; } /** * Get user info from PocketBase */ private getUserInfo(): { displayName: string; email: string } { const model = this.pb?.authStore?.model; if (!model) { return { displayName: 'Unknown', email: '' }; } return { displayName: model.display_name || model.name || model.email || model.username || 'Unknown', email: model.email || model.username || '' }; } /** * Get current state (for UI) */ getState(): JobInfoAuthState { return { ...this.state }; } /** * Get specific token value */ getToken(type: 'pb-user' | 'graph-user'): string | undefined { return this.state[type]?.value; } /** * Get user display name */ getUserDisplayName(): string { return this.state.userDisplayName; } /** * Logout */ logout(): void { console.log('[JobInfoAuth] Logging out'); this.pb?.authStore?.clear?.(); localStorage.removeItem('auth:pb-user'); localStorage.removeItem('auth:graph-user'); this.state = { userDisplayName: 'Unknown' }; this.initialized = false; } } const JobInfoAuth = new JobInfoAuthFlow(); // Expose globally if (typeof window !== 'undefined') { window.JobInfoAuth = JobInfoAuth; } export default JobInfoAuth;