Scan manually added accounts
Build — Windows, Linux & macOS / GDPRScanner / linux (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / macos (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / windows (push) Has been cancelled
Build — Windows, Linux & macOS / Create GitHub Release (push) Has been cancelled

Manual accounts were never scanned:
- Sidebar "add user" entries had no platform, and getSelectedUsers()
  required platform === 'm365', so they were dropped from every scan.
- Profile-editor manual accounts were stored as "manual:<email>", which
  is not a Graph user id, and applying the profile never put them in the
  sidebar list, so they could not be selected.

Now the editor looks the email up via /api/users/lookup and stores the
directory id plus name/email (falling back to manual:<email> only when the
lookup fails). Applying a profile adds its manual accounts to the sidebar
and resolves legacy manual:<email> entries, logging any that can't be
found. The scan engine resolves remaining manual:<email> ids (e.g. from
scheduled scans) via the new M365Connector.lookup_user(), skipping and
reporting failures without widening to all users.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #1.
This commit is contained in:
StyxX65andClaude Opus 5.5 committed 2026-09-28 09:27:56 +02:00
1 parent bbe9c62ff9
commit 1c79ac15b7
11 files changed
+234 -18

No files matched your search

+6
View File
@@ -81,6 +81,12 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
- **`scheduler.js` strings must use `t()`** — frequency labels, "Next", "Running...", "Disabled", empty-job text, and empty-history text all have translation keys. Do not hard-code English strings in `schedLoad()` or `schedRenderJobs()`.
- **Scheduler UI — `schedToggleReportOnly()`** — dims the Profile row, shows/hides `#schedReportOnlyHint`, and forces `#schedAutoEmail` checked. Called from the checkbox `onchange` handler and at the start of `schedAddJob()` / `schedEditJob()`.
- **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked. Saved IDs not in `S._allUsers` (`manual:<email>`, users not loaded yet, users gone from the directory) are rendered by `_unresolvedAccountRows()` as checked rows so the next save keeps them — do not go back to building rows from `S._allUsers` alone (that silently dropped them on save). `user_ids: "all"` is kept as `"all"` when saved before users have loaded.
- **Manual accounts must be scannable** — they aren't in the `/api/users` directory list, so:
- `getSelectedUsers()` treats a missing `platform` as `'m365'`. Sidebar `addUserManually()` users historically had no platform and were silently excluded from every scan. Do not go back to a strict `u.platform === 'm365'` check.
- The profile editor's "+ Tilføj konto" looks the email up via `/api/users/lookup` and stores `{id: <directory id>, displayName, email, manual: true}`. It falls back to `"manual:<email>"` only when the lookup fails. Checkboxes with `data-manual` save as objects; everything else saves as plain id strings.
- `_applyProfile()` → `_addProfileManualUsers()` pushes manual accounts into `S._allUsers` (`manual: true`, `platform: 'm365'`). Legacy `manual:<email>` entries are looked up and the pending ID is rewritten to the resolved one. Without this the account can never be ticked in the sidebar, so it's never scanned.
- `_userListFinal()` keys off `S._m365UsersLoaded`, not `S._allUsers.length`, because manual users can be pushed before the directory loads.
- Server side, `scan_engine._resolve_manual_users()` resolves any remaining `manual:<email>` (e.g. from scheduled scans) via `conn.lookup_user()`. It skips and reports failures and never widens to "all users".
- **Pending profile users wait for the Google merge** — `_applyPendingProfileUsers()` keeps `window._pendingProfileUserIds` until every ID is matched or `_userListFinal()` (users loaded, and Google either not connected or `S._googleUsersMerged`). `_mergeGoogleUsers()` re-applies in its `finally`. A saved `google:<email>` also matches an M365 user merged to `platform: 'both'` via `u.googleEmail`. Do not clear the pending IDs unconditionally — Google-only users would stay unticked.
- **Date presets** — stored as `years * 365` (integer days). Do not use `* 365.25`.
- **`copyTokenLink` is async** — called from `onclick` as fire-and-forget. Do not make it synchronous.
+75 -12
View File
@@ -215,6 +215,7 @@ function _applyProfile(profile) {
}
} else if (Array.isArray(profile.user_ids) && profile.user_ids.length) {
window._pendingProfileUserIds = profile.user_ids.map(u => u.id || u);
_addProfileManualUsers(profile.user_ids);
_applyPendingProfileUsers();
} else if (Array.isArray(profile.user_ids) && profile.user_ids.length === 0) {
// Explicitly empty list — deselect everyone so previous sidebar state doesn't persist
@@ -229,7 +230,7 @@ function _applyProfile(profile) {
// un-awaited _mergeGoogleUsers(). Keep the pending IDs until every one is
// matched or the user list is final, else those users stay unticked.
function _userListFinal() {
return S._allUsers.length > 0 && (!window._googleConnected || S._googleUsersMerged);
return S._m365UsersLoaded && (!window._googleConnected || S._googleUsersMerged);
}
function _applyPendingProfileUsers() {
@@ -244,6 +245,54 @@ function _applyPendingProfileUsers() {
if (found >= wanted.size || _userListFinal()) window._pendingProfileUserIds = null;
}
function _isManualEntry(u) {
return typeof u === 'object' ? !!u.manual : String(u).startsWith('manual:');
}
// Manual accounts aren't in the /api/users directory list, so they must be added
// to S._allUsers (as sidebar manual users) or they can never be selected/scanned.
// Legacy "manual:<email>" entries are looked up to get their directory id.
function _addProfileManualUsers(entries) {
entries.filter(_isManualEntry).forEach(async function(u) {
if (typeof u === 'object' && !String(u.id).startsWith('manual:')) {
if (!S._allUsers.some(x => x.id === u.id)) {
S._allUsers.push({id: u.id, displayName: u.displayName || u.email || u.id,
email: u.email || '', userRole: u.userRole || 'other',
platform: 'm365', manual: true, selected: false});
}
return; // selection is handled by _applyPendingProfileUsers()
}
const legacyId = u.id || u;
try {
const email = legacyId.slice('manual:'.length);
const d = await _lookupM365User(email);
if (!d) {
// Not connected / not found — scheduled scans retry the lookup at scan time
log(t('m365_profile_manual_not_found', 'Manual account not found in M365') + ': ' + email, 'err');
return;
}
let user = S._allUsers.find(x => x.id === d.id);
if (!user) {
user = {...d, platform: 'm365', manual: true};
S._allUsers.push(user);
}
user.selected = true;
// Pending apply may re-run later — make it select the resolved id, not the legacy one
const pend = window._pendingProfileUserIds;
if (pend) window._pendingProfileUserIds = pend.map(x => x === legacyId ? d.id : x);
renderAccountList();
} catch(e) { /* leave for the scan-time fallback */ }
});
}
// Returns {id, displayName, email} or null when M365 isn't connected / the user doesn't exist.
async function _lookupM365User(upn) {
const r = await fetch('/api/users/lookup?upn=' + encodeURIComponent(upn));
if (!r.ok) return null;
const d = await r.json();
return d && d.id ? d : null;
}
async function saveCurrentAsProfile() {
const name = prompt(t('m365_profile_save_prompt', 'Profile name:'),
S._activeProfileId
@@ -385,12 +434,14 @@ function _openEditorForProfile(profile) {
const accountRows = S._allUsers.map(u => {
// Only check if the user was explicitly saved — default to unchecked like the main window
const checked = isSaved(u) ? 'checked' : '';
const manualAttrs = u.manual
? ` data-manual="1" data-name="${_esc(u.displayName || '')}" data-email="${_esc(u.email || '')}"` : '';
const platBadge = u.platform === 'both' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:linear-gradient(90deg,#E6F1FB 50%,#EAF3DE 50%);color:#1a4a1a;font-weight:500;border:0.5px solid #b5d4b5">M365+GWS</span>'
: (u.platform || 'm365') === 'google' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#EAF3DE;color:#3B6D11;font-weight:500">GWS</span>'
: '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#E6F1FB;color:#185FA5;font-weight:500">M365</span>';
const roleBadge = u.userRole === 'student' ? t('role_student','Elev') : u.userRole === 'staff' ? t('role_staff','Ansat') : t('role_other','Anden');
const roleOverrideStyle = u.roleOverride ? 'color:var(--color-text-info);outline:1px solid var(--color-border-info);' : '';
return `<label class="pmgmt-acct-row" data-uid="${_esc(u.id)}" data-role="${_esc(u.userRole || 'other')}"><input type="checkbox" ${checked} data-uid="${_esc(u.id)}"><span style="flex:1;color:var(--color-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(u.displayName)}</span>${platBadge}<button type="button" class="pmgmt-role-badge" data-uid="${_esc(u.id)}" onclick="_pmgmtCycleRole(this.getAttribute('data-uid'),event)" style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;border:none;cursor:pointer;${roleOverrideStyle}">${roleBadge}</button></label>`;
return `<label class="pmgmt-acct-row" data-uid="${_esc(u.id)}" data-role="${_esc(u.userRole || 'other')}"><input type="checkbox" ${checked} data-uid="${_esc(u.id)}"${manualAttrs}><span style="flex:1;color:var(--color-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(u.displayName)}</span>${platBadge}<button type="button" class="pmgmt-role-badge" data-uid="${_esc(u.id)}" onclick="_pmgmtCycleRole(this.getAttribute('data-uid'),event)" style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;border:none;cursor:pointer;${roleOverrideStyle}">${roleBadge}</button></label>`;
}).join('') + _unresolvedAccountRows(savedList);
body.innerHTML = `
@@ -492,11 +543,13 @@ function _unresolvedAccountRows(savedList) {
const id = u.id || u;
if (!id || known.has(id) || seen.has(id)) return '';
seen.add(id);
const isManual = id.startsWith('manual:');
const isManual = _isManualEntry(u);
const label = (typeof u === 'object' && u.displayName) || id.replace(/^(manual|google):/, '');
const badge = isManual ? t('m365_profile_acct_manual', 'Manual')
: t('m365_profile_acct_unresolved', 'Not loaded');
return `<label class="pmgmt-acct-row" data-uid="${_esc(id)}" data-role="${_esc((typeof u === 'object' && u.userRole) || 'other')}"><input type="checkbox" checked data-uid="${_esc(id)}"><span style="flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${_esc(id)}">${_esc(label)}</span><span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">${_esc(badge)}</span></label>`;
const manualAttrs = _isManualEntry(u) && typeof u === 'object'
? ` data-manual="1" data-name="${_esc(u.displayName || '')}" data-email="${_esc(u.email || '')}"` : '';
return `<label class="pmgmt-acct-row" data-uid="${_esc(id)}" data-role="${_esc((typeof u === 'object' && u.userRole) || 'other')}"><input type="checkbox" checked data-uid="${_esc(id)}"${manualAttrs}><span style="flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${_esc(id)}">${_esc(label)}</span><span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">${_esc(badge)}</span></label>`;
}).join('');
}
@@ -628,18 +681,24 @@ function _pmgmtRoleFilter(role) {
_pmgmtFilterAccounts(document.getElementById('pmgmtAcctSearch')?.value || '');
}
function _pmgmtAddManual() {
const email = prompt('E-mail adresse:');
if (!email || !email.trim()) return;
async function _pmgmtAddManual() {
const email = (prompt('E-mail adresse:') || '').trim();
if (!email) return;
const list = document.getElementById('pmgmtAcctList');
if (!list) return;
const id = 'manual:' + email.trim().toLowerCase();
if (list.querySelector(`input[data-uid="${id}"]`)) return; // already exists
// Resolve to the directory id so the scan can use it. If M365 isn't reachable
// right now, fall back to "manual:<email>" — the scan engine resolves that later.
let d = null;
try { d = await _lookupM365User(email); } catch(e) { /* offline — use fallback */ }
const id = d ? d.id : 'manual:' + email.toLowerCase();
const label = d ? (d.displayName || email) : email;
const existing = [...list.querySelectorAll('input[data-uid]')].find(cb => cb.dataset.uid === id);
if (existing) { existing.checked = true; return; } // already listed — just tick it
const lbl = document.createElement('label');
lbl.className = 'pmgmt-acct-row';
lbl.dataset.uid = id;
lbl.dataset.role = 'other';
lbl.innerHTML = `<input type="checkbox" checked data-uid="${_esc(id)}"><span style="flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(email.trim())}</span><span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">${_esc(t('m365_profile_acct_manual', 'Manual'))}</span>`;
lbl.innerHTML = `<input type="checkbox" checked data-uid="${_esc(id)}" data-manual="1" data-name="${_esc(label)}" data-email="${_esc(d ? (d.email || email) : email)}"><span style="flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${_esc(email)}">${_esc(label)}</span><span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">${_esc(t('m365_profile_acct_manual', 'Manual'))}</span>`;
list.appendChild(lbl);
}
@@ -674,8 +733,10 @@ async function _pmgmtSaveFullEdit() {
const effectiveFileSources = fileRendered ? fileSources : (profile.file_sources || []);
const allSources = m365Sources.concat(effectiveGoogleSources).concat(effectiveFileSources);
let user_ids = Array.from(document.querySelectorAll('#pmgmtAcctList input[type=checkbox]:checked'))
.map(cb => cb.dataset.uid)
.filter(Boolean);
.filter(cb => cb.dataset.uid)
.map(cb => cb.dataset.manual
? {id: cb.dataset.uid, displayName: cb.dataset.name || '', email: cb.dataset.email || '', manual: true}
: cb.dataset.uid);
// "all" profile opened before users loaded: there were no rows to tick, so keep "all"
if (window._pmgmtSavedAll && !S._allUsers.length && !user_ids.length) user_ids = 'all';
const updated = {
@@ -810,6 +871,8 @@ window._pmgmtUse = _pmgmtUse;
window._pmgmtOpenEditor = _pmgmtOpenEditor;
window._openEditorForProfile = _openEditorForProfile;
window._unresolvedAccountRows = _unresolvedAccountRows;
window._addProfileManualUsers = _addProfileManualUsers;
window._lookupM365User = _lookupM365User;
window._userListFinal = _userListFinal;
window._peSetDate = _peSetDate;
window._peSetYear = _peSetYear;
+1
View File
@@ -19,6 +19,7 @@ export const S = {
_progressCurrentUser: '',
// Users
_allUsers: [],
_m365UsersLoaded: false, // true once loadUsers() has fetched the directory
_googleUsersMerged: false, // true once _mergeGoogleUsers() has added Google users
// Auth
_currentAppMode: null,
+8 -5
View File
@@ -28,6 +28,7 @@ async function loadUsers() {
u.selected = prevSelected.has(u.id) ? prevSelected.get(u.id) : false;
});
S._allUsers = [...fetched, ...toAdd];
S._m365UsersLoaded = true;
// Apply deferred "select all" from a profile chosen before users loaded
if (window._pendingProfileAllUsers) {
S._allUsers.forEach(u => { u.selected = true; });
@@ -421,7 +422,7 @@ async function addUserManually() {
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});
S._allUsers.push({...d, platform: 'm365', manual: true});
input.value = '';
renderAccountList(S._allUsers.filter(u => !u.manual).length <= 1);
} catch(e) {
@@ -447,16 +448,18 @@ function selectAllAccounts(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'));
// Manually added users may have no platform — they are M365 lookups, so treat missing as 'm365'
let selected = S._allUsers.filter(u => u.selected !== false && ['m365', 'both'].includes(u.platform || 'm365'));
// 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'
}));
// manual/email let a profile saved from this selection re-add manual users on apply
return selected.map(u => Object.assign(
{ id: u.id, displayName: u.displayName, userRole: u.userRole || 'other' },
u.manual ? { manual: true, email: u.email || '' } : {}));
}
// Fallback to DOM if S._allUsers not yet populated
return Array.from(document.querySelectorAll('.account-check:checked')).map(cb => ({