PageSourceSearch

https://hexvault.co.uk/static/settings-handler.js

js hexvault.co.uk collected 2026-10-02 02:11:53 UTC 64,649 bytes, 1,186 lines download raw bytes

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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.