// modules/api-manager/PermissionManager.js import { Logger } from '../../core/logger.js'; /** * Permission Management System for Web APIs */ export class PermissionManager { constructor(config = {}) { this.config = config; this.permissionCache = new Map(); this.permissionWatchers = new Map(); this.requestQueue = new Map(); // Check Permissions API support this.support = { permissions: 'permissions' in navigator, query: navigator.permissions?.query !== undefined, request: 'requestPermission' in Notification || 'getUserMedia' in (navigator.mediaDevices || {}), geolocation: 'geolocation' in navigator, notifications: 'Notification' in window, camera: navigator.mediaDevices !== undefined, microphone: navigator.mediaDevices !== undefined, clipboard: 'clipboard' in navigator, vibration: 'vibrate' in navigator }; // Permission mappings for different APIs this.permissionMap = { camera: { name: 'camera', api: 'mediaDevices' }, microphone: { name: 'microphone', api: 'mediaDevices' }, geolocation: { name: 'geolocation', api: 'geolocation' }, notifications: { name: 'notifications', api: 'notification' }, 'clipboard-read': { name: 'clipboard-read', api: 'clipboard' }, 'clipboard-write': { name: 'clipboard-write', api: 'clipboard' }, 'background-sync': { name: 'background-sync', api: 'serviceWorker' }, 'persistent-storage': { name: 'persistent-storage', api: 'storage' }, 'push': { name: 'push', api: 'serviceWorker' }, 'midi': { name: 'midi', api: 'midi' }, 'payment-handler': { name: 'payment-handler', api: 'payment' } }; Logger.info('[PermissionManager] Initialized with support:', this.support); // Auto-cache current permissions this.cacheCurrentPermissions(); } /** * Check permission status for a specific permission */ async check(permission) { if (!this.support.permissions || !this.support.query) { Logger.warn('[PermissionManager] Permissions API not supported, using fallback'); return this.checkFallback(permission); } try { // Check cache first const cached = this.permissionCache.get(permission); if (cached && Date.now() - cached.timestamp < 30000) { // 30s cache return cached.status; } const permissionDescriptor = this.getPermissionDescriptor(permission); const status = await navigator.permissions.query(permissionDescriptor); const result = { name: permission, state: status.state, timestamp: Date.now(), supported: true }; this.permissionCache.set(permission, result); // Watch for changes status.addEventListener('change', () => { this.onPermissionChange(permission, status.state); }); Logger.info(`[PermissionManager] Permission checked: ${permission} = ${status.state}`); return result; } catch (error) { Logger.warn(`[PermissionManager] Permission check failed for ${permission}:`, error.message); return this.checkFallback(permission); } } /** * Request permission for a specific API */ async request(permission, options = {}) { const { showRationale = true, fallbackMessage = null, timeout = 30000 } = options; try { // Check current status first const currentStatus = await this.check(permission); if (currentStatus.state === 'granted') { return { granted: true, state: 'granted', fromCache: true }; } if (currentStatus.state === 'denied') { if (showRationale) { await this.showPermissionRationale(permission, fallbackMessage); } return { granted: false, state: 'denied', reason: 'previously-denied' }; } // Add to request queue to prevent multiple simultaneous requests const queueKey = permission; if (this.requestQueue.has(queueKey)) { Logger.info(`[PermissionManager] Permission request already in progress: ${permission}`); return this.requestQueue.get(queueKey); } const requestPromise = this.executePermissionRequest(permission, timeout); this.requestQueue.set(queueKey, requestPromise); const result = await requestPromise; this.requestQueue.delete(queueKey); // Update cache this.permissionCache.set(permission, { name: permission, state: result.state, timestamp: Date.now(), supported: true }); return result; } catch (error) { Logger.error(`[PermissionManager] Permission request failed: ${permission}`, error); this.requestQueue.delete(permission); return { granted: false, state: 'error', error: error.message }; } } /** * Request multiple permissions at once */ async requestMultiple(permissions, options = {}) { const results = {}; if (options.sequential) { // Request permissions one by one for (const permission of permissions) { results[permission] = await this.request(permission, options); // Stop if any critical permission is denied if (options.requireAll && !results[permission].granted) { break; } } } else { // Request permissions in parallel const requests = permissions.map(permission => this.request(permission, options).then(result => [permission, result]) ); const responses = await Promise.allSettled(requests); responses.forEach(response => { if (response.status === 'fulfilled') { const [permission, result] = response.value; results[permission] = result; } else { Logger.error('[PermissionManager] Batch permission request failed:', response.reason); } }); } const summary = { results, granted: Object.values(results).filter(r => r.granted).length, denied: Object.values(results).filter(r => !r.granted).length, total: Object.keys(results).length }; Logger.info(`[PermissionManager] Batch permissions: ${summary.granted}/${summary.total} granted`); return summary; } /** * Check if all required permissions are granted */ async checkRequired(requiredPermissions) { const statuses = {}; const missing = []; for (const permission of requiredPermissions) { const status = await this.check(permission); statuses[permission] = status; if (status.state !== 'granted') { missing.push(permission); } } return { allGranted: missing.length === 0, granted: Object.keys(statuses).filter(p => statuses[p].state === 'granted'), missing, statuses }; } /** * Watch permission changes */ watch(permission, callback) { const watcherId = this.generateId('watcher'); this.permissionWatchers.set(watcherId, { permission, callback, active: true }); // Set up the watcher this.setupPermissionWatcher(permission, callback); Logger.info(`[PermissionManager] Permission watcher created: ${permission}`); return { id: watcherId, stop: () => { const watcher = this.permissionWatchers.get(watcherId); if (watcher) { watcher.active = false; this.permissionWatchers.delete(watcherId); Logger.info(`[PermissionManager] Permission watcher stopped: ${permission}`); } } }; } /** * Get permission recommendations based on app features */ getRecommendations(features = []) { const recommendations = { essential: [], recommended: [], optional: [] }; const featurePermissionMap = { camera: { permissions: ['camera'], priority: 'essential' }, microphone: { permissions: ['microphone'], priority: 'essential' }, location: { permissions: ['geolocation'], priority: 'recommended' }, notifications: { permissions: ['notifications'], priority: 'recommended' }, clipboard: { permissions: ['clipboard-read', 'clipboard-write'], priority: 'optional' }, offline: { permissions: ['persistent-storage'], priority: 'recommended' }, background: { permissions: ['background-sync', 'push'], priority: 'optional' } }; features.forEach(feature => { const mapping = featurePermissionMap[feature]; if (mapping) { recommendations[mapping.priority].push(...mapping.permissions); } }); // Remove duplicates Object.keys(recommendations).forEach(key => { recommendations[key] = [...new Set(recommendations[key])]; }); return recommendations; } /** * Create permission onboarding flow */ createOnboardingFlow(permissions, options = {}) { const { title = 'Permissions Required', descriptions = {}, onComplete = null, onSkip = null } = options; return { permissions, title, descriptions, async start() { Logger.info('[PermissionManager] Starting onboarding flow'); const results = []; for (const permission of permissions) { const description = descriptions[permission] || this.getDefaultDescription(permission); // Show permission explanation const userChoice = await this.showPermissionDialog(permission, description); if (userChoice === 'grant') { const result = await this.request(permission); results.push({ permission, ...result }); } else if (userChoice === 'skip') { results.push({ permission, granted: false, skipped: true }); } else { // User cancelled the entire flow if (onSkip) onSkip(results); return { cancelled: true, results }; } } const summary = { completed: true, granted: results.filter(r => r.granted).length, total: results.length, results }; if (onComplete) onComplete(summary); return summary; }, getDefaultDescription(permission) { const descriptions = { camera: 'Take photos and record videos for enhanced functionality', microphone: 'Record audio for voice features and communication', geolocation: 'Provide location-based services and content', notifications: 'Send you important updates and reminders', 'clipboard-read': 'Access clipboard content for convenience features', 'clipboard-write': 'Copy content to clipboard for easy sharing' }; return descriptions[permission] || `Allow access to ${permission} functionality`; }, async showPermissionDialog(permission, description) { return new Promise(resolve => { // Create modal dialog const modal = document.createElement('div'); modal.className = 'permission-dialog-modal'; modal.innerHTML = `
`; document.body.appendChild(modal); const skipBtn = modal.querySelector('.btn-skip'); const grantBtn = modal.querySelector('.btn-grant'); const cleanup = () => document.body.removeChild(modal); skipBtn.onclick = () => { cleanup(); resolve('skip'); }; grantBtn.onclick = () => { cleanup(); resolve('grant'); }; // Close on backdrop click modal.querySelector('.permission-dialog-backdrop').onclick = (e) => { if (e.target === e.currentTarget) { cleanup(); resolve('cancel'); } }; }); }, getPermissionIcon(permission) { const icons = { camera: '📷', microphone: '🎤', geolocation: '📍', notifications: '🔔', 'clipboard-read': '📋', 'clipboard-write': '📋' }; return icons[permission] || '🔐'; }, getPermissionTitle(permission) { const titles = { camera: 'Camera Access', microphone: 'Microphone Access', geolocation: 'Location Access', notifications: 'Notifications', 'clipboard-read': 'Clipboard Access', 'clipboard-write': 'Clipboard Access' }; return titles[permission] || `${permission} Permission`; } }; } // Helper methods async cacheCurrentPermissions() { if (!this.support.permissions) return; const commonPermissions = ['camera', 'microphone', 'geolocation', 'notifications']; for (const permission of commonPermissions) { try { await this.check(permission); } catch (error) { // Ignore errors during initial caching } } } getPermissionDescriptor(permission) { // Handle different permission descriptor formats switch (permission) { case 'camera': return { name: 'camera' }; case 'microphone': return { name: 'microphone' }; case 'geolocation': return { name: 'geolocation' }; case 'notifications': return { name: 'notifications' }; case 'clipboard-read': return { name: 'clipboard-read' }; case 'clipboard-write': return { name: 'clipboard-write' }; case 'persistent-storage': return { name: 'persistent-storage' }; case 'background-sync': return { name: 'background-sync' }; case 'push': return { name: 'push', userVisibleOnly: true }; default: return { name: permission }; } } async checkFallback(permission) { // Fallback checks for browsers without Permissions API const fallbacks = { camera: () => navigator.mediaDevices !== undefined, microphone: () => navigator.mediaDevices !== undefined, geolocation: () => 'geolocation' in navigator, notifications: () => 'Notification' in window, 'clipboard-read': () => 'clipboard' in navigator, 'clipboard-write': () => 'clipboard' in navigator }; const check = fallbacks[permission]; const supported = check ? check() : false; return { name: permission, state: supported ? 'prompt' : 'unsupported', timestamp: Date.now(), supported, fallback: true }; } async executePermissionRequest(permission, timeout) { return new Promise(async (resolve, reject) => { const timeoutId = setTimeout(() => { reject(new Error('Permission request timeout')); }, timeout); try { let result; switch (permission) { case 'notifications': if ('Notification' in window) { const notificationPermission = await Notification.requestPermission(); result = { granted: notificationPermission === 'granted', state: notificationPermission }; } else { result = { granted: false, state: 'unsupported' }; } break; case 'camera': case 'microphone': try { const constraints = {}; constraints[permission === 'camera' ? 'video' : 'audio'] = true; const stream = await navigator.mediaDevices.getUserMedia(constraints); stream.getTracks().forEach(track => track.stop()); // Stop immediately result = { granted: true, state: 'granted' }; } catch (error) { result = { granted: false, state: error.name === 'NotAllowedError' ? 'denied' : 'error', error: error.message }; } break; case 'geolocation': try { await new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000, maximumAge: 0 }); }); result = { granted: true, state: 'granted' }; } catch (error) { result = { granted: false, state: error.code === 1 ? 'denied' : 'error', error: error.message }; } break; default: // Try generic permission request if (this.support.permissions) { const status = await navigator.permissions.query(this.getPermissionDescriptor(permission)); result = { granted: status.state === 'granted', state: status.state }; } else { result = { granted: false, state: 'unsupported' }; } } clearTimeout(timeoutId); resolve(result); } catch (error) { clearTimeout(timeoutId); reject(error); } }); } async setupPermissionWatcher(permission, callback) { if (!this.support.permissions) return; try { const status = await navigator.permissions.query(this.getPermissionDescriptor(permission)); status.addEventListener('change', () => { callback({ permission, state: status.state, timestamp: Date.now() }); }); } catch (error) { Logger.warn(`[PermissionManager] Could not set up watcher for ${permission}:`, error); } } onPermissionChange(permission, newState) { Logger.info(`[PermissionManager] Permission changed: ${permission} → ${newState}`); // Update cache this.permissionCache.set(permission, { name: permission, state: newState, timestamp: Date.now(), supported: true }); // Notify watchers this.permissionWatchers.forEach(watcher => { if (watcher.permission === permission && watcher.active) { watcher.callback({ permission, state: newState, timestamp: Date.now() }); } }); } async showPermissionRationale(permission, customMessage = null) { const rationales = { camera: 'Camera access is needed to take photos and record videos. You can enable this in your browser settings.', microphone: 'Microphone access is needed for audio recording and voice features. Please check your browser settings.', geolocation: 'Location access helps provide relevant local content. You can manage this in your browser settings.', notifications: 'Notifications keep you updated with important information. You can change this anytime in settings.' }; const message = customMessage || rationales[permission] || `${permission} permission was denied. Please enable it in your browser settings to use this feature.`; // Simple alert for now - could be enhanced with custom UI if (typeof window !== 'undefined' && window.confirm) { return window.confirm(message + '\n\nWould you like to try again?'); } return false; } generateId(prefix = 'perm') { return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Get comprehensive permission status report */ async getPermissionReport() { const report = { support: this.support, cached: Array.from(this.permissionCache.entries()).map(([name, data]) => ({ name, ...data })), watchers: Array.from(this.permissionWatchers.keys()), timestamp: Date.now() }; return report; } /** * Clear all cached permissions */ clearCache() { this.permissionCache.clear(); Logger.info('[PermissionManager] Permission cache cleared'); } /** * Stop all watchers and cleanup */ cleanup() { this.permissionWatchers.forEach(watcher => { watcher.active = false; }); this.permissionWatchers.clear(); this.requestQueue.clear(); this.permissionCache.clear(); Logger.info('[PermissionManager] Cleanup completed'); } }