Show account names and roles on cards from scheduled scans

Cards from scheduled scans of editor-saved profiles showed the account's
GUID as its name and no Elev/Ansat badge: the profile editor stored bare
id strings, the scheduler passes them straight to run_scan, and run_scan
normalised them to displayName=<id>, userRole="other". Before 1.7.10 the
scan-start profile overwrite kept repairing profiles to named objects,
which is why this surfaced now.

- run_scan: _enrich_bare_users() looks bare accounts up in the directory
  (list_users, falling back to lookup_user) for name and role.
- M365Connector.build_role_sku_map(): the SKU map /api/users built inline,
  now shared so cards and the account list classify roles identically.
  lookup_user() also returns assignedLicenses.
- Profile editor saves {id, displayName, userRole} (manual accounts keep
  email/manual), like the 💾 save.
- _accountPill: an account_name equal to account_id counts as missing;
  name and role are filled from the loaded user list, so existing cards
  display correctly without a rescan.
- Excel export: the scan's account-name map no longer breaks on bare id
  strings (.get() on a str raised and dropped the whole map).

Tests: tests/test_account_names.py (10); the run_scan test fails on the
old engine with "Collecting emails — guid-henrik…".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
StyxX65andClaude Opus 5.5 committed 2026-09-28 14:40:14 +02:00
1 parent c55db48894
commit 20156ffc93
8 files changed
+283 -37

No files matched your search

+2 -2
View File
@@ -57,7 +57,7 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
## Card user/group badge — results.js
- **`_accountPill(f)`** builds the account/role pill for both card layouts (list + grid). The **group badge is driven by `f.user_role`** (`student`/`staff`) alone, so it renders even with no display name — items from scans saved before `account_name` was persisted (DB migration 11) have only `user_role` + `account_id`. The user label resolves best-effort: `f.account_name` → `S._allUsers` match (by `id` or `email`) → email-style `account_id` → omit. Do not re-nest the role badge inside an `account_name` check (the old bug) — that hides the group badge for legacy items. Both layouts call `_accountPill(f)`; keep them sharing the one helper.
- **`_accountPill(f)`** builds the account/role pill for both card layouts (list + grid). The **group badge is driven by `f.user_role`** (`student`/`staff`) alone, so it renders even with no display name — items from scans saved before `account_name` was persisted (DB migration 11) have only `user_role` + `account_id`. The user label resolves best-effort: `f.account_name` → `S._allUsers` match (by `id` or `email`) → email-style `account_id` → omit. An `account_name` equal to `account_id` (a GUID stored by older scheduled scans of editor-saved profiles) counts as missing, and a role other than `student`/`staff` is filled from the same `S._allUsers` match. Do not re-nest the role badge inside an `account_name` check (the old bug) — that hides the group badge for legacy items. Both layouts call `_accountPill(f)`; keep them sharing the one helper.
## CPR cross-referencing — results.js
@@ -85,7 +85,7 @@ 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.
- **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. The editor saves each account as `{id, displayName, userRole}` (plus `email`/`manual` for manual accounts), the same shape as 💾 — not a bare id string: scheduled scans pass profile accounts straight to `run_scan`, and bare ids produced GUID-named cards with no role badge. Server side, `scan_engine._enrich_bare_users()` names any bare ids that remain (older profiles) using `conn.build_role_sku_map()`, the same role classification as `GET /api/users`.
- **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.
+18 -7
View File
@@ -437,8 +437,9 @@ 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 || '')}"` : '';
// data-name lets the save keep the display name with the id (see _pmgmtSaveFullEdit)
const manualAttrs = ` data-name="${_esc(u.displayName || '')}"` + (u.manual
? ` data-manual="1" 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>';
@@ -550,8 +551,9 @@ function _unresolvedAccountRows(savedList) {
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');
const manualAttrs = _isManualEntry(u) && typeof u === 'object'
? ` data-manual="1" data-name="${_esc(u.displayName || '')}" data-email="${_esc(u.email || '')}"` : '';
const savedName = typeof u === 'object' && u.displayName && u.displayName !== id ? u.displayName : '';
const manualAttrs = (savedName ? ` data-name="${_esc(savedName)}"` : '') + (isManual && typeof u === 'object'
? ` data-manual="1" 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('');
}
@@ -737,9 +739,18 @@ async function _pmgmtSaveFullEdit() {
const allSources = m365Sources.concat(effectiveGoogleSources).concat(effectiveFileSources);
let user_ids = Array.from(document.querySelectorAll('#pmgmtAcctList input[type=checkbox]:checked'))
.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);
.map(cb => {
// Save name + role with the id (same shape as 💾 / getSelectedUsers()):
// scheduled scans pass these straight to the scan, and a bare id became
// the card's "name" with no Elev/Ansat badge.
const role = cb.closest('label')?.dataset.role || 'other';
if (cb.dataset.manual) {
return {id: cb.dataset.uid, displayName: cb.dataset.name || '', email: cb.dataset.email || '',
userRole: role, manual: true};
}
return cb.dataset.name ? {id: cb.dataset.uid, displayName: cb.dataset.name, userRole: role}
: 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 = {
+17 -7
View File
@@ -32,21 +32,31 @@ const SOURCE_BADGES = {
// email), falling back to an email-style account_id. Returns '' when there is
// neither a label nor a role to show.
function _accountPill(f) {
const roleBadge =
f.user_role === 'student' ? '<span class="role-badge">' + t('role_student', 'Elev') + '</span>' :
f.user_role === 'staff' ? '<span class="role-badge">' + t('role_staff', 'Ansat') + '</span>' : '';
let role = f.user_role;
let label = f.account_name || '';
if (!label && f.account_id) {
// Scheduled scans of editor-saved profiles stored the account id (a GUID) as
// the name, with role "other" — treat that like a missing name.
if (label && f.account_id && label === String(f.account_id)) label = '';
const needName = !label && f.account_id;
const needRole = role !== 'student' && role !== 'staff';
if (f.account_id && (needName || needRole)) {
const aid = String(f.account_id);
const u = (S._allUsers || []).find(function(u) {
return u.id === f.account_id ||
(u.email && u.email.toLowerCase() === aid.toLowerCase());
});
if (u) label = u.displayName || '';
else if (aid.includes('@')) label = aid; // an email is already human-readable
if (u) {
if (needName) label = u.displayName || '';
if (needRole) role = u.userRole;
} else if (needName && aid.includes('@')) {
label = aid; // an email is already human-readable
}
}
const roleBadge =
role === 'student' ? '<span class="role-badge">' + t('role_student', 'Elev') + '</span>' :
role === 'staff' ? '<span class="role-badge">' + t('role_staff', 'Ansat') + '</span>' : '';
if (!label && !roleBadge) return '';
const title = label || f.user_role || '';
const title = label || role || '';
return '<span class="account-pill" title="' + esc(title) + '">' + roleBadge + (label ? esc(label) : '') + '</span>';
}