1/** 2 * HexVault â Settings Handler v2 3 * Single DOMContentLoaded, all buttons wired, profile pic synced. 4 */ 5'use strict'; 6 7// ââ Helpers âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8function _hdr() { 9 return typeof getHeaders === 'function' ? getHeaders() : { 'Content-Type': 'application/json' }; 10} 11function _toast(msg, type) { 12 if (typeof showToast === 'function') showToast(msg, type); 13} 14 15// ââ Profile picture â load into settings div ââââââââââââââââââââââââââââââ 16function loadSettingsProfilePicture() { 17 const el = document.getElementById('settingsProfilePicture'); 18 if (!el) return; 19 const removeBtn = document.getElementById('removeSettingsPictureBtn'); 20 // Reuse the already-fetched picture from the header element 21 const header = document.getElementById('headerProfilePicture'); 22 const img = header && header.querySelector('img'); 23 if (img) { 24 el.innerHTML = `<img src="${img.src}" alt="Profile" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">`; 25 if (removeBtn) removeBtn.style.display = ''; 26 } else { 27 // Show initials 28 const user = typeof currentUser !== 'undefined' ? currentUser : null; 29 const initials = user && user.username ? user.username.substring(0,2).toUpperCase() : 'U'; 30 el.textContent = initials; 31 if (removeBtn) removeBtn.style.display = 'none'; 32 } 33} 34 35// ââ Upload profile picture (base64 JSON â no FormData) ââââââââââââââââââââ 36async function _uploadPicture(file) { 37 if (!file) return; 38 39 // Cap at 4MB raw. Base64 encoding adds ~33% overhead, so a 4MB file 40 // becomes ~5.4MB in the JSON body â within the server's 6MB MAX_CONTENT_LENGTH 41 // with headroom for JSON framing. 42 if (file.size > 4 * 1024 * 1024) { 43 _toast('Image must be under 4MB', 'error'); 44 return; 45 } 46 47 // Match the server-side allow-list at app.py:upload_profile_picture. 48 // Reject unsupported formats here so the user gets a clear message rather 49 // than a generic 400. SVG is intentionally excluded â it can carry <script>. 50 const ALLOWED = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/heic', 'image/heif']; 51 const mime = (file.type || '').toLowerCase(); 52 if (!ALLOWED.includes(mime)) { 53 _toast(`Unsupported image format (${mime || 'unknown'}). Use JPEG, PNG, GIF, WebP or HEIC.`, 'error'); 54 return; 55 } 56 57 const reader = new FileReader(); 58 reader.onload = async function(e) { 59 try { 60 const r = await fetch('/api/profile/picture', { 61 method: 'POST', credentials: 'include', headers: _hdr(), 62 body: JSON.stringify({ image: e.target.result }) 63 }); 64 if (r.ok) { 65 _toast('Profile picture updated', 'success'); 66 if (typeof loadProfilePicture === 'function') await loadProfilePicture(); 67 setTimeout(loadSettingsProfilePicture, 300); 68 } else { 69 const d = await r.json().catch(() => ({})); 70 _toast(d.error || 'Upload failed', 'error'); 71 } 72 } catch(err) { _toast('Upload error', 'error'); } 73 }; 74 reader.readAsDataURL(file); 75} 76 77async function _removePicture() { 78 var ok = await window.customConfirm('Remove your profile picture?', 'Remove Picture'); 79 if (!ok) return; 80 try { 81 const r = await fetch('/api/profile/picture', { method: 'DELETE', credentials: 'include', headers: _hdr() }); 82 if (r.ok) { 83 _toast('Profile picture removed', 'success'); 84 if (typeof loadProfilePicture === 'function') await loadProfilePicture(); 85 setTimeout(loadSettingsProfilePicture, 300); 86 } 87 } catch(e) {} 88} 89 90// ââ Preferences âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 91async function loadSettingsPreferences() { 92 try { 93 const r = await fetch('/api/user/preferences', { credentials: 'include', headers: _hdr() }); 94 if (!r.ok) return; 95 const d = await r.json(); 96 const slider = document.getElementById('sessionTimeoutRange'); 97 const sliderVal = document.getElementById('timeoutValue');
98 const emailToggle = document.getElementById('emailNotificationsToggle'); 99 if (slider && d.session_timeout) { 100 slider.value = d.session_timeout; 101 if (sliderVal) sliderVal.textContent = d.session_timeout; 102 // Mark matching preset chip as active 103 document.querySelectorAll('.timeout-preset').forEach(btn => { 104 btn.classList.toggle('active', parseInt(btn.dataset.mins) === parseInt(d.session_timeout)); 105 }); 106 } 107 if (emailToggle) emailToggle.checked = d.email_notifications !== false; 108 } catch(e) {} 109} 110 111async function savePreferences() { 112 const btn = document.getElementById('savePreferencesBtn'); 113 const timeout = parseInt(document.getElementById('sessionTimeoutRange')?.value || 30); 114 const emailNotif = document.getElementById('emailNotificationsToggle')?.checked !== false; 115 try { 116 if (btn) { btn.disabled = true; btn.textContent = 'Savingâ¦'; } 117 const r = await fetch('/api/user/preferences', { 118 method: 'POST', credentials: 'include', headers: _hdr(), 119 body: JSON.stringify({ session_timeout: timeout, email_notifications: emailNotif }) 120 }); 121 if (r.ok) { 122 _toast('Preferences saved', 'success'); 123 // Update the live auto-lock timer immediately 124 if (typeof window.updateAutoLockTimeout === 'function') { 125 window.updateAutoLockTimeout(timeout); 126 } 127 if (typeof window.setIdleTimeout === 'function') window.setIdleTimeout(timeout); 128 // Update stored preference so it survives page refresh 129 if (window.currentUserPrefs) window.currentUserPrefs.session_timeout = timeout; 130 } else { _toast('Failed to save', 'error'); } 131 } catch(e) { _toast('Connection error', 'error'); } 132 if (btn) { btn.disabled = false; btn.textContent = 'Save Preferences'; } 133} 134 135// ââ Referral ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 136async function loadReferralSection() { 137 const codeInput = document.getElementById('referralCodeDisplay'); 138 const statsEl = document.getElementById('referralStats'); 139 const copyBtn = document.getElementById('copyReferralBtn'); 140 if (!codeInput) return; 141 try { 142 const [codeRes, statsRes] = await Promise.all([ 143 fetch('/api/referral/code', { credentials: 'include', headers: _hdr() }), 144 fetch('/api/referral/stats', { credentials: 'include', headers: _hdr() }), 145 ]); 146 if (codeRes.ok) { 147 const d = await codeRes.json(); 148 codeInput.value = d.url || ''; 149 if (copyBtn) { 150 copyBtn.onclick = () => { 151 navigator.clipboard.writeText(d.url || ''); 152 if (navigator.vibrate) navigator.vibrate(10); 153 copyBtn.textContent = 'Copied!'; 154 setTimeout(() => { copyBtn.textContent = 'Copy Link'; }, 2000); 155 }; 156 } 157 } 158 if (statsRes.ok && statsEl) { 159 const s = await statsRes.json(); 160 const n = s.referred_count || 0; 161 statsEl.textContent = n === 0 ? 'No referrals yet â share your link!' 162 : `OK ${n} person${n !== 1 ? 's' : ''} joined using your link.`; 163 } 164 } catch(e) { if (statsEl) statsEl.textContent = ''; } 165} 166 167// ââ 2FA badge âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 168async function loadSettings2FAStatus() { 169 try { 170 const r = await fetch('/api/2fa/status', { credentials: 'include', headers: _hdr() }); 171 if (!r.ok) return; 172 const d = await r.json(); 173 const badge = document.getElementById('settings2FABadge'); 174 if (badge) { 175 const enabled = d.totp_enabled || d.enabled; 176 badge.className = `settings-badge ${enabled ? 'settings-badge-success' : 'settings-badge-warning'}`; 177 badge.innerHTML = `<span class="settings-badge-dot"></span>${enabled ? 'Enabled' : 'Not enabled'}`; 178 } 179 } catch(e) {} 180} 181 182// ââ Sessions ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 183async function loadActiveSessions() { 184 const el = document.getElementById('activeSessionsList'); 185 if (!el) return; 186 try { 187 const r = await fetch('/api/sessions', { credentials: 'include', headers: _hdr() }); 188 if (!r.ok) { el.innerHTML = ''; return; } 189 const d = await r.json(); 190 const sessions = Array.isArray(d) ? d : (d.sessions || []); 191 if (!sessions.length) { 192 el.innerHTML = '<p style="color:var(--muted);font-size:13px">
192No active sessions found.</p>'; 193 return; 194 } 195 // Deduplicate: keep only the most recent session per IP 196 const seen = new Set(); 197 const unique = sessions.filter(function(s) { 198 const key = (s.ip_address || '') + '|' + (s.device || s.user_agent || ''); 199 if (seen.has(key) && !s.is_current) return false; 200 seen.add(key); 201 return true; 202 }); 203 204 el.innerHTML = unique.map(function(s) { 205 // API returns pre-parsed `device` field; fall back to UA parsing 206 let device = s.device || 'Unknown device'; 207 if (device === 'Unknown device' && s.user_agent) { 208 const ua = s.user_agent; 209 if (/iPhone|iPad/i.test(ua)) device = 'iOS'; 210 else if (/Android/i.test(ua)) device = 'Android'; 211 else if (/Windows/i.test(ua)) device = 'Windows'; 212 else if (/Mac/i.test(ua)) device = 'Mac'; 213 const br = /Firefox/i.test(ua) ? 'Firefox' : /Chrome/i.test(ua) ? 'Chrome' 214 : /Safari/i.test(ua) ? 'Safari' : /Edge/i.test(ua) ? 'Edge' : ''; 215 if (br) device += ' · ' + br; 216 } 217 const time = s.last_active || s.last_activity || ''; 218 const isCurrent = s.is_current || false; 219 return `<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid rgba(255,255,255,.05);gap:12px"> 220 <div> 221 <div style="font-size:13px;font-weight:600;color:var(--text)">${device}${isCurrent ? ' <span style="font-size:11px;color:#10b981;font-weight:500">· current</span>' : ''}</div> 222 <div style="font-size:11px;color:var(--muted);margin-top:2px">${s.ip_address || ''}${time ? ' · ' + time : ''}</div> 223 </div> 224 ${!isCurrent ? `<button style="font-size:12px;padding:4px 10px;color:var(--accent-rose);border:1px solid rgba(244,63,94,.25);border-radius:6px;background:none;cursor:pointer;flex-shrink:0;white-space:nowrap" data-action="revoke-session" data-session-id="${s.id}">Revoke</button>` : ''} 225 </div>`; 226 }).join(''); 227 // v6.38.706: onclick="revokeSession(id)" was CSP-blocked. 228 // One-time delegated listener for the whole sessions list. 229 if (!el._revokeDelegated) { 230 el._revokeDelegated = true; 231 el.addEventListener('click', function(ev) { 232 const btn = ev.target.closest('[data-action="revoke-session"]'); 233 if (!btn || !el.contains(btn)) return; 234 const sid = parseInt(btn.getAttribute('data-session-id'), 10); 235 if (!isNaN(sid) && typeof revokeSession === 'function') revokeSession(sid); 236 }); 237 } 238 } catch(e) { el.innerHTML = ''; } 239} 240 241async function revokeSession(sessionId) { 242 try { 243 const r = await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE', credentials: 'include', headers: _hdr() }); 244 if (r.ok) { _toast('Session revoked', 'success'); loadActiveSessions(); } 245 } catch(e) {} 246} 247 248async function revokeAllSessions() { 249 var ok2 = await window.customConfirm('Sign out of all other sessions?', 'Revoke Sessions'); 250 if (!ok2) return; 251 const btn = document.getElementById('revokeAllSessionsBtn'); 252 try { 253 if (btn) { btn.disabled = true; btn.textContent = 'Revokingâ¦'; } 254 const r = await fetch('/api/sessions/revoke-all', { method: 'POST', credentials: 'include', headers: _hdr() }); 255 if (r.ok) { _toast('All other sessions revoked', 'success'); loadActiveSessions(); } 256 } catch(e) {} 257 if (btn) { btn.disabled = false; btn.textContent = 'Sign out all other sessions'; } 258} 259 260// ââ Trusted devices â show empty state, not loading spinner ââââââââââââââ 261function loadTrustedDevices() { 262 const el = document.getElementById('trustedDevicesList'); 263 if (!el) return; 264 el.innerHTML = '<p style="color:var(--muted);font-size:13px">No trusted devices configured.</p>'; 265} 266 267// ââ Biometric âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 268async function initBiometricSettingsBtn() { 269 const btn = document.getElementById('setupBiometricBtn'); 270 const statusText = document.getElementById('biometricStatusText'); 271 const badge = document.getElementById('biometricStatusBadge'); 272 if (!btn) return; 273 const supported = typeof window.PublicKeyCredential !== 'undefined'; 274 if (!supported) { 275 if (statusText) statusText.textContent = 'Not supported on this device'; 276 btn.style.display = 'none'; return; 277 } 278 try { 279 const r = await fetch('/api/webauthn/credentials', { credentials: 'include', headers: _hdr() }); 280 if (r.ok) { 281 const d = await r.json(); 282 const creds = d.credentials || d || []; 283 if (Array.isArray(creds) && creds.length > 0) { 284 if (statusText) statusText.textContent = `Enabled (${creds.length} device${creds.length > 1 ? 's' : ''})`; 285 if (badge) badge.className = 'settings-badge settings-badge-success'; 286 btn.style.display = 'none'; 287 } else { 288 if (statusText) statusText.textContent = 'Not configured'; 289 btn.style.display = ''; btn.textContent = 'Enable'; 290 } 291 } 292 } catch(e) { btn.style.display = ''; } 293} 294 295// ââ Security settings âââââââââââââââââââââââââââââââââââââââââââââââââââââ 296async function loadSecuritySettings() { 297 try { 298 const r = await fetch('/api/settings/security', { credentials: 'include', headers: _hdr() }); 299 if (!r.ok) return; 300 const d = await r.json(); 301 const ip = document.getElementById('ipChangeDetectionToggle'); 302 const dev = document.getElementById('deviceTrackingToggle'); 303 if (ip && d.ip_change_detection !== undefined) ip.checked = d.ip_change_detection; 304 if (dev && d.device_tracking !== undefined) dev.checked = d.device_tracking; 305 } catch(e) {} 306} 307 308async function saveSecuritySetting(key, value) { 309 try { 310 await fetch('/api/settings/security', { 311 method: 'PUT', credentials: 'include', headers: _hdr(), 312 body: JSON.stringify({ [key]: value }) 313 }); 314 } catch(e) {} 315} 316 317// ââ Change password âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 318async function changeSettingsPassword() { 319 const btn = document.getElementById('changePasswordBtn'); 320 const currentEl = document.getElementById('settingsCurrentPassword'); 321 const newEl = document.getElementById('settingsNewPassword'); 322 const confirmEl = document.getElementById('settingsConfirmPassword'); 323 if (!currentEl || !newEl) {
324 // No inline fields â open 2FA/password flow 325 if (typeof open2FASettings === 'function') open2FASettings(); 326 return; 327 } 328 const current = currentEl.value.trim(); 329 const newPw = newEl.value.trim(); 330 const confirm = confirmEl ? confirmEl.value.trim() : newPw; 331 if (!current || !newPw) { _toast('Fill in all fields', 'error'); return; } 332 if (newPw !== confirm) { _toast('Passwords do not match', 'error'); return; } 333 if (newPw.length < 12) { _toast('Password must be at least 12 characters', 'error'); return; } 334 try { 335 if (btn) { btn.disabled = true; btn.textContent = 'Changingâ¦'; } 336 const r = await fetch('/api/profile/password', { 337 method: 'POST', credentials: 'include', headers: _hdr(), 338 body: JSON.stringify({ current_password: current, new_password: newPw }) 339 }); 340 const d = await r.json().catch(() => ({})); 341 if (r.ok) { 342 _toast('Password changed â logging you out', 'success'); 343 currentEl.value = ''; newEl.value = ''; if (confirmEl) confirmEl.value = ''; 344 setTimeout(() => { if (typeof logout === 'function') logout(); }, 2000); 345 } else { _toast(d.error || 'Change failed', 'error'); } 346 } catch(e) { _toast('Connection error', 'error'); } 347 if (btn) { btn.disabled = false; btn.textContent = 'Change Password'; } 348} 349 350// ââ Delete account ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 351async function deleteSettingsAccount() { 352 // Step 1: intent 353 const intent = await new Promise(resolve => { 354 if (typeof customConfirm === 'function') { 355 customConfirm('Delete Account', 356 'This will permanently delete your account and all your passwords. This cannot be undone.', 357 resolve); 358 } else { 359 window.customConfirm('Permanently delete your account and all passwords? This cannot be undone.', 'Delete Account').then(resolve); 360 } 361 }); 362 if (!intent) return; 363 364 // Step 2: password 365 const password = await new Promise(resolve => { 366 if (typeof customPrompt === 'function') { 367 customPrompt('Confirm Deletion', 368 'Enter your master password to permanently delete your account:', 369 '', resolve, { type: 'password', placeholder: 'Master password' }); 370 } else { 371 window.customPrompt('Enter your master password to confirm deletion:', '', 'Confirm Password', 'password').then(resolve); 372 } 373 }); 374 if (!password || !password.trim()) { _toast('Password required to delete account', 'error'); return; } 375 376 const btn = document.getElementById('deleteAccountBtn'); 377 try { 378 if (btn) { btn.disabled = true; btn.textContent = 'Deletingâ¦'; } 379 const r = await fetch('/api/account/delete', { 380 method: 'POST', credentials: 'include', headers: _hdr(), 381 body: JSON.stringify({ password: password.trim() }) 382 }); 383 const d = await r.json().catch(() => ({})); 384 if (r.ok) { 385 _toast('Account deleted', 'success'); 386 setTimeout(() => { window.location.href = '/app'; }, 1500); 387 } else { 388 _toast(d.error || 'Deletion failed â check your password', 'error'); 389 if (btn) { btn.disabled = false; btn.textContent = 'Delete Account'; } 390 } 391 } catch(e) { 392 _toast('Connection error', 'error'); 393 if (btn) { btn.disabled = false; btn.textContent = 'Delete Account'; } 394 } 395} 396 397// ââ Per-tab loader ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 398window.loadSettingsForTab = function(tab) { 399 if (tab === 'account') { loadSettingsProfilePicture(); if (typeof window.loadBillingStatus === 'function') window.loadBillingStatus(); } 400 if (tab === 'preferences') { loadSettingsPreferences(); loadReferralSection(); } 401 if (tab === 'security') { loadSettings2FAStatus(); loadActiveSessions(); loadTrustedDevices(); loadSecuritySettings(); initBiometricSettingsBtn(); if (typeof window.loadEmergencyAccess === 'function') window.loadEmergencyAccess(); if (typeof window.loadStoredPasskeys === 'function') window.loadStoredPasskeys(); if (typeof window.initApiTokens === 'function') window.initApiTokens(); if (typeof window.loadTravelMode === 'function') window.loadTravelMode(); if (typeof window.loadEmailAliases === 'function') window.loadEmailAliases(); if (typeof window.loadDigitalLegacy === 'function') window.loadDigitalLegacy(); } 402 if (tab === 'data') { if (typeof window.loadBillingStatus === 'function') window.loadBillingStatus(); if (typeof window.initSecureSend === 'function') window.initSecureSend(); } 403 if (tab === 'team') { loadTeamSettingsTab(); if (typeof window.initOrgGroups === 'function') window.initOrgGroups(); if (typeof window.loadDirSyncStatus === 'function') window.loadDirSyncStatus(); if (typeof window.loadAdaptiveMfa === 'function') window.loadAdaptiveMfa(); if (typeof window.initAccessReviews === 'function') window.initAccessReviews(); if (typeof window.loadBreakGlassLog === 'function') { var bgs = document.getElementById('breakGlassLogSection'); if (bgs && window.currentUserIsOrgAdmin) { bgs.style.display=''; window.loadBreakGlassLog(); } } if (typeof window.loadSecretsManager === 'function') { var sma = document.getElementById('secretsManagerArea'); if (sma && window.currentUserIsOrgAdmin) { sma.style.display=''; window.loadSecretsManager(); } } } 404}; 405 406async function loadTeamSettingsTab() { 407 const infoEl = document.getElementById('teamSettingsOrgInfo'); 408 const vaultEl = document.getElementById('teamSettingsVaultArea'); 409 if (!infoEl) return; 410 411 // v6.38.703: route through capability map. Previously hard-coded 412 // ['team','enterprise'] â worked but duplicated the team-tier list. 413 // v6.38.712: removed isDevModeActive from fallback for consistency. 414 const isTeamOrEnt = (typeof window.hasFeature === 'function') 415 ? window.hasFeature('team_vault') 416 : ['team','enterprise'].includes((window.currentUserTier || '').toLowerCase()); 417 418 try { 419 const r = await fetch('/api/org/info', { credentials: 'include', headers: getHeaders() }); 420 if (r.ok) { 421 const d = await r.json(); 422 if (d.has_org && d.name) { 423 const roleLabel = d.is_owner ? 'Owner' : d.is_admin ? 'Admin' : 'Member'; 424 const isAdmin = d.is_admin || d.is_owner; 425 426 // Org card with rename button for admins 427 infoEl.innerHTML = 428 '<div style="padding:14px 16px;background:rgba(99,102,241,.06);border:1px solid rgba(99,102,241,.15);border-radius:10px">' + 429 '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">' + 430 '<div>' + 431 '<div style="font-weight:700;font-size:15px;margin-bottom:3px" id="teamSettingsOrgName">' + (d.name || '') + '</div>' + 432 '<div style="font-size:12px;color:var(--muted)">Your role: <strong style="color:#8b7fff">
432' + roleLabel + '</strong></div>' + 433 '</div>' + 434 (isAdmin ? '<button id="teamSettingsRenameBtn" style="background:rgba(99,102,241,.12);border:1px solid rgba(99,102,241,.25);border-radius:7px;cursor:pointer;padding:6px 12px;color:#8b7fff;font-size:12px;font-weight:600;font-family:inherit">Rename</button>' : '') + 435 '</div>' + 436 '</div>'; 437 438 // Wire rename button 439 if (isAdmin) { 440 document.getElementById('teamSettingsRenameBtn')?.addEventListener('click', async function() { 441 var cur = document.getElementById('teamSettingsOrgName')?.textContent || ''; 442 var name = await window.customPrompt('New organisation name:', cur, 'Rename Organisation'); 443 if (!name || !name.trim() || name.trim() === cur) return; 444 var resp = await fetch('/api/org/name', { 445 method: 'PATCH', credentials: 'include', headers: getHeaders(), 446 body: JSON.stringify({ name: name.trim() }) 447 }); 448 var data = await resp.json(); 449 if (resp.ok) { 450 document.getElementById('teamSettingsOrgName').textContent = data.name; 451 if (typeof showToast === 'function') showToast('Organisation renamed to "' + data.name + '"', 'success'); 452 } else { 453 if (typeof showToast === 'function') showToast(data.error || 'Rename failed', 'error'); 454 } 455 }); 456 } 457 458 if (vaultEl) vaultEl.innerHTML = 459 '<div style="padding:12px 14px;background:rgba(0,229,160,.06);border:1px solid rgba(0,229,160,.18);border-radius:10px;font-size:13px;color:#00e5a0;display:flex;align-items:center;gap:8px">' + 460 '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>' + 461 'Team Vault active â shared credentials, roles and audit log enabled.' + 462 '</div>'; 463 464 const inviteEl = document.getElementById('teamSettingsInviteArea'); 465 if (inviteEl) inviteEl.style.display = isAdmin ? '' : 'none'; 466 const createEl = document.getElementById('teamSettingsCreateArea'); 467 if (createEl) createEl.style.display = 'none'; 468 if (d.has_org) window.currentUserIsOrgAdmin = isAdmin; 469 return; 470 } 471 } 472 } catch(_) {} 473 474 // Not in an org 475 if (isTeamOrEnt) { 476 infoEl.innerHTML = '<p style="font-size:13px;color:var(--muted)">You don\'t have an organisation yet. Create one to invite your team.</p>'; 477 const createEl = document.getElementById('teamSettingsCreateArea'); 478 if (createEl) createEl.style.display = ''; 479 if (vaultEl) vaultEl.innerHTML = 480 '<div style="padding:12px 14px;background:rgba(99,102,241,.06);border:1px solid rgba(99,102,241,.15);border-radius:10px;font-size:13px;color:var(--muted)">' + 481 'Create an organisation above to enable shared team passwords and role-based access control.' + 482 '</div>'; 483 } else { 484 // Personal/Pro â upgrade prompt 485 infoEl.innerHTML = ''; 486 if (vaultEl) vaultEl.innerHTML = 487 '<div style="padding:20px;background:rgba(99,102,241,.04);border:1px solid rgba(99,102,241,.15);border-radius:12px;text-align:center">' + 488 '<div style="font-size:14px;font-weight:600;margin-bottom:6px">Team features require a Team plan</div>' + 489 '<div style="font-size:12px;color:var(--muted);margin-bottom:14px">Shared credentials, cryptographic org vaults, audit logs, and role-based access control.</div>' + 490 '<button type="button" class="btn-primary" style="font-size:13px" id="teamSettingsUpgradeBtn">View Team Plans</button>' + 491 '</div>'; 492 document.getElementById('teamSettingsUpgradeBtn')?.addEventListener('click', function() { 493 if (typeof window.showUpgradeModal === 'function') window.showUpgradeModal(); 494 }); 495 const inviteEl = document.getElementById('teamSettingsInviteArea'); 496 if (inviteEl) inviteEl.style.display = 'none'; 497 const createEl = document.getElementById('teamSettingsCreateArea'); 498 if (createEl) createEl.style.display = 'none'; 499 } 500} 501 502// ââ Single DOMContentLoaded âââââââââââââââââââââââââââââââââââââââââââââââ 503document.addEventListener('DOMContentLoaded', function() { 504 505 // Slider live update + preset chips 506 const slider = document.getElementById('sessionTimeoutRange'); 507 const sliderVal = document.getElementById('timeoutValue'); 508 509 function updatePresetActive(val) { 510 document.querySelectorAll('.timeout-preset').forEach(btn => { 511 btn.classList.toggle('active', parseInt(btn.dataset.mins) === parseInt(val)); 512 }); 513 } 514 515 if (slider && sliderVal) { 516 slider.addEventListener('input', function() { 517 sliderVal.textContent = this.value; 518 updatePresetActive(this.value); 519 }); 520 } 521 522 document.querySelectorAll('.timeout-preset').forEach(btn => { 523 btn.addEventListener('click', function() { 524 const mins = this.dataset.mins; 525 if (slider) { slider.value = mins; } 526 if (sliderVal) sliderVal.textContent = mins; 527 updatePresetActive(mins); 528 }); 529 }); 530 531 // Preferences 532 document.getElementById('savePreferencesBtn') 533 ?.addEventListener('click', savePreferences); 534 535 // Replay onboarding tour 536 document.getElementById('replayTourBtn')?.addEventListener('click', function() { 537 document.getElementById('settingsModal')?.classList.remove('active'); 538 setTimeout(function() { 539 if (window.HexVaultOnboarding && typeof window.HexVaultOnboarding.reset === 'function') { 540 window.HexVaultOnboarding.reset(); 541 } 542 }, 300); 543 }); 544 545 // Security toggles 546 document.getElementById('ipChangeDetectionToggle') 547 ?.addEventListener('change', function() { saveSecuritySetting('ip_change_detection', this.checked); }); 548 document.getElementById('deviceTrackingToggle') 549 ?.addEventListener('change', function() { saveSecuritySetting('device_tracking', this.checked); }); 550 551 // 2FA 552 document.getElementById('manage2FABtn')?.addEventListener('click', function() { 553 document.getElementById('settingsModal')?.classList.remove('active'); 554 if (typeof open2FASettings === 'function') open2FASettings(); 555 }); 556 557 // Biometric 558 document.getElementById('setupBiometricBtn')?.addEventListener('click', function() { 559 if (typeof registerBiometric === 'function') registerBiometric(); 560 }); 561 562 // Sessions 563 document.getElementById('revokeAllSessionsBtn') 564 ?.addEventListener('click', revokeAllSessions); 565 566 // Scan breaches 567 document.getElementById('scanBreachesBtn')?.addEventListener('click', function() { 568 if (typeof scanPasswordsForBreaches === 'function') scanPasswordsForBreaches(); 569 }); 570 571 // Profile picture â base64 upload only 572 document.getElementById('uploadSettingsPictureBtn') 573 ?.addEventListener('click', function() { 574 document.getElementById('settingsProfilePictureInput')?.click(); 575 }); 576 document.getElementById('settingsProfilePictureInput') 577 ?.addEventListener('change', function() { _uploadPicture(this.files[0]); this.value = ''; }); 578 document.getElementById('removeSettingsPictureBtn') 579 ?.addEventListener('click', _removePicture); 580 581 // Change password 582 document.getElementById('changePasswordBtn') 583 ?.addEventListener('click', changeSettingsPassword); 584 585 // Delete account 586 document.getElementById('deleteAccountBtn') 587 ?.addEventListener('click', deleteSettingsAccount); 588 589 // Team tab inline invite 590 document.getElementById('teamInviteSendBtn')?.addEventListener('click', async function() { 591 const emailInput = document.getElementById('teamInviteEmailInput'); 592 const resultEl = document.getElementById('teamInviteResult'); 593 const email = (emailInput?.value || '').trim().toLowerCase(); 594 if (!email || !email.includes('@')) { 595 if (resultEl) { resultEl.style.display = 'block'; resultEl.style.background = 'rgba(244,63,94,.08)'; resultEl.style.color = '#f43f5e'; resultEl.textContent = 'Please enter a valid email address.'; } 596 return; 597 } 598 this.disabled = true; this.textContent = 'Sendingâ¦'; 599 try { 600 const r = await fetch('/api/org/invite', { 601 method: 'POST', credentials: 'include', 602 headers: typeof getHeaders === 'function' ? getHeaders() : { 'Content-Type': 'application/json' }, 603 body: JSON.stringify({ email, role: 'member' }) 604 }); 605 const d = await r.json(); 606 if (resultEl) { 607 resultEl.style.display = 'block'; 608 if (r.ok) { 609 resultEl.style.background = 'rgba(16,185,129,.08)'; 610 resultEl.style.color = '#10b981'; 611 resultEl.textContent = 'Invitation sent to ' + email; 612 if (emailInput) emailInput.value = ''; 613 } else { 614 resultEl.style.background = 'rgba(244,63,94,.08)'; 615 resultEl.style.color = '#f43f5e'; 616 resultEl.textContent = d.error || 'Failed to send invitation.'; 617 } 618 } 619 } catch(e) { 620 if (resultEl) { resultEl.style.display = 'block'; resultEl.style.color = '#f43f5e'; resultEl.textContent = 'Connection error.'; } 621 } 622 this.disabled = false; this.textContent = 'Send Invite'; 623 }); 624 625 // Dev mode toggle 626 document.getElementById('devModeToggle')?.addEventListener('change', async function() { 627 try { 628 await fetch('/api/dev/toggle', { 629 method: 'POST', credentials: 'include', headers: _hdr(), 630 body: JSON.stringify({ enabled: this.checked }) 631 }); 632 _toast(this.checked ? 'Dev mode enabled' : 'Dev mode disabled', 'info'); 633 } catch(e) {} 634 }); 635 636 // Tab click â load tab data 637 document.querySelectorAll('#settingsModal .settings-tab').forEach(function(tab) { 638 tab.addEventListener('click', function() { 639 const t = this.dataset.tab; 640 if (t && typeof window.loadSettingsForTab === 'function') { 641 setTimeout(function() { window.loadSettingsForTab(t); }, 60); 642 } 643 }); 644 }); 645}); 646 647// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 648// SECURE SEND 649// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 650 651window.initSecureSend = async function() { 652 // Load active sends list 653 try { 654 const r = await fetch('/api/secure-send/list', { credentials: 'include', headers: _hdr() }); 655 if (r.ok) { 656 const d = await r.json(); 657 _renderSecureSendList(d.sends || []); 658 } 659 } catch(e) {} 660 661 // Create button 662 const createBtn = document.getElementById('createSecureSendBtn'); 663 if (createBtn && !createBtn._wired) { 664 createBtn._wired = true; 665 createBtn.addEventListener('click', _createSecureSend); 666 } 667 668 // Copy link button 669 document.getElementById('copySecureSendBtn')?.addEventListener('click', function() { 670 const url = document.getElementById('secureSendUrl')?.value; 671 if (url) { 672 navigator.clipboard.writeText(url).then(() => _toast('Link copied', 'success')); 673 } 674 }); 675 676 // New send button 677 document.getElementById('secureSendNewBtn')?.addEventListener('click', function() { 678 document.getElementById('secureSendResult').style.display = 'none'; 679 document.getElementById('secureSendCreateArea').style.display = ''; 680 document.getElementById('secureSendContent').value = ''; 681 document.getElementById('secureSendPassphrase').value = ''; 682 document.getElementById('secureSendHint').value = ''; 683 }); 684}; 685 686async function _createSecureSend() { 687 const content = document.getElementById('secureSendContent')?.value.trim(); 688 const passphrase = document.getElementById('secureSendPassphrase')?.value; 689 const hint = document.getElementById('secureSendHint')?.value.trim(); 690 const maxViews = parseInt(document.getElementById('secureSendMaxViews')?.value || '1'); 691 const expHours = parseInt(document.getElementById('secureSendExpiry')?.value || '24'); 692 const btn = document.getElementById('createSecureSendBtn'); 693 694 if (!content) { _toast('Enter a secret to send', 'error'); return; } 695 696 btn.disabled = true; btn.textContent = 'Encryptingâ¦'; 697 698 try { 699 // Derive AES-GCM key from passphrase (or random key if no passphrase) 700 let keyBytes; 701 if (passphrase) { 702 const enc = new TextEncoder(); 703 const salt = crypto.getRandomValues(new Uint8Array(16)); 704 const iv = crypto.getRandomValues(new Uint8Array(12)); 705 // Argon2id via the bundled WASM (loaded globally by index.html). 706 // Parameters match the vault-key derivation: t=3, m=64 MiB, p=4. 707 // Memory-hard â hundreds-to-thousands of times more expensive for 708 // GPU/ASIC attackers than PBKDF2 at any realistic iteration count. 709 if (typeof argon2 === 'undefined' || !argon2.hash) { 710 throw new Error('Argon2 module not loaded'); 711 } 712 const argonResult = await argon2.hash({ 713 pass: passphrase, 714 salt: salt, 715 type: argon2.ArgonType.Argon2id, 716 mem: 65536, // 64 MiB 717 time: 3, 718 parallelism: 4, 719 hashLen: 32, // 256 bits 720 });
721 const key = await crypto.subtle.importKey( 722 'raw', argonResult.hash, 723 { name: 'AES-GCM', length: 256 }, 724 true, ['encrypt'] 725 ); 726 keyBytes = argonResult.hash; 727 const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(content)); 728 const payload = { 729 encrypted_data: btoa(String.fromCharCode(...new Uint8Array(encrypted))), 730 iv: btoa(String.fromCharCode(...iv)), 731 salt: btoa(String.fromCharCode(...salt)), 732 hint, max_views: maxViews, expires_hours: expHours, 733 passphrase_protected: true, 734 kdf: 'argon2id', // explicit â recipient picks branch 735 }; 736 await _sendSecureSendPayload(payload); 737 } else { 738 // Random AES key â embed in URL fragment so server never sees it 739 const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); 740 const iv = crypto.getRandomValues(new Uint8Array(12)); 741 const enc = new TextEncoder(); 742 const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(content)); 743 const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', key)); 744 const payload = { 745 encrypted_data: btoa(String.fromCharCode(...new Uint8Array(encrypted))), 746 iv: btoa(String.fromCharCode(...iv)), 747 hint, max_views: maxViews, expires_hours: expHours, 748 passphrase_protected: false, 749 }; 750 const result = await _sendSecureSendPayload(payload, rawKey); 751 return result; 752 } 753 } catch(e) { 754 _toast('Failed to create secure link: ' + e.message, 'error'); 755 } finally { 756 btn.disabled = false; 757 btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="margin-right:7px"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>Generate Secure Link'; 758 } 759} 760 761async function _sendSecureSendPayload(payload, rawKey) { 762 const r = await fetch('/api/secure-send', { 763 method: 'POST', credentials: 'include', headers: _hdr(), 764 body: JSON.stringify(payload) 765 }); 766 const d = await r.json(); 767 if (!r.ok) { _toast(d.error || 'Failed to create link', 'error'); return; } 768 769 // Build the final URL â embed key in fragment if no passphrase 770 let finalUrl = d.url; 771 if (rawKey) { 772 const keyHex = Array.from(rawKey).map(b => b.toString(16).padStart(2,'0')).join(''); 773 finalUrl = d.url + '#k=' + keyHex; 774 } 775 776 document.getElementById('secureSendCreateArea').style.display = 'none'; 777 const resultEl = document.getElementById('secureSendResult'); 778 resultEl.style.display = 'block'; 779 document.getElementById('secureSendUrl').value = finalUrl; 780 781 const exp = new Date(d.expires_at).toLocaleString(); 782 document.getElementById('secureSendMeta').textContent = 783 `Expires: ${exp} · Max views: ${d.max_views}`; 784 785 _toast('Secure link created', 'success'); 786 // Refresh the list 787 window.initSecureSend(); 788} 789 790function _renderSecureSendList(sends) { 791 const wrap = document.getElementById('secureSendListWrap'); 792 const list = document.getElementById('secureSendList'); 793 if (!wrap || !list) return; 794 if (!sends.length) { wrap.style.display = 'none'; return; } 795 wrap.style.display = 'block'; 796 list.innerHTML = sends.map(s => { 797 const exp = s.expires_at ? new Date(s.expires_at).toLocaleDateString() : 'Never'; 798 const remaining = (s.max_views || 1) - (s.view_count || 0); 799 return `<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.06);border-radius:8px;gap:10px;flex-wrap:wrap"> 800 <div style="flex:1;min-width:0"> 801 <div style="font-size:12px;color:var(--muted2);font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${s.token_prefix}â¦</div> 802 <div style="font-size:11px;color:var(--muted);margin-top:2px">${remaining} view${remaining!==1?'s':''} left · expires ${exp}${s.hint ? ' · hint: '+s.hint : ''}</div> 803 </div> 804 <button class="btn-secondary" style="font-size:11px;padding:3px 10px;color:var(--rose);border-color:rgba(244,63,94,.3)" data-revoke-token="${s.token}">Revoke</button> 805 </div>`; 806 }).join(''); 807 list.querySelectorAll('[data-revoke-token]').forEach(btn => { 808 btn.addEventListener('click', async function() { 809 const token = this.dataset.revokeToken; 810 this.disabled = true; this.textContent = 'Revokingâ¦'; 811 try { 812 await fetch(`/api/secure-send/${token}/revoke`, { 813 method: 'POST', credentials: 'include', headers: _hdr() 814 }); 815 _toast('Link revoked', 'success'); 816 window.initSecureSend(); 817 } catch(e) { _toast('Failed to revoke', 'error'); } 818 }); 819 }); 820} 821 822// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 823// API TOKENS 824// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 825 826window.initApiTokens = async function() { 827 await _loadApiTokensList(); 828 829 const createBtn = document.getElementById('createApiTokenBtn'); 830 if (createBtn && !createBtn._wired) { 831 createBtn._wired = true; 832 createBtn.addEventListener('click', _createApiToken); 833 } 834 835 document.getElementById('copyNewTokenBtn')?.addEventListener('click', function() { 836 const val = document.getElementById('newTokenValue')?.value; 837 if (val) navigator.clipboard.writeText(val).then(() => _toast('Token copied', 'success')); 838 }); 839}; 840 841async function _loadApiTokensList() { 842 const list = document.getElementById('apiTokensList'); 843 if (!list) return; 844 try { 845 const r = await fetch('/api/v1/tokens', { credentials: 'include', headers: _hdr() }); 846 if (!r.ok) { list.innerHTML = '<div style="font-size:12px;color:var(--muted)">Failed to load tokens.</div>'; return; } 847 const tokens = await r.json(); 848 if (!tokens.length) { 849 list.innerHTML = '<div style="font-size:12px;color:var(--muted)">No tokens yet.</div>'; 850 return; 851 } 852 list.innerHTML = tokens.map(t => { 853 const created = t.created_at ? new Date(t.created_at).toLocaleDateString() : 'â'; 854 const expires = t.expires_at ? new Date(t.expires_at).toLocaleDateString() : 'Never'; 855 const lastUsed = t.last_used ? new Date(t.last_used).toLocaleDateString() : 'Never used'; 856 return `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.06);border-radius:8px;gap:10px;flex-wrap:wrap"> 857 <div style="flex:1;min-width:0"> 858 <div style="font-size:13px;font-weight:600">${t.name || 'Unnamed token'}</div> 859 <div style="font-size:11px;color:var(--muted);margin-top:2px">Created ${created} · Expires ${expires} · Last used ${lastUsed}</div> 860 </div> 861 <button class="btn-secondary" style="font-size:11px;padding:3px 10px;color:var(--rose);border-color:rgba(244,63,94,.3);white-space:nowrap" data-token-id="${t.id}" title="Issue a new token with the same name and expiry, invalidating this one">Rotate</button> 862 <button class="btn-secondary token-revoke-btn" style="font-size:11px;padding:3px 10px;color:var(--rose);border-color:rgba(244,63,94,.3);white-space:nowrap" data-token-id="${t.id}">Revoke</button> 863 </div> 864 </div>`; 865 }).join(''); 866 list.querySelectorAll('.token-revoke-btn').forEach(btn => { 867 btn.addEventListener('click', async function() { 868 const id = parseInt(this.dataset.tokenId); 869 this.disabled = true; this.textContent = 'Revokingâ¦'; 870 try { 871 await fetch('/api/v1/tokens', { 872 method: 'DELETE', credentials: 'include', headers: _hdr(), 873 body: JSON.stringify({ token_id: id }) 874 }); 875 _toast('Token revoked', 'success'); 876 _loadApiTokensList(); 877 } catch(e) { _toast('Failed to revoke', 'error'); } 878 }); 879 }); 880 list.querySelectorAll('.token-rotate-btn').forEach(btn => { 881 btn.addEventListener('click', async function() { 882 const id = parseInt(this.dataset.tokenId); 883 if (!confirm('Rotating this token will immediately invalidate it and issue a new one. Any applications using the old token will stop working.\n\nContinue?')) return; 884 this.disabled = true; this.textContent = 'Rotatingâ¦'; 885 try { 886 const r = await fetch(`/api/v1/tokens/${id}/rotate`, { 887 method: 'POST', credentials: 'include', headers: _hdr() 888 }); 889 const data = await r.json(); 890 if (r.ok) { 891 _toast('Token rotated', 'success');
892 prompt('Your new token â copy it now, you will not see it again:', data.token); 893 _loadApiTokensList(); 894 } else { 895 _toast(data.error || 'Rotate failed', 'error'); 896 this.disabled = false; this.textContent = 'Rotate'; 897 } 898 } catch(e) { 899 _toast('Rotate failed', 'error'); 900 this.disabled = false; this.textContent = 'Rotate'; 901 } 902 }); 903 }); 904 } catch(e) { 905 list.innerHTML = '<div style="font-size:12px;color:var(--muted)">Failed to load tokens.</div>'; 906 } 907} 908 909async function _createApiToken() { 910 const name = document.getElementById('newTokenName')?.value.trim() || 'API Token'; 911 const expiryDays = document.getElementById('newTokenExpiry')?.value; 912 const btn = document.getElementById('createApiTokenBtn'); 913 btn.disabled = true; btn.textContent = 'Creatingâ¦'; 914 try { 915 const body = { name }; 916 if (expiryDays) body.expires_days = parseInt(expiryDays); 917 const r = await fetch('/api/v1/tokens', { 918 method: 'POST', credentials: 'include', headers: _hdr(), 919 body: JSON.stringify(body) 920 }); 921 const d = await r.json(); 922 if (!r.ok) { _toast(d.error || 'Failed to create token', 'error'); return; } 923 // Show the token (one-time display) 924 const display = document.getElementById('newTokenDisplay'); 925 const input = document.getElementById('newTokenValue'); 926 if (display && input) { 927 input.value = d.token; 928 display.style.display = 'block'; 929 } 930 document.getElementById('newTokenName').value = ''; 931 document.getElementById('newTokenExpiry').value = ''; 932 _toast('Token created â copy it now!', 'success'); 933 _loadApiTokensList(); 934 } catch(e) { _toast('Connection error', 'error'); } 935 finally { btn.disabled = false; btn.textContent = 'Create Token'; } 936} 937 938// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 939// GDPR DATA RIGHTS 940// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 941 942(function() { 943 function wireGdpr() { 944 const exportBtn = document.getElementById('gdprExportRequestBtn'); 945 const deleteBtn = document.getElementById('gdprDeleteRequestBtn'); 946 const statusEl = document.getElementById('gdprRequestStatus'); 947 948 async function submitGdpr(type, btnEl, label) { 949 if (!btnEl || btnEl._pending) return; 950 const confirmed = await window.customConfirm( 951 type === 'export' 952 ? 'Request a full export of all data held about you? We will email a download link within 30 days.' 953 : 'Request deletion of your account and all associated data? This is irreversible. Your subscription (if any) will be cancelled.', 954 label 955 ); 956 if (!confirmed) return; 957 btnEl._pending = true; 958 btnEl.disabled = true; 959 const orig = btnEl.innerHTML; 960 btnEl.textContent = 'Submittingâ¦'; 961 try { 962 // Map UI types to backend types: exportâportability, deletionâerasure 963 const gdprType = type === 'export' ? 'portability' : 'erasure'; 964 const userEmail = document.getElementById('settingsEmail')?.textContent || 965 document.getElementById('sbEmail')?.textContent || ''; 966 const r = await fetch('/api/gdpr/request', { 967 method: 'POST', credentials: 'include', headers: _hdr(), 968 body: JSON.stringify({ type: gdprType, email: userEmail }) 969 }); 970 const d = await r.json(); 971 if (statusEl) { 972 statusEl.style.display = 'block'; 973 statusEl.style.background = r.ok ? 'rgba(0,229,160,.07)' : 'rgba(244,63,94,.07)'; 974 statusEl.style.color = r.ok ? '#00e5a0' : '#f43f5e'; 975 statusEl.style.border = r.ok ? '1px solid rgba(0,229,160,.2)' : '1px solid rgba(244,63,94,.2)'; 976 statusEl.textContent = r.ok 977 ? (type === 'export' ? "Export request submitted. You'll receive an email within 30 days." : "Deletion request submitted. Our team will process it within 30 days.") 978 : (d.error || 'Request failed. Please contact [email protected].'); 979 } 980 if (r.ok) _toast(type === 'export' ? 'Export request submitted' : 'Deletion request submitted', 'success'); 981 } catch(e) { 982 if (statusEl) { statusEl.style.display='block'; statusEl.style.color='#f43f5e'; statusEl.textContent='Connection error.'; } 983 } finally { 984 btnEl.disabled = false;
985 btnEl.innerHTML = orig; 986 delete btnEl._pending; 987 } 988 } 989 990 if (exportBtn && !exportBtn._wired) { 991 exportBtn._wired = true; 992 exportBtn.addEventListener('click', () => submitGdpr('export', exportBtn, 'Request Data Export')); 993 } 994 if (deleteBtn && !deleteBtn._wired) { 995 deleteBtn._wired = true; 996 deleteBtn.addEventListener('click', () => submitGdpr('deletion', deleteBtn, 'Request Data Deletion')); 997 } 998 } 999 1000 // Wire on data tab open 1001 const _origLoadForTab = window.loadSettingsForTab; 1002 if (_origLoadForTab) { 1003 window.loadSettingsForTab = function(tab) { 1004 _origLoadForTab(tab); 1005 if (tab === 'data') wireGdpr(); 1006 }; 1007 } 1008 // Also wire on DOMContentLoaded in case settings open to data tab directly 1009 if (document.readyState === 'loading') { 1010 document.addEventListener('DOMContentLoaded', wireGdpr); 1011 } else { 1012 wireGdpr(); 1013 } 1014})(); 1015 1016// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1017// ORG GROUPS 1018// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1019 1020window.initOrgGroups = async function() { 1021 const area = document.getElementById('teamGroupsArea'); 1022 if (!area) return; 1023 1024 // No organisation â /api/org/groups returns 403. Skip the request entirely 1025 // and just hide the section, rather than firing a call that errors in the 1026 // console (and reports to Sentry) on every settings load. 1027 if (!window.currentUserOrgId) { area.style.display = 'none'; return; } 1028 1029 // Show section only for org members on team/enterprise 1030 try { 1031 const r = await fetch('/api/org/groups', { credentials: 'include', headers: _hdr() }); 1032 if (r.status === 402) { area.style.display = 'none'; return; } 1033 if (!r.ok) { area.style.display = 'none'; return; } 1034 area.style.display = 'block'; 1035 const d = await r.json(); 1036 _renderGroups(d.groups || []); 1037 } catch(e) { area.style.display = 'none'; return; } 1038 1039 // Create group button 1040 const createBtn = document.getElementById('createGroupBtn'); 1041 if (createBtn && !createBtn._wired) { 1042 createBtn._wired = true; 1043 createBtn.addEventListener('click', async function() { 1044 const name = document.getElementById('newGroupNameInput')?.value.trim(); 1045 if (!name) { _toast('Enter a group name', 'error'); return; } 1046 createBtn.disabled = true; createBtn.textContent = 'Creatingâ¦'; 1047 try { 1048 const r = await fetch('/api/org/groups', { 1049 method: 'POST', credentials: 'include', headers: _hdr(), 1050 body: JSON.stringify({ name }) 1051 }); 1052 const d = await r.json(); 1053 if (!r.ok) { _toast(d.error || 'Failed to create group', 'error'); return; } 1054 document.getElementById('newGroupNameInput').value = ''; 1055 _toast('Group created', 'success'); 1056 window.initOrgGroups(); 1057 } catch(e) { _toast('Connection error', 'error'); } 1058 finally { createBtn.disabled = false; createBtn.textContent = 'Create Group'; } 1059 }); 1060 } 1061}; 1062 1063async function _renderGroups(groups) { 1064 const list = document.getElementById('groupsList'); 1065 if (!list) return; 1066 if (!groups.length) { 1067 list.innerHTML = '<div style="font-size:12px;color:var(--muted)">No groups yet. Create one above to organise members.</div>'; 1068 return; 1069 } 1070 list.innerHTML = groups.map(g => `
1071 <div style="padding:10px 12px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.06);border-radius:8px"> 1072 <div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap"> 1073 <div style="flex:1;min-width:0"> 1074 <div style="font-size:13px;font-weight:600">${_escHtml(g.name)}</div> 1075 <div style="font-size:11px;color:var(--muted);margin-top:2px">${g.member_count || 0} member${g.member_count !== 1 ? 's' : ''}${g.description ? ' · ' + _escHtml(g.description) : ''}</div> 1076 </div> 1077 <div style="display:flex;gap:6px"> 1078 <button class="btn-secondary" style="font-size:11px;padding:3px 10px" data-manage-group="${g.id}" data-group-name="${_escHtml(g.name)}">Manage</button> 1079 <button class="btn-secondary" style="font-size:11px;padding:3px 10px;color:var(--rose);border-color:rgba(244,63,94,.3)" data-delete-group="${g.id}" data-group-name="${_escHtml(g.name)}">Delete</button> 1080 </div> 1081 </div> 1082 </div>`).join(''); 1083 1084 // v6.38.706: Manage button â was onclick="manageGroup(..)" which would 1085 // have broken for any group name containing an apostrophe AND was 1086 // CSP-blocked. Now a proper delegated listener. 1087 list.querySelectorAll('[data-manage-group]').forEach(btn => { 1088 btn.addEventListener('click', function() { 1089 const gid = parseInt(this.dataset.manageGroup, 10); 1090 const gname = this.dataset.groupName; 1091 if (!isNaN(gid) && typeof window.manageGroup === 'function') { 1092 window.manageGroup(gid, gname); 1093 } 1094 }); 1095 }); 1096 1097 // Wire delete buttons 1098 list.querySelectorAll('[data-delete-group]').forEach(btn => { 1099 btn.addEventListener('click', async function() { 1100 const gid = this.dataset.deleteGroup; 1101 const gname = this.dataset.groupName; 1102 const ok = await window.customConfirm(`Delete group "${gname}"? Members will lose folder access granted through this group.`, 'Delete Group'); 1103 if (!ok) return; 1104 this.disabled = true; 1105 try { 1106 await fetch(`/api/org/groups/${gid}`, { method: 'DELETE', credentials: 'include', headers: _hdr() }); 1107 _toast('Group deleted', 'success'); 1108 window.initOrgGroups(); 1109 } catch(e) { _toast('Failed to delete', 'error'); } 1110 }); 1111 }); 1112} 1113 1114window.manageGroup = async function(groupId, groupName) { 1115 // Fetch members and show in a modal-style panel 1116 try { 1117 const [grpR, memR] = await Promise.all([ 1118 fetch(`/api/org/groups/${groupId}/members`, { credentials: 'include', headers: _hdr() }), 1119 fetch('/api/org/members', { credentials: 'include', headers: _hdr() }) 1120 ]); 1121 const grpD = await grpR.json(); 1122 const memD = await memR.json(); 1123 const groupMembers = new Set((grpD.members || []).map(m => m.id)); 1124 const allMembers = memD.members || []; 1125 1126 const confirmed = await new Promise(resolve => { 1127 const overlay = document.createElement('div'); 1128 overlay.className = 'modal-overlay active'; 1129 overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(5,5,8,.85);backdrop-filter:blur(8px)'; 1130 overlay.innerHTML = ` 1131 <div style="background:var(--surface2,#0e0e1a);border:1px solid rgba(255,255,255,.1);border-radius:16px;padding:24px;max-width:480px;width:90%;max-height:70vh;overflow-y:auto"> 1132 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px"> 1133 <div style="font-size:15px;font-weight:700">Manage: ${_escHtml(groupName)}</div> 1134 <button id="closeGroupMgr" style="background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer;padding:0 4px">Ã</button> 1135 </div> 1136 <div style="font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--muted);margin-bottom:10px">Members</div> 1137 <div style="display:flex;flex-direction:column;gap:6px"> 1138 ${allMembers.map(m => ` 1139 <div style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:rgba(255,255,255,.03);border-radius:8px"> 1140 <div> 1141 <div style="font-size:13px;font-weight:500">
1141${_escHtml(m.username || m.email)}</div> 1142 <div style="font-size:11px;color:var(--muted)">${_escHtml(m.email)}</div> 1143 </div> 1144 <label style="display:flex;align-items:center;gap:8px;cursor:pointer"> 1145 <input type="checkbox" data-uid="${m.id}" ${groupMembers.has(m.id) ? 'checked' : ''} style="cursor:pointer"> 1146 <span style="font-size:12px;color:var(--muted)">${groupMembers.has(m.id) ? 'In group' : 'Add'}</span> 1147 </label> 1148 </div>`).join('')} 1149 </div> 1150 <div style="display:flex;gap:8px;margin-top:16px"> 1151 <button id="saveGroupMembers" class="btn-primary" style="flex:1">Save Changes</button> 1152 <button id="closeGroupMgr2" class="btn-secondary" style="flex:1">Cancel</button> 1153 </div> 1154 </div>`; 1155 document.body.appendChild(overlay); 1156 document.getElementById('closeGroupMgr').onclick = () => { overlay.remove(); resolve(false); }; 1157 document.getElementById('closeGroupMgr2').onclick = () => { overlay.remove(); resolve(false); }; 1158 document.getElementById('saveGroupMembers').onclick = async () => { 1159 const checks = overlay.querySelectorAll('input[data-uid]'); 1160 const promises = []; 1161 checks.forEach(cb => { 1162 const uid = parseInt(cb.dataset.uid); 1163 const inGroup = groupMembers.has(uid); 1164 if (cb.checked && !inGroup) { 1165 promises.push(fetch(`/api/org/groups/${groupId}/members`, { 1166 method: 'POST', credentials: 'include', headers: _hdr(), 1167 body: JSON.stringify({ user_id: uid }) 1168 })); 1169 } else if (!cb.checked && inGroup) { 1170 promises.push(fetch(`/api/org/groups/${groupId}/members/${uid}`, { 1171 method: 'DELETE', credentials: 'include', headers: _hdr() 1172 })); 1173 } 1174 }); 1175 await Promise.all(promises); 1176 overlay.remove(); 1177 resolve(true); 1178 }; 1179 }); 1180 if (confirmed) { _toast('Group updated', 'success'); window.initOrgGroups(); } 1181 } catch(e) { _toast('Failed to load group', 'error'); } 1182}; 1183 1184function _escHtml(s) { 1185 return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); 1186}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.