499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
/**
|
|
* 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<OAuthPopupConfig>;
|
|
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: ' +
|
|
'<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>'
|
|
);
|
|
}
|
|
|
|
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 = `
|
|
<div style="position: relative;">
|
|
<button class="oauth-close-btn" aria-label="Close">×</button>
|
|
|
|
<h2 style="margin: 0 0 8px 0; font-size: 20px; font-weight: 600; color: #1f2937;">
|
|
OAuth Login
|
|
</h2>
|
|
<p style="margin: 0 0 24px 0; color: #6b7280; font-size: 14px;">
|
|
Acquiring tokens through secure OAuth flow
|
|
</p>
|
|
|
|
<!-- PocketBase User Token -->
|
|
<div>
|
|
<label style="display: block; font-size: 12px; font-weight: 600; color: #374151; margin-bottom: 8px;">
|
|
<span class="oauth-status pending"></span>
|
|
PocketBase User Token
|
|
</label>
|
|
<div id="oauth-pb-user-token" class="oauth-token-badge">
|
|
Waiting for login...
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PocketBase Agent Token -->
|
|
<div>
|
|
<label style="display: block; font-size: 12px; font-weight: 600; color: #374151; margin-bottom: 8px; margin-top: 16px;">
|
|
<span class="oauth-status pending"></span>
|
|
PocketBase Agent Token
|
|
</label>
|
|
<div id="oauth-pb-agent-token" class="oauth-token-badge">
|
|
Will be obtained after user login...
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status Message -->
|
|
<div id="oauth-status-message" style="margin-top: 20px; padding: 12px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 6px; color: #1e40af; font-size: 13px;"></div>
|
|
|
|
<!-- Error Message -->
|
|
<div id="oauth-error-message" style="margin-top: 20px; padding: 12px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; color: #991b1b; font-size: 13px; display: none;"></div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div style="display: flex; gap: 12px; margin-top: 24px;">
|
|
<button id="oauth-login-btn" style="flex: 1; background: #3b82f6; color: white; border: none; border-radius: 6px; padding: 10px 16px; font-weight: 500; cursor: pointer; font-size: 14px;">
|
|
Start ${this.config.provider} Login
|
|
</button>
|
|
<button id="oauth-close-btn-bottom" style="flex: 1; background: #e5e7eb; color: #1f2937; border: none; border-radius: 6px; padding: 10px 16px; font-weight: 500; cursor: pointer; font-size: 14px;">
|
|
Close
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Token Display (after successful acquisition) -->
|
|
<div id="oauth-success-section" style="display: none; margin-top: 24px; padding: 16px; background: #f0fdf4; border: 1px solid #86efac; border-radius: 6px;">
|
|
<h3 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #15803d;">
|
|
✓ Tokens Successfully Acquired
|
|
</h3>
|
|
<div id="oauth-final-tokens" style="font-size: 12px;"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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 = `
|
|
<strong>${token.value}</strong><br>
|
|
<small>Expires: ${expiresAt}</small>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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 = `<div><strong>PB User:</strong> ${this.uiState.pbUserToken.value.substring(0, 50)}...</div>`;
|
|
const pbAgentDisplay = this.uiState.pbAgentToken
|
|
? `<div><strong>PB Agent:</strong> ${this.uiState.pbAgentToken.value.substring(0, 50)}...</div>`
|
|
: '<div><strong>PB Agent:</strong> Not available</div>';
|
|
|
|
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<void> {
|
|
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;
|