fixed login loop
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user