besper-frontend-site-dev-main
Version:
Professional B-esper Frontend Site - Site-wide integration toolkit for full website bot deployment
818 lines (718 loc) • 25.5 kB
JavaScript
/**
* Smart Token Authentication Service
* Handles PowerPages authentication tokens with intelligent generation, storage, and cross-page persistence
* Implements foreign key-based authorization using Microsoft security system
*
* Features:
* - Smart token sharing: Multiple functions share the same token generation process
* - Cross-page persistence: Tokens stored in browser storage for navigation
* - Intelligent refresh: Automatic token validation and refresh
* - Central coordination: Prevents duplicate token generation requests
*
* PowerPages Integration Pattern:
* {% if user %}
* {% assign bsp_token = user.bsp_token %}
* {% assign contact_id = user.contactid %}
* {% endif %}
*
* Pass the token directly to b_esper_site functions
*/
import { getRootApiEndpoint } from './centralizedApi.js';
class TokenAuthService {
constructor() {
this.token = null;
this.contactId = null;
this.isAuthenticated = false;
this.userPermissions = new Map();
// Smart token management
this.tokenGenerationPromise = null; // Shared promise for concurrent requests
this.tokenExpiry = null;
this.storageKeys = {
token: 'bsp_auth_token',
contactId: 'bsp_contact_id',
userPermissions: 'bsp_user_permissions',
tokenExpiry: 'bsp_token_expiry',
authenticated: 'bsp_authenticated',
};
// Initialize from stored data first, then PowerPages
this.initializeFromStorage();
this.initializeFromLiquidData();
}
/**
* Initialize from browser storage for cross-page persistence
* Check if valid token exists from previous page visits
*/
initializeFromStorage() {
if (typeof window === 'undefined' || typeof localStorage === 'undefined')
return;
try {
const storedToken = localStorage.getItem(this.storageKeys.token);
const storedExpiry = localStorage.getItem(this.storageKeys.tokenExpiry);
const storedAuthenticated = localStorage.getItem(
this.storageKeys.authenticated
);
if (storedToken && storedExpiry && storedAuthenticated === 'true') {
const expiryTime = parseInt(storedExpiry, 10);
const currentTime = Date.now();
// Check if token is still valid (not expired)
if (currentTime < expiryTime) {
const storedContactId = localStorage.getItem(
this.storageKeys.contactId
);
const storedPermissions = localStorage.getItem(
this.storageKeys.userPermissions
);
this.token = storedToken;
this.contactId = storedContactId;
this.tokenExpiry = expiryTime;
this.isAuthenticated = true;
// Restore user permissions
if (storedPermissions) {
try {
const permissions = JSON.parse(storedPermissions);
this.userPermissions = new Map(Object.entries(permissions));
} catch (e) {
console.warn('[Token] Failed to parse stored permissions:', e);
}
}
console.log('[Token] ♻️ Authentication restored from storage');
return;
} else {
// Token expired, clear storage
this.clearStoredAuthentication();
console.log('[Token] 🕐 Stored token expired, cleared from storage');
}
}
} catch (error) {
console.warn('[Token] Failed to restore from storage:', error);
this.clearStoredAuthentication();
}
}
/**
* Initialize authentication from PowerPages liquid template data
* PowerPages provides token directly via liquid template: {{ user.bsp_token }}
* Smart system - stores for cross-page use
*/
initializeFromLiquidData() {
if (typeof window === 'undefined') return;
// Skip if already authenticated from storage
if (this.isAuthenticated && this.token) {
console.log(
'[Token] [SUCCESS] Already authenticated from storage, skipping PowerPages init'
);
return;
}
// Use original token generation from PowerPages liquid template
if (window.PowerPagesAuth && window.PowerPagesAuth.token) {
this.setAuthenticationDataWithStorage(window.PowerPagesAuth);
return;
}
// Original global variables from liquid template
if (window.bsp_token) {
this.setAuthenticationDataWithStorage({
token: window.bsp_token,
contactId: window.contact_id || window.user_contactid,
userId: window.user_id,
workspaceId: window.workspace_id,
accountId: window.account_id,
});
return;
}
console.log('[Token] No authentication token available from PowerPages');
}
/**
* Set authentication data with smart storage for cross-page persistence
* Called when page provides pre-fetched authentication data
*/
setAuthenticationDataWithStorage(authData, options = {}) {
if (!authData || !authData.token) {
this.isAuthenticated = false;
this.token = null;
console.log('[Token] No authentication data provided');
return;
}
this.setAuthenticationData(authData);
// Store in browser storage for cross-page persistence
this.storeAuthentication(options.tokenLifetime);
}
/**
* Set authentication data (internal method)
*/
setAuthenticationData(authData) {
if (!authData || !authData.token) {
this.isAuthenticated = false;
this.token = null;
return;
}
this.token = authData.token;
this.contactId = authData.contactId;
this.isAuthenticated = true;
// Store user permissions for authorization checks
this.userPermissions.set('contactId', authData.contactId);
this.userPermissions.set('userId', authData.userId);
this.userPermissions.set('workspaceId', authData.workspaceId);
this.userPermissions.set('accountId', authData.accountId);
this.userPermissions.set('subscriptionId', authData.subscriptionId);
console.log('[Token] 🔐 Authentication data set successfully');
}
/**
* Store authentication data in browser storage for cross-page persistence
*/
storeAuthentication(tokenLifetimeMs = 30 * 60 * 1000) {
// Default: 30 minutes
if (typeof window === 'undefined' || typeof localStorage === 'undefined')
return;
try {
const expiryTime = Date.now() + tokenLifetimeMs;
localStorage.setItem(this.storageKeys.token, this.token);
localStorage.setItem(this.storageKeys.contactId, this.contactId || '');
localStorage.setItem(this.storageKeys.authenticated, 'true');
localStorage.setItem(this.storageKeys.tokenExpiry, expiryTime.toString());
// Store user permissions as JSON
const permissionsObj = Object.fromEntries(this.userPermissions);
localStorage.setItem(
this.storageKeys.userPermissions,
JSON.stringify(permissionsObj)
);
this.tokenExpiry = expiryTime;
console.log('[Token] 💾 Authentication stored for cross-page use');
} catch (error) {
console.warn('[Token] Failed to store authentication:', error);
}
}
/**
* Clear stored authentication data
*/
clearStoredAuthentication() {
if (typeof window === 'undefined' || typeof localStorage === 'undefined')
return;
try {
Object.values(this.storageKeys).forEach(key => {
localStorage.removeItem(key);
});
console.log('[Token] 🗑️ Stored authentication cleared');
} catch (error) {
console.warn('[Token] Failed to clear stored authentication:', error);
}
}
/**
* Initialize page with authentication data from b_esper_site function call
* Smart system - uses stored token if available, otherwise stores new token
*/
initializeFromPageData(pageData) {
// If already authenticated from storage and no new page data, keep existing
if (this.isAuthenticated && this.token && !pageData) {
console.log('[Token] Using existing stored authentication');
return;
}
if (pageData && pageData.authData) {
this.setAuthenticationDataWithStorage(pageData.authData);
} else if (pageData && pageData.token) {
// Direct token provided
this.setAuthenticationDataWithStorage({
token: pageData.token,
contactId: pageData.contactId,
userId: pageData.userId,
workspaceId: pageData.workspaceId,
accountId: pageData.accountId,
});
} else if (!this.isAuthenticated) {
// No auth data provided and no stored auth
console.log('[Token] No authentication data available');
this.isAuthenticated = false;
this.token = null;
}
}
/**
* Verify contact access - checks if the current user can access their own contact record
* Used for authorization on entities like support tickets that don't have dataverse equivalent
*/
async verifyContactAccess() {
if (!this.isAuthenticated || !this.contactId) {
console.log('[Token] Cannot verify contact access - not authenticated');
return false;
}
try {
// Verify the contact can access their own contact record (read permission)
return await this.canAccessEntity('contact', this.contactId, ['read']);
} catch (error) {
console.error('[Token] Contact verification failed:', error);
return false;
}
}
/**
* Smart token validation - checks storage and expiry
*/
isTokenValid() {
if (!this.isAuthenticated || !this.token) {
return false;
}
// Check if token is expired
if (this.tokenExpiry && Date.now() >= this.tokenExpiry) {
console.log('[Token] Token expired, clearing authentication');
this.clearStoredAuthentication();
this.isAuthenticated = false;
this.token = null;
return false;
}
return true;
}
/**
* Smart token generation with concurrent request coordination
* Multiple functions calling this will share the same generation process
*/
async getTokenSmart() {
// If token is valid, return it immediately
if (this.isTokenValid()) {
console.log('[Token] ⚡ Using existing valid token');
return this.token;
}
// If token generation is already in progress, wait for it
if (this.tokenGenerationPromise) {
console.log('[Token] [LOADING] Token generation in progress, waiting...');
try {
const result = await this.tokenGenerationPromise;
return result.token;
} catch (error) {
console.error('[Token] Shared token generation failed:', error);
return null;
}
}
// Start new token generation process
console.log('[Token] [INIT] Starting new token generation');
this.tokenGenerationPromise = this.generateTokenFromPowerPages();
try {
const result = await this.tokenGenerationPromise;
this.tokenGenerationPromise = null; // Clear promise after completion
return result.token;
} catch (error) {
this.tokenGenerationPromise = null; // Clear promise on error
console.error('[Token] Token generation failed:', error);
return null;
}
}
/**
* Generate token from PowerPages authentication endpoint
*/
async generateTokenFromPowerPages() {
try {
if (typeof window === 'undefined') {
throw new Error('Window not available');
}
// Try to get token from PowerPages /_services/auth/token endpoint
const response = await fetch('/_services/auth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Include cookies for PowerPages auth
});
if (!response.ok) {
throw new Error(`Token generation failed: ${response.status}`);
}
const tokenData = await response.json();
if (tokenData.token) {
// Store the generated token
this.setAuthenticationDataWithStorage({
token: tokenData.token,
contactId: tokenData.contactId || tokenData.contact_id,
userId: tokenData.userId || tokenData.user_id,
workspaceId: tokenData.workspaceId || tokenData.workspace_id,
accountId: tokenData.accountId || tokenData.account_id,
});
console.log('[Token] [SUCCESS] Token generated from PowerPages');
return {
success: true,
token: tokenData.token,
source: 'powerpages_endpoint',
};
} else {
throw new Error('No token in response');
}
} catch (error) {
console.error('[Token] PowerPages token generation failed:', error);
// Fallback: try to use existing global variables
if (window.bsp_token) {
this.setAuthenticationDataWithStorage({
token: window.bsp_token,
contactId: window.contact_id || window.user_contactid,
userId: window.user_id,
workspaceId: window.workspace_id,
accountId: window.account_id,
});
console.log(
'[Token] [SUCCESS] Using existing global token as fallback'
);
return {
success: true,
token: window.bsp_token,
source: 'global_fallback',
};
}
throw new Error('No authentication available');
}
}
/**
* Check if user can access a specific entity
* Uses foreign key relationships to workspace/account and subscription/cost pool
*/
async canAccessEntity(entityType, entityId, requiredPermissions = []) {
if (!this.isAuthenticated || !this.token) {
return false;
}
try {
// For entities with direct dataverse equivalent, check permissions directly
if (this.hasDirectDataverseEquivalent(entityType)) {
return await this.checkDataversePermissions(
entityType,
entityId,
requiredPermissions
);
}
// For entities like support tickets or notifications without dataverse equivalent,
// check if user can access connected dataverse entities
return await this.checkForeignKeyPermissions(
entityType,
entityId,
requiredPermissions
);
} catch (error) {
console.error('[Token] Permission check failed:', error);
return false;
}
}
/**
* Check if entity type has direct dataverse equivalent
*/
hasDirectDataverseEquivalent(entityType) {
const dataverseEntities = [
'contact',
'account',
'workspace',
'subscription',
'bot_config',
'user',
'organization',
'billing',
];
return dataverseEntities.includes(entityType);
}
/**
* Check permissions for entities with direct dataverse equivalent
*/
async checkDataversePermissions(entityType, entityId, _requiredPermissions) {
try {
if (typeof window === 'undefined' || !window.$) {
return false;
}
const response = await window.$.ajax({
url: `${getRootApiEndpoint()}/permissions/check`,
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
data: JSON.stringify({
entityType,
entityId,
permissions: _requiredPermissions,
}),
});
return response.hasAccess === true;
} catch (error) {
console.error('[Token] Dataverse permission check failed:', error);
return false;
}
}
/**
* Check permissions using foreign key relationships
* For entities like support tickets, notifications that reference workspace/account and subscription
*/
async checkForeignKeyPermissions(entityType, entityId, _requiredPermissions) {
try {
// Get foreign key references for the entity
const foreignKeys = await this.getForeignKeyReferences(
entityType,
entityId
);
if (!foreignKeys) {
return false;
}
// Check if user can access the referenced workspace/account
if (foreignKeys.workspaceId || foreignKeys.accountId) {
const workspaceAccess = foreignKeys.workspaceId
? await this.checkDataversePermissions(
'workspace',
foreignKeys.workspaceId,
['read']
)
: true;
const accountAccess = foreignKeys.accountId
? await this.checkDataversePermissions(
'account',
foreignKeys.accountId,
['read']
)
: true;
if (!workspaceAccess || !accountAccess) {
return false;
}
}
// Check if user can access the referenced subscription/cost pool
if (foreignKeys.subscriptionId) {
const subscriptionAccess = await this.checkDataversePermissions(
'subscription',
foreignKeys.subscriptionId,
['read']
);
if (!subscriptionAccess) {
return false;
}
}
return true;
} catch (error) {
console.error('[Token] Foreign key permission check failed:', error);
return false;
}
}
/**
* Get foreign key references for an entity
*/
async getForeignKeyReferences(entityType, entityId) {
try {
if (typeof window === 'undefined' || !window.$) {
return null;
}
const response = await window.$.ajax({
url: `${getRootApiEndpoint()}/entities/${entityType}/${entityId}/foreign-keys`,
method: 'GET',
headers: {
Authorization: `Bearer ${this.token}`,
},
});
return {
workspaceId: response.workspace_id || response.related_workspace,
accountId: response.account_id || response.related_account,
subscriptionId: response.subscription_id || response.cost_pool_id,
};
} catch (error) {
console.error('[Token] Failed to get foreign keys:', error);
return null;
}
}
/**
* Check if user is authenticated
* Original token generation only - no fallbacks
*/
isUserAuthenticated() {
// Simply return the authentication state without any fallback mechanisms
// If authentication fails, it fails honestly
return this.isAuthenticated && this.token;
}
/**
* Check if user is authenticated with retry logic for token generation
* Original token generation only - no fallbacks, honest about failures
*/
async isUserAuthenticatedWithRetry(maxWaitTime = 5000, checkInterval = 250) {
const startTime = Date.now();
console.log('[Token] Checking authentication with retry logic...');
while (Date.now() - startTime < maxWaitTime) {
// Check current authentication state
if (this.isUserAuthenticated()) {
console.log('[Token] Authentication confirmed');
return true;
}
// Wait for a bit before retrying
await new Promise(resolve => setTimeout(resolve, checkInterval));
}
// No token found after waiting - be honest about authentication failure
console.log('[Token] Authentication failed - no valid token found');
return false;
}
/**
* Generate token as a promise - core lifecycle method
* Original token generation only - no fallbacks
*/
async generateTokenPromise(maxWaitTime = 8000, checkInterval = 500) {
console.log('[Token] [LOADING] Starting token generation...');
return new Promise((resolve, reject) => {
const startTime = Date.now();
let attempts = 0;
const maxAttempts = Math.ceil(maxWaitTime / checkInterval);
const checkForToken = () => {
attempts++;
const elapsed = Date.now() - startTime;
// Only log to console, never show user-facing messages
console.log(
`[Token] Token check attempt ${attempts}/${maxAttempts} (${elapsed}ms elapsed)`
);
// Check if token is available from any source
if (this.isUserAuthenticated()) {
console.log(`[Token] [SUCCESS] Token found after ${elapsed}ms`);
resolve({
success: true,
token: this.getToken(),
elapsed,
attempts,
source: 'authenticated',
});
return;
}
// Check if we should continue waiting
if (elapsed < maxWaitTime && attempts < maxAttempts) {
setTimeout(checkForToken, checkInterval);
} else {
// No token found after waiting - be honest about authentication failure
console.log(`[Token] Authentication failed after ${elapsed}ms`);
reject({
success: false,
error: 'Authentication timeout - no valid token found',
elapsed,
attempts,
});
}
};
// Start checking for token
checkForToken();
});
}
/**
* Get current token with smart generation if needed
* This is the main method functions should call
*/
async getToken() {
return await this.getTokenSmart();
}
/**
* Get current token synchronously (for backward compatibility)
* Returns null if no token is immediately available
*/
getTokenSync() {
if (this.isTokenValid()) {
return this.token;
}
return null;
}
/**
* Get complete auth data for APIM operator calls
* Returns authentication data including token and user permissions
*/
getAuthData() {
if (!this.isAuthenticated || !this.token) {
throw new Error('No authentication data available');
}
return {
token: this.token,
contactId: this.contactId,
userId: this.getUserPermission('userId'),
userName: this.getUserPermission('userName'),
userEmail: this.getUserPermission('userEmail'),
workspaceId: this.getUserPermission('workspaceId'),
accountId: this.getUserPermission('accountId'),
subscriptionId: this.getUserPermission('subscriptionId'),
isAuthenticated: this.isAuthenticated,
};
}
getUserPermission(key) {
return this.userPermissions.get(key);
}
/**
* Cleanup resources and clear stored data
*/
destroy() {
this.token = null;
this.isAuthenticated = false;
this.userPermissions.clear();
this.tokenGenerationPromise = null;
this.tokenExpiry = null;
this.clearStoredAuthentication();
}
}
// Global token manager functions for easy access
// DEPRECATED: These are now handled by professionalCookieTokenManager.js
// Keeping for backward compatibility but they delegate to professionalTokenManager
// Import is handled in the global section below to avoid unused import warning
const globalTokenManager = {
/**
* Generate token with no arguments - DEPRECATED
* Use professionalTokenManager instead
*/
async generateToken() {
console.warn(
'[TokenAuth] generateToken is deprecated - use professionalTokenManager.getToken()'
);
// Import professionalTokenManager and delegate
if (typeof window !== 'undefined' && window.professionalTokenManager) {
return window.professionalTokenManager.getToken('deprecated-tokenAuth');
}
return null; // Prevent infinite loops
},
/**
* Get current token if available - DEPRECATED
*/
getCurrentToken() {
console.warn(
'[TokenAuth] getCurrentToken is deprecated - use professionalTokenManager.getTokenSync()'
);
if (typeof window !== 'undefined' && window.professionalTokenManager) {
return window.professionalTokenManager.getTokenSync();
}
return tokenAuthService.getTokenSync();
},
/**
* Check if user is authenticated - DEPRECATED
*/
isAuthenticated() {
if (typeof window !== 'undefined' && window.professionalTokenManager) {
return window.professionalTokenManager.isAuthenticated();
}
return tokenAuthService.isTokenValid();
},
/**
* Get user information - DEPRECATED
*/
getUserInfo() {
if (typeof window !== 'undefined' && window.professionalTokenManager) {
return window.professionalTokenManager.getUserInfo();
}
if (!tokenAuthService.isTokenValid()) return null;
return {
contactId: tokenAuthService.contactId,
userId: tokenAuthService.getUserPermission('userId'),
workspaceId: tokenAuthService.getUserPermission('workspaceId'),
accountId: tokenAuthService.getUserPermission('accountId'),
subscriptionId: tokenAuthService.getUserPermission('subscriptionId'),
};
},
/**
* Clear all authentication data - DEPRECATED
*/
clearToken() {
if (typeof window !== 'undefined' && window.professionalTokenManager) {
return window.professionalTokenManager.clearAllTokens();
}
tokenAuthService.destroy();
},
/**
* Refresh token by clearing current and generating new - DEPRECATED
*/
async refreshToken() {
console.warn(
'[TokenAuth] refreshToken is deprecated - use professionalTokenManager.forceRefresh()'
);
if (typeof window !== 'undefined' && window.professionalTokenManager) {
return window.professionalTokenManager.forceRefresh(
'deprecated-tokenAuth'
);
}
return null; // Prevent infinite loops
},
};
// NOTE: All token functions are now handled by professionalCookieTokenManager.js
// This service only handles authentication detection and token validation
// Do NOT export global token functions from here to prevent conflicts
// Create singleton instance
const tokenAuthService = new TokenAuthService();
// Export singleton and class for different use cases
export default tokenAuthService;
export { TokenAuthService, globalTokenManager };