/** * OAuth Popup Login with Dual Token Acquisition * * Production-ready standalone module for acquiring both pbuser and pbagent tokens * through PocketBase OAuth popup flow. Displays both tokens in the popup UI once acquired. * * ACTUAL PROJECT CONFIGURATION: * - PocketBase URL: http://localhost:8090 * - OAuth Provider: Microsoft (OAuth 2.0 configured in PocketBase) * - Graph Scopes: profile, email, https://graph.microsoft.com/.default * - Service Account: Configured in PocketBase as special user with agent privileges * - Token Expiry: 3600 seconds (1 hour) for both token types * - Storage: localStorage with keys auth:pb-user and auth:pb-agent * * Usage in production: * const loginPopup = new OAuthPopupLogin(); * await loginPopup.open(); * const { pbUser, pbAgent } = loginPopup.getTokens(); * * // Tokens now available in: * // - localStorage.getItem('auth:pb-user') * // - localStorage.getItem('auth:pb-agent') * * Token Types & Storage: * - auth:pb-user: PocketBase user OAuth token (from Microsoft OAuth flow) * - auth:pb-agent: PocketBase service account token (system integration token) * * Dependencies: * - PocketBase SDK: https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js * - localStorage API (browser standard) * - Microsoft OAuth configured in PocketBase admin dashboard * - Service account user created in PocketBase with email/password * * Architecture: * 1. User clicks login → popup opens with OAuth UI * 2. User authenticates with Microsoft → receives pbuser token * 3. PocketBase returns authenticated user record with metadata * 4. Service account credentials used to obtain pbagent token * 5. Both tokens stored in localStorage and displayed in popup * 6. Popup shows success confirmation with token values * 7. Caller retrieves tokens via getTokens() method * * Gotchas & Notes: * - Popup must be opened from user gesture (click/input event) due to browser security * - PocketBase instance uses hardcoded production URL (http://localhost:8090) * - Agent token requires valid service account user in PocketBase * - If agent credentials fail, pbuser token still acquired (graceful degradation) * - Tokens automatically expire in 1 hour; caller must refresh as needed * - Multiple popups can coexist but share token storage */ // Production configuration from project setup const PRODUCTION_CONFIG = { pbUrl: 'http://localhost:8090', provider: 'microsoft' as const, scopes: ['profile', 'email', 'https://graph.microsoft.com/.default'], // Agent credentials sourced from environment or PocketBase service account agentEmail: process.env.POCKETBASE_SERVICE_EMAIL || 'service@job-info.local', agentPassword: process.env.POCKETBASE_SERVICE_PASSWORD || '' }; interface OAuthPopupConfig { pbUrl?: string; provider?: 'google' | 'microsoft' | 'github'; scopes?: string[]; agentEmail?: string; agentPassword?: string; } interface TokenData { type: 'pb-user' | 'pb-agent'; value: string; expiresAt: number; acquiredAt: number; } interface PopupUIState { pbUserToken: TokenData | null; pbAgentToken: TokenData | null; status: 'waiting' | 'acquiring' | 'complete' | 'error'; errorMessage: string | null; } class OAuthPopupLogin { private config: Required; private pb: any = null; private popup: Window | null = null; private uiState: PopupUIState = { pbUserToken: null, pbAgentToken: null, status: 'waiting', errorMessage: null }; private popupElement: HTMLElement | null = null; constructor(overrideConfig?: OAuthPopupConfig) { // Use production config with optional overrides this.config = { pbUrl: overrideConfig?.pbUrl || PRODUCTION_CONFIG.pbUrl, provider: overrideConfig?.provider || PRODUCTION_CONFIG.provider, scopes: overrideConfig?.scopes || PRODUCTION_CONFIG.scopes, agentEmail: overrideConfig?.agentEmail || PRODUCTION_CONFIG.agentEmail, agentPassword: overrideConfig?.agentPassword || PRODUCTION_CONFIG.agentPassword }; } /** * Initialize PocketBase instance */ private initPocketBase(): void { if (this.pb) return; const PocketBase = (window as any).PocketBase; if (!PocketBase) { throw new Error( 'PocketBase SDK not loaded. Include: ' + '' ); } this.pb = new PocketBase(this.config.pbUrl); } /** * Create and display the popup UI */ private createPopupUI(): HTMLElement { const container = document.createElement('div'); container.id = 'oauth-popup-overlay'; container.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 10000; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; `; const popup = document.createElement('div'); popup.style.cssText = ` background: white; border-radius: 12px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); max-width: 500px; width: 90%; padding: 32px; animation: slideUp 0.3s ease-out; `; const style = document.createElement('style'); style.textContent = ` @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .oauth-token-badge { background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; margin: 12px 0; font-family: 'Monaco', 'Courier New', monospace; font-size: 12px; word-break: break-all; } .oauth-token-badge.success { border-color: #10b981; background: #f0fdf4; } .oauth-token-badge.error { border-color: #ef4444; background: #fef2f2; } .oauth-status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; } .oauth-status.pending { background: #f59e0b; animation: pulse 1.5s infinite; } .oauth-status.complete { background: #10b981; } .oauth-status.error { background: #ef4444; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .oauth-close-btn { position: absolute; top: 16px; right: 16px; background: none; border: none; font-size: 24px; cursor: pointer; color: #6b7280; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; } .oauth-close-btn:hover { color: #1f2937; } `; document.head.appendChild(style); const html = `

OAuth Login

Acquiring tokens through secure OAuth flow

