fixed login loop

This commit is contained in:
2026-01-10 19:23:47 +00:00
parent bef80c5bab
commit 6619269840
17 changed files with 5740 additions and 28 deletions
+259
View File
@@ -0,0 +1,259 @@
/**
* Job-Info Auth Flow - Standalone
* Complete auth lifecycle: acquire tokens, refresh, persist, retrieve
* No external imports - loads PocketBase from global scope
*/
class JobInfoAuthFlow {
constructor() {
this.pb = null;
this.tokens = {};
this.init();
}
/**
* Initialize: load persisted tokens, check if active, prompt login if needed
*/
async init() {
console.log('[JobInfoAuth] init() called');
// PocketBase is loaded via script tag
if (typeof PocketBase === 'undefined') {
throw new Error('PocketBase SDK not loaded. Check script tag in HTML.');
}
this.pb = new PocketBase('http://localhost:8090');
console.log('[JobInfoAuth] PocketBase initialized:', this.pb);
// Load tokens from localStorage
this.loadTokens();
console.log('[JobInfoAuth] Tokens loaded from storage:', Object.keys(this.tokens));
// Check if tokens are active
const hasActiveTokens = this.isTokenActive('pb-user') || this.isTokenActive('graph-user');
if (!hasActiveTokens) {
console.log('[JobInfoAuth] No active tokens, prompting login...');
await this.promptLogin();
} else {
console.log('[JobInfoAuth] Tokens active, refreshing...');
await this.refreshAllTokens();
}
console.log('[JobInfoAuth] init() complete');
}
/**
* Prompt user to log in via OAuth popup
*/
async promptLogin() {
console.log('[JobInfoAuth] promptLogin() called');
try {
const authData = await this.pb.collection('users').authWithOAuth2({
provider: 'microsoft'
});
console.log('[JobInfoAuth] OAuth successful, authData:', authData);
// Store PocketBase user token
const pbToken = authData.token;
const displayName = authData.record?.name || authData.record?.email || 'Unknown';
this.storeToken('pb-user', pbToken, displayName);
console.log('[JobInfoAuth] pb-user token stored');
// Extract and store Graph token from OAuth metadata
const graphToken = this.extractGraphToken(authData.meta || {});
if (graphToken) {
this.storeToken('graph-user', graphToken, displayName);
console.log('[JobInfoAuth] graph-user token stored');
} else {
console.log('[JobInfoAuth] No graph token in OAuth response');
}
} catch (err) {
console.error('[JobInfoAuth] OAuth failed:', err);
throw new Error(`Login failed: ${err.message}`);
}
}
/**
* Extract Graph token from OAuth metadata
*/
extractGraphToken(metadata) {
console.log('[JobInfoAuth] extractGraphToken() called, metadata:', metadata);
if (!metadata) return null;
// Microsoft OAuth response includes access_token for Graph
if (metadata.accessToken) {
return metadata.accessToken;
}
if (metadata.access_token) {
return metadata.access_token;
}
console.log('[JobInfoAuth] No Graph token found in metadata');
return null;
}
/**
* Refresh all tokens - keep them fresh, only replace pb-user on new token
*/
async refreshAllTokens() {
console.log('[JobInfoAuth] refreshAllTokens() called');
try {
// Refresh pb-user token
if (this.tokens['pb-user']) {
const oldToken = this.tokens['pb-user'].value;
// Use PocketBase refresh
const authData = await this.pb.collection('users').authRefresh();
const newToken = authData.token;
// Only replace if genuinely new
if (newToken && newToken !== oldToken) {
console.log('[JobInfoAuth] pb-user token refreshed (new token)');
const displayName = this.tokens['pb-user'].displayName;
this.storeToken('pb-user', newToken, displayName);
} else {
console.log('[JobInfoAuth] pb-user token still valid, not replacing');
}
}
// Graph token: don't require for refresh, but use if available
if (this.tokens['graph-user']) {
console.log('[JobInfoAuth] graph-user token exists, keeping it');
// Optionally refresh graph token here if needed
}
} catch (err) {
console.warn('[JobInfoAuth] Token refresh had issues:', err.message);
// Don't throw - tokens might still be valid
}
}
/**
* Store token with metadata
*/
storeToken(type, token, displayName) {
console.log('[JobInfoAuth] storeToken():', type);
const lastFourDigits = token.slice(-4);
const decoded = this.decodeToken(token);
const expiresAt = decoded?.exp ? decoded.exp * 1000 : Date.now() + (3600 * 1000); // Default 1 hour
this.tokens[type] = {
value: token,
displayName,
lastFourDigits,
expiresAt,
storedAt: Date.now()
};
// Persist to localStorage
localStorage.setItem(`job-info-${type}`, JSON.stringify(this.tokens[type]));
console.log(`[JobInfoAuth] ${type} stored, expires at:`, new Date(expiresAt).toISOString());
}
/**
* Load tokens from localStorage
*/
loadTokens() {
console.log('[JobInfoAuth] loadTokens() called');
const types = ['pb-user', 'graph-user', 'pb-agent', 'graph-agent'];
types.forEach(type => {
const stored = localStorage.getItem(`job-info-${type}`);
if (stored) {
try {
this.tokens[type] = JSON.parse(stored);
console.log(`[JobInfoAuth] Loaded ${type} from localStorage`);
} catch (err) {
console.warn(`[JobInfoAuth] Failed to parse ${type}:`, err);
localStorage.removeItem(`job-info-${type}`);
}
}
});
}
/**
* Check if token is active (exists and not expired)
*/
isTokenActive(type) {
const token = this.tokens[type];
if (!token) {
console.log(`[JobInfoAuth] isTokenActive(${type}): no token stored`);
return false;
}
const isExpired = token.expiresAt && Date.now() > token.expiresAt;
if (isExpired) {
console.log(`[JobInfoAuth] isTokenActive(${type}): expired`);
return false;
}
console.log(`[JobInfoAuth] isTokenActive(${type}): active`);
return true;
}
/**
* Get current auth state
*/
getState() {
return {
'pb-user': this.tokens['pb-user'] || null,
'graph-user': this.tokens['graph-user'] || null,
'pb-agent': this.tokens['pb-agent'] || null,
'graph-agent': this.tokens['graph-agent'] || null,
userDisplayName: this.tokens['pb-user']?.displayName || 'Unknown'
};
}
/**
* Logout - clear all tokens
*/
logout() {
console.log('[JobInfoAuth] logout() called');
const types = ['pb-user', 'graph-user', 'pb-agent', 'graph-agent'];
types.forEach(type => {
localStorage.removeItem(`job-info-${type}`);
});
this.tokens = {};
if (this.pb) {
this.pb.authStore.clear();
}
console.log('[JobInfoAuth] All tokens cleared');
}
/**
* Decode JWT token to get exp claim
*/
decodeToken(token) {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const decoded = JSON.parse(atob(parts[1]));
return decoded;
} catch (err) {
console.warn('[JobInfoAuth] Failed to decode token:', err);
return null;
}
}
}
// Create singleton instance
const JobInfoAuth = new JobInfoAuthFlow();
// Expose globally
if (typeof window !== 'undefined') {
window.JobInfoAuth = JobInfoAuth;
}
+294
View File
@@ -0,0 +1,294 @@
/**
* 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<void> {
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: <script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>');
}
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<void> {
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<void> {
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;
+242
View File
@@ -0,0 +1,242 @@
/**
* Token Status UI - Standalone
* Displays token statuses, expiration times, last 4 digits
* No external imports - uses window.JobInfoAuth from global scope
*/
class TokenStatusUI {
constructor() {
this.container = null;
this.refreshInterval = null;
}
/**
* Create and show token status UI
*/
create(containerId = 'app') {
console.log('[TokenUI] create() called with container:', containerId);
this.container = document.getElementById(containerId);
if (!this.container) {
console.error('[TokenUI] Container not found:', containerId);
return;
}
this.render();
// Refresh UI every second to update expiration countdown
this.refreshInterval = window.setInterval(() => {
this.render();
}, 1000);
console.log('[TokenUI] UI created and refresh interval started');
}
/**
* Render token status UI
*/
render() {
if (!this.container) return;
const state = this.getState();
const pbStatus = state.pbUser.active ? '✓ Active' : '✗ Inactive';
const pbStatusColor = state.pbUser.active ? '#10b981' : '#ef4444';
const graphStatus = state.graphUser.active ? '✓ Active' : '○ Not Required';
const graphStatusColor = state.graphUser.active ? '#10b981' : '#6b7280';
this.container.innerHTML = `
<div style="
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
">
<div style="
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 40px;
max-width: 600px;
width: 100%;
">
<!-- Header -->
<div style="margin-bottom: 30px;">
<h1 style="font-size: 28px; font-weight: bold; color: #1f2937; margin: 0 0 10px 0;">
Job Info Auth
</h1>
<p style="color: #6b7280; margin: 0; font-size: 14px;">
User: <strong>${state.userDisplayName}</strong>
</p>
</div>
<!-- PocketBase User Token -->
<div style="
margin-bottom: 24px;
padding: 20px;
background: #f9fafb;
border-radius: 8px;
border-left: 4px solid ${pbStatusColor};
">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h2 style="font-size: 14px; font-weight: 600; color: #1f2937; margin: 0;">
PocketBase User Token
</h2>
<span style="color: ${pbStatusColor}; font-weight: 600; font-size: 13px;">
${pbStatus}
</span>
</div>
<p style="color: #6b7280; font-size: 12px; margin: 8px 0; font-family: monospace;">
Token: ...${state.pbUser.lastFour}
</p>
<p style="color: #6b7280; font-size: 12px; margin: 8px 0;">
Expires in: <strong style="color: #1f2937;">${state.pbUser.expiresIn}</strong>
</p>
</div>
<!-- Graph User Token -->
<div style="
margin-bottom: 24px;
padding: 20px;
background: #f9fafb;
border-radius: 8px;
border-left: 4px solid ${graphStatusColor};
">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h2 style="font-size: 14px; font-weight: 600; color: #1f2937; margin: 0;">
Microsoft Graph Token
</h2>
<span style="color: ${graphStatusColor}; font-weight: 600; font-size: 13px;">
${graphStatus}
</span>
</div>
${state.graphUser.active ? `
<p style="color: #6b7280; font-size: 12px; margin: 8px 0; font-family: monospace;">
Token: ...${state.graphUser.lastFour}
</p>
<p style="color: #6b7280; font-size: 12px; margin: 8px 0;">
Expires in: <strong style="color: #1f2937;">${state.graphUser.expiresIn}</strong>
</p>
` : `
<p style="color: #6b7280; font-size: 12px; margin: 8px 0;">
Graph token is optional. Login proceeded without it.
</p>
`}
</div>
<!-- Actions -->
<div style="display: flex; gap: 10px;">
<button onclick="window.JobInfoAuth.logout(); location.reload();" style="
flex: 1;
padding: 12px;
background: #ef4444;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
">
Logout
</button>
<button onclick="location.reload();" style="
flex: 1;
padding: 12px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
">
Refresh
</button>
</div>
<!-- Debug Info -->
<div style="
margin-top: 30px;
padding: 15px;
background: #f0f9ff;
border-radius: 6px;
border: 1px solid #bfdbfe;
font-size: 11px;
color: #1e40af;
font-family: monospace;
word-break: break-all;
">
<strong>Debug Info:</strong><br>
pb-user: ${state.pbUser.active ? 'active' : 'inactive'} | graph-user: ${state.graphUser.active ? 'active' : 'inactive'}
</div>
</div>
</div>
`;
}
/**
* Get current token state
*/
getState() {
if (!window.JobInfoAuth) {
return {
pbUser: { active: false, lastFour: 'none', expiresIn: 'unknown' },
graphUser: { active: false, lastFour: 'none', expiresIn: 'unknown' },
userDisplayName: 'Unknown'
};
}
const auth = window.JobInfoAuth;
const authState = auth.getState();
const calculateTimeLeft = (expiresAt) => {
if (!expiresAt) return 'unknown';
const now = Date.now();
const diff = expiresAt - now;
if (diff < 0) return 'expired';
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
return {
pbUser: {
active: !!authState['pb-user'],
lastFour: authState['pb-user']?.lastFourDigits || 'none',
expiresIn: calculateTimeLeft(authState['pb-user']?.expiresAt),
expiresAt: authState['pb-user']?.expiresAt
},
graphUser: {
active: !!authState['graph-user'],
lastFour: authState['graph-user']?.lastFourDigits || 'none',
expiresIn: calculateTimeLeft(authState['graph-user']?.expiresAt),
expiresAt: authState['graph-user']?.expiresAt
},
userDisplayName: authState.userDisplayName || 'Unknown'
};
}
/**
* Destroy UI and cleanup
*/
destroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
}
// Create singleton instance
const TokenUI = new TokenStatusUI();
// Expose globally
if (typeof window !== 'undefined') {
window.TokenUI = TokenUI;
}
+228
View File
@@ -0,0 +1,228 @@
/**
* Token Status UI - Standalone
* Displays token statuses, expiration times, last 4 digits
*/
class TokenStatusUI {
private container: HTMLElement | null = null;
private refreshInterval: number | null = null;
/**
* Create and show token status UI
*/
create(containerId: string = 'app'): void {
this.container = document.getElementById(containerId);
if (!this.container) {
console.error('[TokenUI] Container not found:', containerId);
return;
}
this.render();
// Refresh UI every second to update expiration countdown
this.refreshInterval = window.setInterval(() => {
this.render();
}, 1000);
}
/**
* Render token status UI
*/
private render(): void {
if (!this.container) return;
const state = this.getState();
const pbStatus = state.pbUser.active ? '✓ Active' : '✗ Inactive';
const pbStatusColor = state.pbUser.active ? '#10b981' : '#ef4444';
const graphStatus = state.graphUser.active ? '✓ Active' : '○ Not Required';
const graphStatusColor = state.graphUser.active ? '#10b981' : '#6b7280';
this.container.innerHTML = `
<div style="
min-h-screen;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
">
<div style="
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 40px;
max-width: 600px;
width: 100%;
">
<!-- Header -->
<div style="margin-bottom: 30px;">
<h1 style="font-size: 28px; font-weight: bold; color: #1f2937; margin: 0 0 10px 0;">
Job Info Auth
</h1>
<p style="color: #6b7280; margin: 0; font-size: 14px;">
User: <strong>${state.userDisplayName}</strong>
</p>
</div>
<!-- PocketBase User Token -->
<div style="
margin-bottom: 24px;
padding: 20px;
background: #f9fafb;
border-radius: 8px;
border-left: 4px solid ${pbStatusColor};
">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h2 style="font-size: 14px; font-weight: 600; color: #1f2937; margin: 0;">
PocketBase User Token
</h2>
<span style="color: ${pbStatusColor}; font-weight: 600; font-size: 13px;">
${pbStatus}
</span>
</div>
<p style="color: #6b7280; font-size: 12px; margin: 8px 0; font-family: monospace;">
Token: ...${state.pbUser.lastFour}
</p>
<p style="color: #6b7280; font-size: 12px; margin: 8px 0;">
Expires in: <strong style="color: #1f2937;">${state.pbUser.expiresIn}</strong>
</p>
</div>
<!-- Graph User Token -->
<div style="
margin-bottom: 24px;
padding: 20px;
background: #f9fafb;
border-radius: 8px;
border-left: 4px solid ${graphStatusColor};
">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h2 style="font-size: 14px; font-weight: 600; color: #1f2937; margin: 0;">
Microsoft Graph Token
</h2>
<span style="color: ${graphStatusColor}; font-weight: 600; font-size: 13px;">
${graphStatus}
</span>
</div>
${state.graphUser.active ? `
<p style="color: #6b7280; font-size: 12px; margin: 8px 0; font-family: monospace;">
Token: ...${state.graphUser.lastFour}
</p>
<p style="color: #6b7280; font-size: 12px; margin: 8px 0;">
Expires in: <strong style="color: #1f2937;">${state.graphUser.expiresIn}</strong>
</p>
` : `
<p style="color: #6b7280; font-size: 12px; margin: 8px 0;">
Graph token is optional. Login proceeded without it.
</p>
`}
</div>
<!-- Actions -->
<div style="display: flex; gap: 10px;">
<button onclick="window.JobInfoAuth.logout(); location.reload();" style="
flex: 1;
padding: 12px;
background: #ef4444;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
">
Logout
</button>
<button onclick="location.reload();" style="
flex: 1;
padding: 12px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
">
Refresh
</button>
</div>
<!-- Debug Info -->
<div style="
margin-top: 30px;
padding: 15px;
background: #f0f9ff;
border-radius: 6px;
border: 1px solid #bfdbfe;
font-size: 11px;
color: #1e40af;
font-family: monospace;
word-break: break-all;
">
<strong>Debug Info:</strong><br>
pb-user: ${state.pbUser.active ? 'active' : 'inactive'} | graph-user: ${state.graphUser.active ? 'active' : 'inactive'}
</div>
</div>
</div>
`;
}
/**
* Get current token state
*/
private getState() {
const auth = window.JobInfoAuth;
const authState = auth.getState();
const calculateTimeLeft = (expiresAt) => {
if (!expiresAt) return 'unknown';
const now = Date.now();
const diff = expiresAt - now;
if (diff < 0) return 'expired';
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
return {
pbUser: {
active: !!authState['pb-user'],
lastFour: authState['pb-user']?.lastFourDigits || 'none',
expiresIn: calculateTimeLeft(authState['pb-user']?.expiresAt),
expiresAt: authState['pb-user']?.expiresAt
},
graphUser: {
active: !!authState['graph-user'],
lastFour: authState['graph-user']?.lastFourDigits || 'none',
expiresIn: calculateTimeLeft(authState['graph-user']?.expiresAt),
expiresAt: authState['graph-user']?.expiresAt
},
userDisplayName: authState.userDisplayName || 'Unknown'
};
}
/**
* Destroy UI and cleanup
*/
destroy(): void {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
}
const TokenUI = new TokenStatusUI();
// Expose globally
if (typeof window !== 'undefined') {
window.TokenUI = TokenUI;
}
export default TokenUI;