Files
michaelschiemer/resources/js/modules/api-manager/PermissionManager.js
Michael Schiemer 55a330b223 Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
2025-08-11 20:13:26 +02:00

687 lines
26 KiB
JavaScript

// 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 = `
<div class="permission-dialog-backdrop" style="
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
">
<div class="permission-dialog" style="
background: white;
border-radius: 8px;
padding: 2rem;
max-width: 400px;
margin: 1rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
">
<h3 style="margin: 0 0 1rem 0; color: #333;">
${this.getPermissionIcon(permission)} ${this.getPermissionTitle(permission)}
</h3>
<p style="margin: 0 0 2rem 0; color: #666; line-height: 1.5;">
${description}
</p>
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
<button class="btn-skip" style="
background: #f5f5f5;
border: 1px solid #ddd;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
">Skip</button>
<button class="btn-grant" style="
background: #007bff;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
">Allow</button>
</div>
</div>
</div>
`;
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');
}
}