Waiting for login...
Will be obtained after user login...
`; popup.innerHTML = html; container.appendChild(popup); // Event listeners const closeBtn = popup.querySelector('.oauth-close-btn') as HTMLElement; const closeBtnBottom = popup.querySelector('#oauth-close-btn-bottom') as HTMLElement; const loginBtn = popup.querySelector('#oauth-login-btn') as HTMLElement; closeBtn?.addEventListener('click', () => this.close()); closeBtnBottom?.addEventListener('click', () => this.close()); loginBtn?.addEventListener('click', () => this.performOAuthLogin()); return container; } /** * Perform OAuth login flow */ private async performOAuthLogin(): Promise { try { this.updateStatus('acquiring', 'Initiating OAuth flow...'); // Authenticate with OAuth provider const authData = await this.pb .collection('users') .authWithOAuth2({ provider: this.config.provider, scopes: this.config.scopes }); // Store pbuser token const pbUserToken: TokenData = { type: 'pb-user', value: authData.token, expiresAt: Date.now() + 3600 * 1000, // 1 hour acquiredAt: Date.now() }; this.uiState.pbUserToken = pbUserToken; localStorage.setItem('auth:pb-user', pbUserToken.value); this.updateTokenDisplay('pb-user', pbUserToken); this.updateStatus('acquiring', 'User token acquired. Obtaining agent token...'); // Obtain pbagent token (via service account authentication) await this.obtainAgentToken(); // Mark as complete this.uiState.status = 'complete'; this.displaySuccess(); } catch (error: any) { const errorMsg = error?.message || 'OAuth authentication failed'; this.updateStatus('error', `Error: ${errorMsg}`); this.uiState.errorMessage = errorMsg; } } /** * Obtain agent token using service account credentials */ private async obtainAgentToken(): Promise { try { // Use service account credentials if provided, otherwise use pbuser's agent privileges const agentEmail = this.config.agentEmail || this.uiState.pbUserToken?.value; const agentPassword = this.config.agentPassword; if (!agentEmail || !agentPassword) { throw new Error('Agent credentials not configured'); } // Authenticate as service account const agentAuth = await this.pb .collection('users') .authWithPassword(agentEmail, agentPassword); const pbAgentToken: TokenData = { type: 'pb-agent', value: agentAuth.token, expiresAt: Date.now() + 3600 * 1000, // 1 hour acquiredAt: Date.now() }; this.uiState.pbAgentToken = pbAgentToken; localStorage.setItem('auth:pb-agent', pbAgentToken.value); this.updateTokenDisplay('pb-agent', pbAgentToken); } catch (error: any) { const errorMsg = error?.message || 'Failed to obtain agent token'; console.warn('[OAuthPopup] Agent token error (non-critical):', errorMsg); // Don't fail completely - agent token is optional this.updateStatus('acquiring', 'User token acquired (agent token unavailable)'); } } /** * Update UI token display */ private updateTokenDisplay(type: 'pb-user' | 'pb-agent', token: TokenData): void { const elementId = type === 'pb-user' ? 'oauth-pb-user-token' : 'oauth-pb-agent-token'; const element = document.getElementById(elementId); if (!element) return; const lastFour = token.value.slice(-4); const expiresAt = new Date(token.expiresAt).toLocaleString(); element.classList.add('success'); element.innerHTML = ` ${token.value}
Expires: ${expiresAt} `; } /** * Update status message */ private updateStatus(status: PopupUIState['status'], message: string): void { this.uiState.status = status; const statusEl = document.getElementById('oauth-status-message'); const errorEl = document.getElementById('oauth-error-message'); if (statusEl) { statusEl.textContent = message; statusEl.style.display = status === 'error' ? 'none' : 'block'; } if (errorEl) { if (status === 'error') { errorEl.textContent = message; errorEl.style.display = 'block'; } else { errorEl.style.display = 'none'; } } } /** * Display success screen */ private displaySuccess(): void { const successSection = document.getElementById('oauth-success-section'); const loginBtn = document.getElementById('oauth-login-btn'); if (successSection) { successSection.style.display = 'block'; const finalTokens = document.getElementById('oauth-final-tokens'); if (finalTokens && this.uiState.pbUserToken) { const pbUserDisplay = `
PB User: ${this.uiState.pbUserToken.value.substring(0, 50)}...
`; const pbAgentDisplay = this.uiState.pbAgentToken ? `
PB Agent: ${this.uiState.pbAgentToken.value.substring(0, 50)}...
` : '
PB Agent: Not available
'; finalTokens.innerHTML = pbUserDisplay + pbAgentDisplay; } } if (loginBtn) { loginBtn.textContent = 'Tokens Acquired ✓'; loginBtn.disabled = true; (loginBtn as HTMLButtonElement).style.opacity = '0.5'; } } /** * Open the popup - call this from user gesture (click event) * Uses production PocketBase URL and Microsoft OAuth configuration */ async open(): Promise { this.initPocketBase(); // Create and display popup UI this.popupElement = this.createPopupUI(); document.body.appendChild(this.popupElement); // Add overlay click to close this.popupElement.addEventListener('click', (e) => { if (e.target === this.popupElement) { this.close(); } }); } /** * Close the popup */ close(): void { if (this.popupElement) { this.popupElement.remove(); this.popupElement = null; } } /** * Get acquired tokens */ getTokens(): { pbUser: TokenData | null; pbAgent: TokenData | null } { return { pbUser: this.uiState.pbUserToken, pbAgent: this.uiState.pbAgentToken }; } /** * Get current UI state */ getState(): PopupUIState { return { ...this.uiState }; } } // Export for use export default OAuthPopupLogin;