feat: scan history browser, user-scoped viewer tokens, export fixes, email fixes (v1.6.20)
- Scan history browser (history.js, GET /api/db/sessions, get_sessions(), get_session_items(ref_scan_id)) — review any past session without rescanning - User-scoped viewer tokens (#34) — scope by individual employee across M365 and GWS; autocomplete from Accounts list; dual-email support - Fix: GWS scan never marked finished (end_scan → finish_scan) and emitted wrong SSE event (scan_done → google_scan_done), excluding GWS items from all exports - Fix: file scan begin_scan called with wrong keyword args (TypeError swallowed), so local/SMB items were never written to DB - Fix: Graph sendMail reported failure on success — _post() now returns {} on empty 202 response instead of raising JSONDecodeError - Fix: Graph error hidden behind generic "No SMTP host" message when both Graph and SMTP were unavailable - Fix: Gmail vs Google Workspace SMTP error messages distinguished by username domain; Workspace errors point to admin console, not personal security settings - Docs: update README, MANUAL-EN, MANUAL-DA, CLAUDE.md, TODO.md, CHANGELOG.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
e64d7eb958
commit
c9aab19a97
24 files changed
+753
-70
No files matched your search
+146
-10
@@ -1,5 +1,6 @@
|
||||
// ── Viewer token management (#33) ─────────────────────────────────────────────
|
||||
// Share button → modal to create, copy, and revoke read-only viewer links.
|
||||
import { S } from './state.js';
|
||||
|
||||
async function _getShareBaseUrl() {
|
||||
// Use the machine's LAN IP so links work for remote users, not just localhost.
|
||||
@@ -15,13 +16,126 @@ async function _getShareBaseUrl() {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
// ── User autocomplete for Share modal ────────────────────────────────────────
|
||||
|
||||
// Holds the resolved user when one is picked from the dropdown.
|
||||
// Cleared on modal reset or when the input is edited manually.
|
||||
let _selectedScopeUser = null; // { emails: string[], display_name: string }
|
||||
let _userAcInit = false;
|
||||
|
||||
function _initUserAutocomplete() {
|
||||
if (_userAcInit) return;
|
||||
_userAcInit = true;
|
||||
const input = document.getElementById('shareScopeUser');
|
||||
const drop = document.getElementById('shareScopeUserDropdown');
|
||||
if (!input || !drop) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
_selectedScopeUser = null; // user edited manually — discard dropdown selection
|
||||
_renderUserDropdown(input.value);
|
||||
});
|
||||
input.addEventListener('focus', () => _renderUserDropdown(input.value));
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { drop.style.display = 'none'; }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); drop.querySelector('[data-uid]')?.focus(); }
|
||||
});
|
||||
drop.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { drop.style.display = 'none'; input.focus(); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); document.activeElement?.nextElementSibling?.focus(); }
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = document.activeElement?.previousElementSibling;
|
||||
prev ? prev.focus() : input.focus();
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
const el = document.activeElement;
|
||||
if (el?.dataset?.uid) _selectUser(parseInt(el.dataset.uid, 10));
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
if (!document.getElementById('shareScopeUserWrap')?.contains(e.target))
|
||||
drop.style.display = 'none';
|
||||
}, true);
|
||||
}
|
||||
|
||||
function _renderUserDropdown(query) {
|
||||
const drop = document.getElementById('shareScopeUserDropdown');
|
||||
if (!drop) return;
|
||||
const users = S._allUsers;
|
||||
if (!users.length) { drop.style.display = 'none'; return; }
|
||||
const q = (query || '').trim().toLowerCase();
|
||||
const matches = (q
|
||||
? users.filter(u =>
|
||||
(u.displayName || '').toLowerCase().includes(q) ||
|
||||
(u.email || '').toLowerCase().includes(q) ||
|
||||
(u.googleEmail || '').toLowerCase().includes(q))
|
||||
: users
|
||||
).slice(0, 8);
|
||||
if (!matches.length) { drop.style.display = 'none'; return; }
|
||||
drop.innerHTML = '';
|
||||
matches.forEach((u, i) => {
|
||||
const emails = [u.email, u.googleEmail].filter(Boolean);
|
||||
const emailLbl = emails.join(', ');
|
||||
const roleLbl = u.userRole === 'staff' ? t('share_scope_staff', 'Staff')
|
||||
: u.userRole === 'student' ? t('share_scope_student', 'Students')
|
||||
: '';
|
||||
const row = document.createElement('div');
|
||||
row.tabIndex = 0;
|
||||
row.dataset.uid = i; // index into matches; resolved in _selectUser
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;cursor:pointer;font-size:12px'
|
||||
+ (i < matches.length - 1 ? ';border-bottom:1px solid var(--border)' : '');
|
||||
row.innerHTML =
|
||||
'<div style="flex:1;min-width:0">' +
|
||||
'<div style="font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
|
||||
(u.displayName || emails[0] || '') +
|
||||
(roleLbl ? ' <span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--accent);color:#fff;font-weight:600">' + roleLbl + '</span>' : '') +
|
||||
'</div>' +
|
||||
'<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + emailLbl + '</div>' +
|
||||
'</div>';
|
||||
row.addEventListener('mouseenter', () => row.style.background = 'var(--surface)');
|
||||
row.addEventListener('mouseleave', () => row.style.background = '');
|
||||
row.addEventListener('focus', () => row.style.background = 'var(--surface)');
|
||||
row.addEventListener('blur', () => row.style.background = '');
|
||||
row.addEventListener('mousedown', e => {
|
||||
e.preventDefault();
|
||||
_selectUser(u);
|
||||
});
|
||||
drop.appendChild(row);
|
||||
});
|
||||
drop.style.display = '';
|
||||
}
|
||||
|
||||
function _selectUser(u) {
|
||||
const input = document.getElementById('shareScopeUser');
|
||||
const drop = document.getElementById('shareScopeUserDropdown');
|
||||
const emails = [u.email, u.googleEmail].filter(Boolean);
|
||||
_selectedScopeUser = {
|
||||
emails: emails,
|
||||
display_name: u.displayName || emails[0] || '',
|
||||
};
|
||||
if (input) input.value = u.displayName || emails[0] || '';
|
||||
if (drop) drop.style.display = 'none';
|
||||
}
|
||||
|
||||
function _shareScopeTypeChanged() {
|
||||
const type = document.getElementById('shareScopeType')?.value || '';
|
||||
document.getElementById('shareScopeRoleWrap').style.display = type === 'role' ? '' : 'none';
|
||||
document.getElementById('shareScopeUserWrap').style.display = type === 'user' ? '' : 'none';
|
||||
if (type === 'user') _initUserAutocomplete();
|
||||
}
|
||||
|
||||
function openShareModal() {
|
||||
document.getElementById('shareBackdrop').classList.add('open');
|
||||
document.getElementById('shareNewLinkRow').style.display = 'none';
|
||||
document.getElementById('shareLabel').value = '';
|
||||
document.getElementById('shareExpiry').value = '30';
|
||||
const scopeSel = document.getElementById('shareScope');
|
||||
if (scopeSel) scopeSel.value = '';
|
||||
const scopeType = document.getElementById('shareScopeType');
|
||||
if (scopeType) { scopeType.value = ''; _shareScopeTypeChanged(); }
|
||||
_selectedScopeUser = null;
|
||||
const scopeUser = document.getElementById('shareScopeUser');
|
||||
if (scopeUser) scopeUser.value = '';
|
||||
const scopeDrop = document.getElementById('shareScopeUserDropdown');
|
||||
if (scopeDrop) scopeDrop.style.display = 'none';
|
||||
_renderTokenList();
|
||||
fetch('/api/viewer/pin').then(function(r){ return r.json(); }).then(function(d) {
|
||||
const el = document.getElementById('sharePinStatus');
|
||||
@@ -54,17 +168,23 @@ async function _renderTokenList() {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:12px';
|
||||
const roleVal = tok.scope?.role || '';
|
||||
const roleLbl = roleVal === 'student' ? t('share_scope_student', 'Elever')
|
||||
: roleVal === 'staff' ? t('share_scope_staff', 'Ansatte')
|
||||
const roleLbl = roleVal === 'student' ? t('share_scope_student', 'Students')
|
||||
: roleVal === 'staff' ? t('share_scope_staff', 'Staff')
|
||||
: '';
|
||||
const roleBadge = roleLbl
|
||||
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--accent);color:#fff;margin-left:5px;font-weight:600;vertical-align:middle">' + roleLbl + '</span>'
|
||||
: '';
|
||||
const userScope = tok.scope?.user;
|
||||
const userLbl = tok.scope?.display_name
|
||||
|| (Array.isArray(userScope) ? userScope.join(', ') : (userScope || ''));
|
||||
const userBadge = userLbl
|
||||
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--muted);color:#fff;margin-left:5px;font-weight:600;vertical-align:middle;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block">' + userLbl + '</span>'
|
||||
: '';
|
||||
row.innerHTML =
|
||||
'<div style="flex:1;min-width:0">' +
|
||||
'<div style="font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
|
||||
(tok.label || '<span style="color:var(--muted);font-style:italic">' + t('share_unlabelled', 'Unlabelled') + '</span>') +
|
||||
roleBadge +
|
||||
roleBadge + userBadge +
|
||||
'</div>' +
|
||||
'<div style="font-size:10px;color:var(--muted);margin-top:1px">' +
|
||||
t('share_expires_prefix', 'Expires:') + ' ' + expires + ' · ' + t('share_last_used', 'Last used:') + ' ' + lastUsed +
|
||||
@@ -82,12 +202,27 @@ async function _renderTokenList() {
|
||||
}
|
||||
|
||||
async function createShareLink() {
|
||||
const label = document.getElementById('shareLabel').value.trim();
|
||||
const expiry = document.getElementById('shareExpiry').value;
|
||||
const role = document.getElementById('shareScope')?.value || '';
|
||||
const body = {label};
|
||||
const label = document.getElementById('shareLabel').value.trim();
|
||||
const expiry = document.getElementById('shareExpiry').value;
|
||||
const scopeType = document.getElementById('shareScopeType')?.value || '';
|
||||
const body = {label};
|
||||
if (expiry) body.expires_days = parseInt(expiry);
|
||||
if (role) body.scope = {role};
|
||||
if (scopeType === 'role') {
|
||||
const role = document.getElementById('shareScope')?.value || '';
|
||||
if (role) body.scope = {role};
|
||||
} else if (scopeType === 'user') {
|
||||
if (_selectedScopeUser) {
|
||||
body.scope = { user: _selectedScopeUser.emails, display_name: _selectedScopeUser.display_name };
|
||||
} else {
|
||||
// Manual entry fallback — treat raw input as a single email
|
||||
const email = (document.getElementById('shareScopeUser')?.value || '').trim().toLowerCase();
|
||||
if (!email || !email.includes('@')) {
|
||||
alert(t('share_scope_user_invalid', 'Please enter a valid email address for the user scope.'));
|
||||
return;
|
||||
}
|
||||
body.scope = { user: [email], display_name: email };
|
||||
}
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/api/viewer/tokens', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
@@ -240,6 +375,7 @@ async function stClearViewerPin() {
|
||||
}
|
||||
|
||||
// ── Window exports ────────────────────────────────────────────────────────────
|
||||
window._shareScopeTypeChanged = _shareScopeTypeChanged;
|
||||
window.openShareModal = openShareModal;
|
||||
window.closeShareModal = closeShareModal;
|
||||
window.createShareLink = createShareLink;
|
||||
|
||||
Reference in new issue
Block a user