Files
GDPRScanner/static/js/users.js
T
StyxX65andClaude Opus 5.5 bbe9c62ff9 Fix profiles losing their selected users
Starting an M365 scan wrote the sidebar state back into the active profile
via _save_settings(), replacing its user_ids with the sidebar selection
(minus Google-only users and anything hidden by the role filter). With no
profile active it overwrote profiles[0]. Profiles are loaders, so scan
start no longer writes them; profile_id is audit-only.

The profile editor built its account list from S._allUsers only, so
manually added accounts, users not yet loaded, and users gone from the
directory were not rendered and got dropped on the next save. They now
render as checked "Manual" / "Not loaded" rows, and user_ids "all" is kept
when saved before users load.

Applying a profile cleared the pending user IDs before the un-awaited
Google merge had added google:<email> users, leaving them unticked.
Pending IDs are now kept until all are matched or the user list is final,
and _mergeGoogleUsers() re-applies them. A saved google:<email> also
matches an M365 user merged to platform 'both'.

Adds regression tests for scan start leaving profiles untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:22:32 +02:00

487 lines
23 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { S } from './state.js';
// ── Accounts ──────────────────────────────────────────────────────────────────
async function loadUsers() {
const list = document.getElementById('accountsList');
const loading = document.getElementById('accountsLoading');
if (!list) return;
if (loading) loading.textContent = t('lbl_loading', 'Loading…');
// Ensure source panel checkboxes exist before we render the account list
if (!document.querySelector('#sourcesPanel input') && typeof renderSourcesPanel === 'function') {
renderSourcesPanel();
}
try {
const r = await fetch('/api/users');
if (!r.ok) { if (loading) loading.textContent = 'Could not load users'; return; }
const d = await r.json();
if (d.error) { if (loading) loading.textContent = d.error; return; }
// Merge with any manually-added users, preserving them
const fetched = d.users || [];
fetched.forEach(u => { u.platform = 'm365'; });
const existingManual = S._allUsers.filter(u => u.manual);
const fetchedIds = new Set(fetched.map(u => u.id));
const toAdd = existingManual.filter(u => !fetchedIds.has(u.id));
// Preserve existing selected state for users already in S._allUsers;
// new users default to selected=true
const prevSelected = new Map(S._allUsers.map(u => [u.id, u.selected]));
fetched.forEach(u => {
u.selected = prevSelected.has(u.id) ? prevSelected.get(u.id) : false;
});
S._allUsers = [...fetched, ...toAdd];
// Apply deferred "select all" from a profile chosen before users loaded
if (window._pendingProfileAllUsers) {
S._allUsers.forEach(u => { u.selected = true; });
window._pendingProfileAllUsers = false;
}
renderAccountList(fetched.length <= 1);
// Merge Google users separately so they're not blocked by M365 auth timing
_mergeGoogleUsers();
checkCheckpoint();
checkDeltaStatus();
_applyPendingProfileUsers();
// Show warning banner when no users could be classified
const warn = document.getElementById('skuWarnBanner');
if (warn) {
const allOther = fetched.length > 0 && fetched.every(u => u.userRole === 'other');
warn.style.display = allOther ? 'block' : 'none';
}
} catch(e) {
if (loading) loading.textContent = 'Could not load users';
}
}
async function _mergeGoogleUsers() {
if (!window._googleConnected) return;
try {
var gr = await fetch('/api/google/scan/users');
if (!gr.ok) return;
var gd = await gr.json();
if (gd.error) return;
var prevSelected = new Map(S._allUsers.map(function(u){ return [u.id, u.selected]; }));
// Build displayName → Google user map for cross-platform matching
// Both M365 and GWS are maintained from AD — full name is identical
var googleByName = {};
(gd.users || []).forEach(function(gu) {
var name = (gu.displayName || '').trim().toLowerCase();
if (name) googleByName[name] = gu;
});
// Merge onto M365 users where display name matches
var matchedNames = new Set();
S._allUsers.forEach(function(u) {
if ((u.platform || 'm365') !== 'm365') return;
var name = (u.displayName || '').trim().toLowerCase();
var gu = googleByName[name];
if (gu) {
u.platform = 'both';
u.googleEmail = gu.email;
// Keep M365 displayName (from AD, authoritative)
matchedNames.add(name);
} else {
// Clear previous merge if Google disconnected
delete u.googleEmail;
u.platform = 'm365';
}
});
// Add unmatched Google users as standalone entries
var googleUsers = [];
(gd.users || []).forEach(function(gu) {
var name = (gu.displayName || '').trim().toLowerCase();
if (matchedNames.has(name)) return; // already merged
var uid = 'google:' + gu.email;
googleUsers.push({
id: uid,
displayName: gu.displayName || gu.email,
email: gu.email,
userRole: gu.userRole || 'other',
platform: 'google',
selected: prevSelected.has(uid) ? prevSelected.get(uid) : false,
});
});
// Remove stale standalone Google users, add fresh unmatched ones
S._allUsers = S._allUsers.filter(function(u){ return (u.platform||'m365') !== 'google'; });
S._allUsers = S._allUsers.concat(googleUsers);
renderAccountList();
} catch(e) { /* Google users unavailable */ }
finally {
// Google users are now as complete as they'll get (even on error/early
// return) — finish a profile apply that was waiting on google:<email> IDs.
S._googleUsersMerged = true;
_applyPendingProfileUsers();
}
}
let _activeRoleFilter = ''; // '' = all, 'staff', 'student'
// ── Sidebar section collapse ──────────────────────────────────────────────────
const _COLLAPSE_SECTIONS = ['sourcesPanelSection', 'optionsSection', 'accountsSection', 'logSection'];
function toggleSection(id) {
const body = document.getElementById(id + 'Body');
if (!body) return;
const collapsing = body.style.display !== 'none';
body.style.display = collapsing ? 'none' : '';
const btn = document.getElementById(id + '-btn');
if (btn) btn.textContent = collapsing ? '▸' : '▾';
if (id === 'accountsSection') {
const sec = document.getElementById('accountsSection');
if (sec) sec.style.flex = collapsing ? '0 0 auto' : '1';
}
try { localStorage.setItem('sc_' + id, collapsing ? '1' : '0'); } catch(e) {}
}
function restoreSectionStates() {
_COLLAPSE_SECTIONS.forEach(function(id) {
try {
if (localStorage.getItem('sc_' + id) === '1') {
const body = document.getElementById(id + 'Body');
if (body) body.style.display = 'none';
const btn = document.getElementById(id + '-btn');
if (btn) btn.textContent = '▸';
if (id === 'accountsSection') {
const sec = document.getElementById('accountsSection');
if (sec) sec.style.flex = '0 0 auto';
}
}
} catch(e) {}
});
}
// ── Role filter with counts ───────────────────────────────────────────────────
function updateRoleFilterCounts() {
const total = S._allUsers.filter(function(u){ return !u.manual; }).length;
const staff = S._allUsers.filter(function(u){ return !u.manual && u.userRole === 'staff'; }).length;
const student = S._allUsers.filter(function(u){ return !u.manual && u.userRole === 'student'; }).length;
const btnAll = document.getElementById('rfAll');
const btnStaff = document.getElementById('rfStaff');
const btnStudent = document.getElementById('rfStudent');
if (btnAll) btnAll.textContent = t('m365_role_all','All') + (total ? ' (' + total + ')' : '');
if (btnStaff) btnStaff.textContent = t('role_staff','Ansat') + (staff ? ' (' + staff + ')' : '');
if (btnStudent) btnStudent.textContent = t('role_student','Elev') + (student ? ' (' + student + ')' : '');
}
function setRoleFilter(role) {
_activeRoleFilter = role;
[['rfAll',''],['rfStaff','staff'],['rfStudent','student']].forEach(function(pair) {
const btn = document.getElementById(pair[0]);
if (!btn) return;
const active = role === pair[1];
btn.style.background = active ? 'var(--accent)' : 'none';
btn.style.color = active ? '#fff' : 'var(--muted)';
});
updateRoleFilterCounts();
filterUsers();
}
// ── Last scan summary (empty state) ──────────────────────────────────────────
async function loadLastScanSummary() {
try {
const r = await fetch('/api/db/stats');
const d = await r.json();
if (!d.scan_id || S.flaggedData.length > 0 || S._m365ScanRunning || S._googleScanRunning || S._fileScanRunning) return;
const panel = document.getElementById('lastScanSummary');
const empty = document.getElementById('emptyState');
if (!panel || !empty) return;
const dateStr = d.finished_at
? new Date(d.finished_at * 1000).toLocaleDateString('da-DK', {day:'numeric', month:'short', year:'numeric'})
: '—';
const sources = Object.keys(d.by_source || {});
const srcLabels = {'email':'Outlook','onedrive':'OneDrive','sharepoint':'SharePoint','teams':'Teams',
'gmail':'Gmail','gdrive':'Drive','local':'Lokale filer','smb':'SMB'};
const srcStr = sources.map(function(s){ return srcLabels[s] || s; }).join(' · ') || '—';
panel.innerHTML =
'<div class="last-scan-card">' +
'<h3>' + t('last_scan_title', 'Seneste scanning') + '</h3>' +
'<div class="last-scan-stats">' +
'<div class="last-scan-stat"><span class="val">' + (d.flagged_count || 0) + '</span><span class="lbl">' + t('last_scan_hits', 'Fund') + '</span></div>' +
'<div class="last-scan-stat"><span class="val">' + (d.unique_subjects || 0) + '</span><span class="lbl">' + t('last_scan_subjects', 'Unikke CPR') + '</span></div>' +
'<div class="last-scan-stat"><span class="val">' + (d.total_scanned || 0) + '</span><span class="lbl">' + t('last_scan_scanned', 'Scannet') + '</span></div>' +
'</div>' +
'<div style="margin-top:12px;font-size:11px;color:var(--muted)">' + dateStr + ' &nbsp;·&nbsp; ' + srcStr + '</div>' +
'</div>' +
'<div class="empty-text" style="font-size:12px">' + t('m365_empty_hint', 'Vælg kilder og klik på <strong>Scan</strong><br>for at starte en ny scanning') + '</div>';
empty.style.display = 'none';
panel.style.display = 'flex';
} catch(e) {}
}
function renderAccountList(showAdminNote = false) {
updateRoleFilterCounts();
const list = document.getElementById('accountsList');
if (!list) return;
const q = (document.getElementById('userSearch')?.value || '').toLowerCase().trim();
let visible = S._allUsers;
// Filter by platform: only show accounts relevant to checked sources
// If the sources panel hasn't been rendered yet (no checkboxes at all), treat M365 as active
var panelHasAny = !!document.querySelector('#sourcesPanel input[data-source-type]');
var hasM365Src = panelHasAny
? !!document.querySelector('#sourcesPanel input[data-source-type="m365"]:checked')
: S._allUsers.some(function(u){ return !u.platform || u.platform === 'm365' || u.platform === 'both'; });
var hasGoogleSrc = !!document.querySelector('#sourcesPanel input[data-source-type="google"]:checked');
// Always filter — if neither is active, show nothing
// Check if Google is enabled in Source Management (not just selected in KILDER)
var googleEnabled = !!(document.getElementById('smGoogleSrcGmail') && document.getElementById('smGoogleSrcGmail').checked)
|| !!(document.getElementById('smGoogleSrcDrive') && document.getElementById('smGoogleSrcDrive').checked);
var effectiveGws = hasGoogleSrc && googleEnabled;
visible = visible.filter(function(u) {
var plat = u.platform || 'm365';
if (plat === 'both') return hasM365Src || effectiveGws;
return (plat === 'm365' && hasM365Src) || (plat === 'google' && effectiveGws);
});
// Apply role filter first
if (_activeRoleFilter) {
visible = visible.filter(u => (u.userRole || 'other') === _activeRoleFilter);
}
// Then apply text search
if (q) {
visible = visible.filter(u =>
(u.displayName || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q));
}
_updateUserCountBadge(visible.length, S._allUsers.length);
const note = (!q && !_activeRoleFilter && showAdminNote)
? `<div style="font-size:10px;color:var(--muted);padding:4px 0 6px;line-height:1.4">${t('m365_admin_note','Only showing your account. To list all users, an admin must grant <strong>User.Read.All</strong> consent.')}</div>`
: '';
const noMatch = (q || _activeRoleFilter) && !visible.length
? `<div style="padding:4px 0;color:var(--muted);font-size:11px">${t('m365_no_users_match','No users match')} "${q || _activeRoleFilter}"</div>`
: '';
list.innerHTML = note + noMatch + visible.map(u => `
<label style="display:flex;align-items:center;gap:7px;padding:2px 0;cursor:pointer">
<input type="checkbox" class="account-check" data-id="${u.id}" data-name="${u.displayName}" data-role="${u.userRole || 'other'}"
${u.selected !== false ? 'checked' : ''}
onchange="onAccountCheckChange('${u.id}', this.checked)">
<span style="flex:1;overflow:hidden">
<span style="display:block;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${u.displayName}${u.isMe ? ' <span style=\'color:var(--accent);font-size:10px\'>(you)</span>' : ''}${u.manual ? ' <span style=\'color:var(--muted);font-size:10px\'>(manual)</span>' : ''}</span>
<span style="color:var(--muted);font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block">${u.email}</span>
</span>
<span style="font-size:9px;padding:1px 5px;border-radius:10px;flex-shrink:0;"
class="${u.platform==='both' ? (hasM365Src && effectiveGws ? 'plat-badge-both' : effectiveGws ? 'plat-badge-google' : 'plat-badge-m365') : (u.platform||'m365')==='google' ? 'plat-badge-google' : 'plat-badge-m365'}">
${u.platform==='both' ? (hasM365Src && effectiveGws ? 'M365 + GWS' : effectiveGws ? 'GWS' : 'M365') : (u.platform||'m365')==='google' ? 'GWS' : 'M365'}
</span>
<button type="button" onclick="cycleUserRole(this.getAttribute('data-uid'))"
data-uid="${u.id.replace(/&/g,'&amp;').replace(/'/g,'&#39;').replace(/"/g,'&quot;')}"
title="${t('m365_role_cycle_tip','Click to change role')}"
class="role-badge" style="font-size:9px;padding:1px 5px;cursor:pointer;flex-shrink:0;white-space:nowrap;border:none;${u.roleOverride ? 'color:var(--color-text-info);outline:1px solid var(--color-border-info)' : ''}">
${u.userRole === 'student' ? t('role_student','Elev') : u.userRole === 'staff' ? t('role_staff','Ansat') : t('role_other','Anden')}${u.roleOverride ? ' ✎' : ''}
</button>
${u.manual ? `<button onclick="removeUser(this.getAttribute('data-uid'))" data-uid="${u.id.replace(/&/g,'&amp;').replace(/'/g,'&#39;').replace(/"/g,'&quot;')}" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:0;flex-shrink:0" title="Remove">×</button>` : ''}
</label>`).join('');
}
function _updateUserCountBadge(visible, total) {
const badge = document.getElementById('userCountBadge');
if (!badge) return;
if (total === 0) { badge.textContent = ''; return; }
badge.textContent = visible < total ? `(${visible} / ${total})` : `(${total})`;
}
// ── SKU debug — surface unknown tenant SKU IDs so they can be added to m365_skus.json ──
async function showSkuDebug() {
let modal = document.getElementById('skuDebugModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'skuDebugModal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1000;display:flex;align-items:center;justify-content:center';
modal.onclick = e => { if (e.target === modal) modal.remove(); };
document.body.appendChild(modal);
}
modal.innerHTML = `<div style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:22px 26px;width:min(520px,95vw);max-height:80vh;display:flex;flex-direction:column;gap:12px;font-size:12px">
<div style="display:flex;align-items:center;justify-content:space-between">
<strong style="font-size:13px">${t('m365_sku_debug_title','🔍 Tenant SKU IDs')}</strong>
<button onclick="document.getElementById('skuDebugModal').remove()" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:16px">×</button>
</div>
<div style="color:var(--muted);font-size:11px;line-height:1.5">${t('m365_sku_debug_desc','These are the raw SKU IDs assigned to your users. Any marked <b>❓ unknown</b> are not in <code>classification/m365_skus.json</code> — copy them in under <code>student_ids</code> or <code>staff_ids</code> and restart.')}</div>
<div id="skuDebugList" style="overflow-y:auto;flex:1;font-family:var(--mono);font-size:11px">Loading…</div>
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;border-top:1px solid var(--border)">
<button onclick="document.getElementById('skuDebugModal').remove()" style="background:none;border:1px solid var(--border);color:var(--muted);padding:4px 14px;border-radius:6px;cursor:pointer">${t('btn_close','Close')}</button>
</div>
</div>`;
const listEl = document.getElementById('skuDebugList');
try {
const r = await fetch('/api/users/license_debug');
const d = await r.json();
if (d.error) { listEl.textContent = 'Error: ' + d.error; return; }
// Collect unique SKUs across all users
const skuSeen = {}; // skuId → {name, role, count, known}
for (const u of (d.users || [])) {
for (let i = 0; i < (u.skuIds || []).length; i++) {
const id = u.skuIds[i];
const nm = (u.skuNames || [])[i] || '';
if (!skuSeen[id]) skuSeen[id] = { name: nm, role: u.role, count: 0 };
skuSeen[id].count++;
}
}
const rows = Object.entries(skuSeen).sort((a,b) => b[1].count - a[1].count);
if (!rows.length) { listEl.textContent = t('m365_sku_debug_none','No license data returned — check that the app has User.Read.All permission.'); return; }
const knownStudent = new Set((d.student_ids || []));
const knownStaff = new Set((d.staff_ids || []));
listEl.innerHTML = rows.map(([id, info]) => {
const known = knownStudent.has(id) ? '🎓 student'
: knownStaff.has(id) ? '👔 staff'
: '❓ unknown';
const color = known.startsWith('❓') ? 'var(--danger)' : 'var(--accent)';
return `<div style="display:flex;align-items:baseline;gap:8px;padding:3px 0;border-bottom:1px solid var(--border)">
<code style="flex:1;color:var(--text);user-select:all">${id}</code>
<span style="color:var(--muted);font-size:10px;white-space:nowrap">${info.name || '—'}</span>
<span style="color:${color};font-size:10px;white-space:nowrap;flex-shrink:0">${known} (${info.count})</span>
</div>`;
}).join('');
} catch(e) {
listEl.textContent = 'Error: ' + e.message;
}
}
function filterUsers() {
const showAdminNote = S._allUsers.filter(u => !u.manual).length <= 1;
renderAccountList(showAdminNote);
}
async function cycleUserRole(id) {
// Cycle: student → staff → other → (clear override, back to auto)
if (!id) { console.warn('cycleUserRole: no id'); return; }
const u = S._allUsers.find(u => u.id === id);
if (!u) { console.warn('cycleUserRole: user not found for id', id); return; }
const cycle = ['student', 'staff', 'other'];
let next;
if (!u.roleOverride) {
// First click: remember auto role, pin to next in cycle
u._autoRole = u.userRole;
u._cycleSteps = 0;
const cur = cycle.indexOf(u.userRole);
next = cycle[(cur + 1) % cycle.length];
} else {
u._cycleSteps = (u._cycleSteps || 0) + 1;
if (u._cycleSteps >= cycle.length) {
next = ''; // full cycle completed — clear override
} else {
const cur = cycle.indexOf(u.userRole);
next = cycle[(cur + 1) % cycle.length];
}
}
try {
const r = await fetch('/api/users/role_override', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user_id: id, role: next})
});
const d = await r.json();
if (d.error) { log('Role override failed: ' + d.error, 'err'); return; }
// Update local state
if (next) {
if (!u.roleOverride) u._autoRole = u.userRole; // remember original for clear
u.userRole = next;
u.roleOverride = true;
} else {
u.userRole = u._autoRole || u.userRole;
u.roleOverride = false;
u._autoRole = undefined;
}
// Update the role filter count badges and re-render
renderAccountList(S._allUsers.filter(u => !u.manual).length <= 1);
log((next ? t('m365_role_set', 'Role set') + ': ' + next : t('m365_role_cleared', 'Role override cleared')) + ' — ' + (u.displayName || id));
} catch(e) {
log('Role override error: ' + e.message, 'err');
}
}
function removeUser(id) {
S._allUsers = S._allUsers.filter(u => u.id !== id);
renderAccountList(S._allUsers.filter(u => !u.manual).length <= 1);
}
async function addUserManually() {
const input = document.getElementById('addUserInput');
const upn = input.value.trim();
if (!upn) return;
// Look up the user via server
const btn = input.nextElementSibling;
btn.disabled = true; btn.textContent = '…';
try {
const r = await fetch('/api/users/lookup?upn=' + encodeURIComponent(upn));
const d = await r.json();
if (d.error) { alert('User not found: ' + d.error); return; }
if (S._allUsers.find(u => u.id === d.id)) { alert('User already in list.'); return; }
S._allUsers.push({...d, manual: true});
input.value = '';
renderAccountList(S._allUsers.filter(u => !u.manual).length <= 1);
} catch(e) {
alert('Lookup failed: ' + e.message);
} finally {
btn.disabled = false; btn.textContent = '+';
}
}
function onAccountCheckChange(id, checked) {
const user = S._allUsers.find(u => u.id === id);
if (user) user.selected = checked;
}
function selectAllAccounts(checked) {
// Toggle all visible users (respects search + role filter)
const visible = new Set(
Array.from(document.querySelectorAll('#accountsList .account-check')).map(cb => cb.dataset.id)
);
S._allUsers.forEach(u => { if (visible.has(u.id)) u.selected = checked; });
document.querySelectorAll('#accountsList .account-check').forEach(cb => cb.checked = checked);
}
function getSelectedUsers() {
// Only return M365 users — Google users are handled separately via selectedGoogleEmails
let selected = S._allUsers.filter(u => u.selected !== false && (u.platform === 'm365' || u.platform === 'both'));
// Respect the active role filter — hidden users must not sneak into the scan
// even if they were checked before the filter was applied.
if (_activeRoleFilter) {
selected = selected.filter(u => (u.userRole || 'other') === _activeRoleFilter);
}
if (selected.length) {
return selected.map(u => ({
id: u.id, displayName: u.displayName, userRole: u.userRole || 'other'
}));
}
// Fallback to DOM if S._allUsers not yet populated
return Array.from(document.querySelectorAll('.account-check:checked')).map(cb => ({
id: cb.dataset.id, displayName: cb.dataset.name, userRole: cb.dataset.role || 'other'
}));
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window.loadUsers = loadUsers;
window._mergeGoogleUsers = _mergeGoogleUsers;
window.toggleSection = toggleSection;
window.restoreSectionStates = restoreSectionStates;
window.updateRoleFilterCounts = updateRoleFilterCounts;
window.setRoleFilter = setRoleFilter;
window.loadLastScanSummary = loadLastScanSummary;
window.renderAccountList = renderAccountList;
window._updateUserCountBadge = _updateUserCountBadge;
window.showSkuDebug = showSkuDebug;
window.filterUsers = filterUsers;
window.cycleUserRole = cycleUserRole;
window.removeUser = removeUser;
window.addUserManually = addUserManually;
window.onAccountCheckChange = onAccountCheckChange;
window.selectAllAccounts = selectAllAccounts;
window.getSelectedUsers = getSelectedUsers;
window._activeRoleFilter = _activeRoleFilter;
window._COLLAPSE_SECTIONS = _COLLAPSE_SECTIONS;