1697 lines
65 KiB
JavaScript
1697 lines
65 KiB
JavaScript
// -------------------------------------------------------------------------
|
|
// Auth gate: runs before the main app IIFE bootstraps. If the user is not
|
|
// authenticated, we redirect to /login.html (or /setup.html when the backend
|
|
// indicates the initial setup is still required) and abort further init.
|
|
// -------------------------------------------------------------------------
|
|
(async function authGate() {
|
|
try {
|
|
const r = await fetch('/api/auth/me', { credentials: 'same-origin' });
|
|
if (r.status === 401) {
|
|
const setup = await fetch('/api/auth/setup-required').then(function (x) { return x.json(); }).catch(function () { return { setup_required: false }; });
|
|
window.location.replace(setup.setup_required ? '/setup.html' : '/login.html');
|
|
return;
|
|
}
|
|
if (!r.ok) return;
|
|
const me = await r.json();
|
|
window.__clearviewUser = me;
|
|
renderUserBadge(me);
|
|
if (me.role !== 'admin') {
|
|
const usersLink = document.querySelector('[data-route="users"]');
|
|
if (usersLink) usersLink.style.display = 'none';
|
|
}
|
|
} catch (e) {
|
|
window.location.replace('/login.html');
|
|
}
|
|
})();
|
|
|
|
function renderUserBadge(me) {
|
|
const slot = document.getElementById('userBadge');
|
|
if (!slot) return;
|
|
slot.innerHTML = '';
|
|
const wrap = document.createElement('span');
|
|
wrap.className = 'user-badge';
|
|
wrap.append(document.createTextNode(me.username + ' (' + me.role + ')'));
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.textContent = 'Sign out';
|
|
btn.addEventListener('click', async function () {
|
|
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
|
window.location.replace('/login.html');
|
|
});
|
|
wrap.append(btn);
|
|
slot.append(wrap);
|
|
}
|
|
|
|
(function () {
|
|
const state = {
|
|
selectedJobId: null,
|
|
selectedJobData: null,
|
|
refreshTimer: null,
|
|
tenants: [],
|
|
sharingLinkSelectionByJob: {},
|
|
currentRoute: 'dashboard',
|
|
};
|
|
|
|
const ROUTE_TITLES = {
|
|
'dashboard': 'Dashboard',
|
|
'jobs': 'Scan Jobs',
|
|
'scan-sharepoint': 'New SharePoint Scan',
|
|
'scan-mailbox': 'New Mailbox Scan',
|
|
'scan-entra': 'New Entra Group Scan',
|
|
'tenants': 'Tenants',
|
|
'settings': 'Settings',
|
|
};
|
|
|
|
const els = {
|
|
// Cert block
|
|
certBlock: document.getElementById('certBlock'),
|
|
certPem: document.getElementById('certPem'),
|
|
downloadCertBtn: document.getElementById('downloadCertBtn'),
|
|
copyCertBtn: document.getElementById('copyCertBtn'),
|
|
closeCertBtn: document.getElementById('closeCertBtn'),
|
|
// Tenant panel
|
|
addTenantBtn: document.getElementById('addTenantBtn'),
|
|
addTenantForm: document.getElementById('addTenantForm'),
|
|
tenantSetupAutomated: document.getElementById('tenantSetupAutomated'),
|
|
tenantSetupManual: document.getElementById('tenantSetupManual'),
|
|
onboardingForm: document.getElementById('onboardingForm'),
|
|
connectMicrosoftBtn: document.getElementById('connectMicrosoftBtn'),
|
|
connectedTenantId: document.getElementById('connectedTenantId'),
|
|
scanAppDisplayName: document.getElementById('scanAppDisplayName'),
|
|
newTenantName: document.getElementById('newTenantName'),
|
|
newTenantTenantId: document.getElementById('newTenantTenantId'),
|
|
newTenantPrimaryDomain: document.getElementById('newTenantPrimaryDomain'),
|
|
newTenantClientId: document.getElementById('newTenantClientId'),
|
|
newTenantClientSecret: document.getElementById('newTenantClientSecret'),
|
|
saveTenantBtn: document.getElementById('saveTenantBtn'),
|
|
cancelTenantBtn: document.getElementById('cancelTenantBtn'),
|
|
tenantsTableBody: document.getElementById('tenantsTableBody'),
|
|
tenantFeedback: document.getElementById('tenantFeedback'),
|
|
// Scan panel
|
|
scanTenantSelect: document.getElementById('scanTenantSelect'),
|
|
manualCredentialsBlock: document.getElementById('manualCredentialsBlock'),
|
|
tenantId: document.getElementById('tenantId'),
|
|
clientId: document.getElementById('clientId'),
|
|
clientSecret: document.getElementById('clientSecret'),
|
|
manualScanForm: document.getElementById('manualScanForm'),
|
|
csvScanForm: document.getElementById('csvScanForm'),
|
|
manualUrls: document.getElementById('manualUrls'),
|
|
manualSkipDefaults: document.getElementById('manualSkipDefaults'),
|
|
csvFile: document.getElementById('csvFile'),
|
|
csvSkipDefaults: document.getElementById('csvSkipDefaults'),
|
|
submitFeedback: document.getElementById('submitFeedback'),
|
|
sharepointScanMode: document.getElementById('sharepointScanMode'),
|
|
// Mailbox scan panel
|
|
entraScanTenantSelect: document.getElementById('entraScanTenantSelect'),
|
|
manualEntraForm: document.getElementById('manualEntraForm'),
|
|
csvEntraForm: document.getElementById('csvEntraForm'),
|
|
allEntraForm: document.getElementById('allEntraForm'),
|
|
manualEntraIds: document.getElementById('manualEntraIds'),
|
|
csvEntraFile: document.getElementById('csvEntraFile'),
|
|
entraSubmitFeedback: document.getElementById('entraSubmitFeedback'),
|
|
mailboxScanTenantSelect: document.getElementById('mailboxScanTenantSelect'),
|
|
manualMailboxForm: document.getElementById('manualMailboxForm'),
|
|
csvMailboxForm: document.getElementById('csvMailboxForm'),
|
|
allMailboxesForm: document.getElementById('allMailboxesForm'),
|
|
allMailboxesOrg: document.getElementById('allMailboxesOrg'),
|
|
manualMailboxes: document.getElementById('manualMailboxes'),
|
|
csvMailboxFile: document.getElementById('csvMailboxFile'),
|
|
mailboxSubmitFeedback: document.getElementById('mailboxSubmitFeedback'),
|
|
// Jobs panel
|
|
refreshJobsBtn: document.getElementById('refreshJobsBtn'),
|
|
jobTenantFilter: document.getElementById('jobTenantFilter'),
|
|
jobTypeFilter: document.getElementById('jobTypeFilter'),
|
|
jobsTableBody: document.getElementById('jobsTableBody'),
|
|
jobAutoRefresh: document.getElementById('jobAutoRefresh'),
|
|
// Sidebar / routing
|
|
contentTitle: document.getElementById('contentTitle'),
|
|
appVersion: document.getElementById('appVersion'),
|
|
targetsTableHead: document.getElementById('targetsTableHead'),
|
|
targetsHeading: document.getElementById('targetsHeading'),
|
|
deviationsTableHead: document.getElementById('deviationsTableHead'),
|
|
// Job detail panel
|
|
targetsTableBody: document.getElementById('targetsTableBody'),
|
|
deviationsTableBody: document.getElementById('deviationsTableBody'),
|
|
selectedJobId: document.getElementById('selectedJobId'),
|
|
jobSummary: document.getElementById('jobSummary'),
|
|
jobActivity: document.getElementById('jobActivity'),
|
|
jobSiteFilter: document.getElementById('jobSiteFilter'),
|
|
exportJobBtn: document.getElementById('exportJobBtn'),
|
|
sharingLinksResolveBlock: document.getElementById('sharingLinksResolveBlock'),
|
|
sharingLinksTypes: document.getElementById('sharingLinksTypes'),
|
|
resolveSharingLinksBtn: document.getElementById('resolveSharingLinksBtn'),
|
|
resolveFeedback: document.getElementById('resolveFeedback'),
|
|
resolveGroupsBlock: document.getElementById('resolveGroupsBlock'),
|
|
resolveGroupsBtn: document.getElementById('resolveGroupsBtn'),
|
|
resolveGroupsFeedback: document.getElementById('resolveGroupsFeedback'),
|
|
// Hero stats
|
|
statTenants: document.getElementById('statTenants'),
|
|
statJobs: document.getElementById('statJobs'),
|
|
statRunning: document.getElementById('statRunning'),
|
|
statErrors: document.getElementById('statErrors'),
|
|
dashRecentJobs: document.getElementById('dashRecentJobs'),
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Generic helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
async function requestJson(url, options) {
|
|
const response = await fetch(url, Object.assign({ credentials: 'same-origin' }, options || {}));
|
|
if (response.status === 401) {
|
|
window.location.replace('/login.html');
|
|
throw new Error('unauthenticated');
|
|
}
|
|
if (!response.ok) {
|
|
let detail = response.statusText;
|
|
try {
|
|
const data = await response.json();
|
|
detail = data.detail || JSON.stringify(data);
|
|
} catch (err) {
|
|
void err;
|
|
}
|
|
throw new Error(detail || 'Request failed');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function showFeedback(el, message, type) {
|
|
el.textContent = message;
|
|
el.className = 'feedback ' + (type || '');
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '-';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function renderProbeStatus(target) {
|
|
if (!target.last_probe_at) {
|
|
return '<span class="risk info">Not tested yet</span>';
|
|
}
|
|
const when = formatDate(target.last_probe_at);
|
|
const msg = target.last_probe_message || '';
|
|
if (target.last_probe_ok) {
|
|
return '<span class="risk ok" title="' + escHtml(msg) + '">OK</span> <span class="cell-members">' + escHtml(when) + '</span>';
|
|
}
|
|
return '<span class="risk critical" title="' + escHtml(msg) + '">Failed</span> <span class="cell-members">' + escHtml(when) + '</span><br><span class="cell-members">' + escHtml(msg) + '</span>';
|
|
}
|
|
|
|
async function testTargetConnection(targetId, button) {
|
|
if (!state.selectedJobId) return;
|
|
const originalLabel = button.textContent;
|
|
button.disabled = true;
|
|
button.textContent = 'Testing…';
|
|
try {
|
|
const resp = await fetch(
|
|
'/api/scan-jobs/' + encodeURIComponent(state.selectedJobId) +
|
|
'/targets/' + encodeURIComponent(targetId) + '/test-connection',
|
|
{ method: 'POST' }
|
|
);
|
|
if (!resp.ok) {
|
|
const body = await resp.text();
|
|
throw new Error('HTTP ' + resp.status + ': ' + body);
|
|
}
|
|
await resp.json();
|
|
await refreshSelectedJob();
|
|
} catch (err) {
|
|
window.alert('Test failed: ' + err.message);
|
|
} finally {
|
|
button.disabled = false;
|
|
button.textContent = originalLabel;
|
|
}
|
|
}
|
|
|
|
function statusBadge(status) {
|
|
const cls = status === 'completed' ? 'ok'
|
|
: status === 'running' ? 'warn'
|
|
: status === 'queued' ? 'info'
|
|
: status === 'completed_with_errors' ? 'high'
|
|
: status === 'failed' ? 'critical'
|
|
: status === 'cancelled' ? 'critical'
|
|
: 'info';
|
|
return '<span class="risk ' + cls + '">' + status + '</span>';
|
|
}
|
|
|
|
function abbrev(str, max) {
|
|
if (!str) return '-';
|
|
return str.length > max ? str.slice(0, max) + '\u2026' : str;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Tenant management
|
|
// -------------------------------------------------------------------------
|
|
|
|
async function loadTenants() {
|
|
const tenants = await requestJson('/api/tenants');
|
|
state.tenants = tenants;
|
|
renderTenantsTable();
|
|
populateTenantDropdowns();
|
|
}
|
|
|
|
function renderTenantsTable() {
|
|
els.statTenants.textContent = String(state.tenants.length);
|
|
|
|
if (!state.tenants.length) {
|
|
els.tenantsTableBody.innerHTML = '<tr><td colspan="5">No tenants configured yet.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
els.tenantsTableBody.innerHTML = state.tenants.map(function (t) {
|
|
var authCell;
|
|
if (t.has_certificate) {
|
|
var expiry = t.cert_expires_at ? ' (exp. ' + formatDate(t.cert_expires_at).split(',')[0] + ')' : '';
|
|
authCell = '<span class="tenant-tag">cert' + escHtml(expiry) + '</span>';
|
|
} else if (t.client_secret !== undefined) {
|
|
authCell = '<span style="color:var(--cv-text-secondary);font-size:0.82rem">secret</span>';
|
|
} else {
|
|
authCell = '<span class="risk critical">none</span>';
|
|
}
|
|
return (
|
|
'<tr>' +
|
|
'<td><strong>' + escHtml(t.name) + '</strong></td>' +
|
|
'<td><code>' + abbrev(t.tenant_id, 36) + '</code></td>' +
|
|
'<td><code>' + abbrev(t.client_id, 36) + '</code></td>' +
|
|
'<td>' + authCell + '</td>' +
|
|
'<td>' + formatDate(t.created_at) + '</td>' +
|
|
'<td>' +
|
|
'<div style="display:flex;gap:0.4rem;flex-wrap:wrap">' +
|
|
'<button class="btn btn-outline btn-small" data-tenant-cert="' + escHtml(t.id) + '" title="Generate or replace certificate">Certificate</button>' +
|
|
'<button class="btn btn-outline btn-small" data-tenant-scan="' + escHtml(t.id) + '" title="Start a scan for this tenant">Scan</button>' +
|
|
'<button class="btn btn-outline btn-small" data-tenant-delete="' + escHtml(t.id) + '" title="Remove this tenant profile">Delete</button>' +
|
|
'</div>' +
|
|
'</td>' +
|
|
'</tr>'
|
|
);
|
|
}).join('');
|
|
|
|
els.tenantsTableBody.querySelectorAll('[data-tenant-cert]').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
const id = btn.getAttribute('data-tenant-cert');
|
|
const tenant = state.tenants.find(function (t) { return t.id === id; });
|
|
const name = tenant ? tenant.name : id;
|
|
const msg = tenant && tenant.has_certificate
|
|
? 'Replace the existing certificate for "' + name + '"? The old certificate will stop working in Azure.'
|
|
: 'Generate a certificate for "' + name + '"?';
|
|
if (!window.confirm(msg)) return;
|
|
generateCertificate(id);
|
|
});
|
|
});
|
|
|
|
els.tenantsTableBody.querySelectorAll('[data-tenant-scan]').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
const id = btn.getAttribute('data-tenant-scan');
|
|
els.scanTenantSelect.value = id;
|
|
onScanTenantChange();
|
|
navigateTo('scan-sharepoint');
|
|
});
|
|
});
|
|
|
|
els.tenantsTableBody.querySelectorAll('[data-tenant-delete]').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
const id = btn.getAttribute('data-tenant-delete');
|
|
const tenant = state.tenants.find(function (t) { return t.id === id; });
|
|
const name = tenant ? tenant.name : id;
|
|
if (!window.confirm('Delete tenant "' + name + '"? Associated jobs will remain but lose their tenant link.')) {
|
|
return;
|
|
}
|
|
deleteTenant(id);
|
|
});
|
|
});
|
|
}
|
|
|
|
function populateTenantDropdowns() {
|
|
// SharePoint scan tenant select (supports manual creds)
|
|
const scanVal = els.scanTenantSelect.value;
|
|
els.scanTenantSelect.innerHTML =
|
|
'<option value="">-- Select a tenant --</option>' +
|
|
state.tenants.map(function (t) {
|
|
return '<option value="' + escHtml(t.id) + '">' + escHtml(t.name) + '</option>';
|
|
}).join('') +
|
|
'<option value="__manual__">Manual credentials...</option>';
|
|
if (scanVal) {
|
|
els.scanTenantSelect.value = scanVal;
|
|
}
|
|
|
|
// Entra scan tenant select (cert required for Graph)
|
|
if (els.entraScanTenantSelect) {
|
|
const ev = els.entraScanTenantSelect.value;
|
|
els.entraScanTenantSelect.innerHTML =
|
|
'<option value="">-- Select a tenant --</option>' +
|
|
state.tenants.map(function (t) {
|
|
var label = escHtml(t.name);
|
|
if (!t.has_certificate) label += ' (no certificate)';
|
|
return '<option value="' + escHtml(t.id) + '"' + (t.has_certificate ? '' : ' disabled') + '>' + label + '</option>';
|
|
}).join('');
|
|
if (ev) els.entraScanTenantSelect.value = ev;
|
|
}
|
|
|
|
// Mailbox scan tenant select (cert only, no manual creds)
|
|
if (els.mailboxScanTenantSelect) {
|
|
const mbVal = els.mailboxScanTenantSelect.value;
|
|
els.mailboxScanTenantSelect.innerHTML =
|
|
'<option value="">-- Select a tenant --</option>' +
|
|
state.tenants.map(function (t) {
|
|
var label = escHtml(t.name);
|
|
if (!t.has_certificate) {
|
|
label += ' (no certificate)';
|
|
}
|
|
return '<option value="' + escHtml(t.id) + '"' + (t.has_certificate ? '' : ' disabled') + '>' + label + '</option>';
|
|
}).join('');
|
|
if (mbVal) {
|
|
els.mailboxScanTenantSelect.value = mbVal;
|
|
}
|
|
}
|
|
|
|
// Job tenant filter select
|
|
const filterVal = els.jobTenantFilter.value;
|
|
els.jobTenantFilter.innerHTML =
|
|
'<option value="">All tenants</option>' +
|
|
state.tenants.map(function (t) {
|
|
return '<option value="' + escHtml(t.id) + '">' + escHtml(t.name) + '</option>';
|
|
}).join('');
|
|
if (filterVal) {
|
|
els.jobTenantFilter.value = filterVal;
|
|
}
|
|
}
|
|
|
|
async function generateCertificate(tenantId) {
|
|
const tenant = state.tenants.find(function (t) { return t.id === tenantId; });
|
|
const tenantName = tenant ? tenant.name.replace(/[^a-z0-9_-]/gi, '_') : 'clearview';
|
|
showFeedback(els.tenantFeedback, 'Generating certificate...', '');
|
|
try {
|
|
const result = await requestJson('/api/tenants/' + encodeURIComponent(tenantId) + '/generate-certificate', {
|
|
method: 'POST',
|
|
});
|
|
await loadTenants();
|
|
showFeedback(els.tenantFeedback, 'Certificate generated (thumbprint: ' + result.thumbprint + '). Download and upload to Azure Portal.', 'ok');
|
|
els.certPem.value = result.public_cert_pem;
|
|
els.downloadCertBtn.setAttribute('data-filename', tenantName + '_clearview.cer');
|
|
els.certBlock.removeAttribute('hidden');
|
|
els.certBlock.scrollIntoView({ behavior: 'smooth' });
|
|
} catch (err) {
|
|
showFeedback(els.tenantFeedback, 'Certificate generation failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function saveTenant() {
|
|
const name = (els.newTenantName.value || '').trim();
|
|
const tenantId = (els.newTenantTenantId.value || '').trim();
|
|
const primaryDomain = (els.newTenantPrimaryDomain ? els.newTenantPrimaryDomain.value : '').trim().toLowerCase();
|
|
const clientId = (els.newTenantClientId.value || '').trim();
|
|
const clientSecret = (els.newTenantClientSecret.value || '').trim();
|
|
|
|
if (!name || !tenantId || !clientId) {
|
|
showFeedback(els.tenantFeedback, 'Name, Tenant ID, and Client ID are required.', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await requestJson('/api/tenants', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: name,
|
|
tenant_id: tenantId,
|
|
primary_domain: primaryDomain || null,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
}),
|
|
});
|
|
showFeedback(els.tenantFeedback, 'Tenant "' + name + '" saved.', 'ok');
|
|
closeTenantForm();
|
|
await loadTenants();
|
|
} catch (err) {
|
|
showFeedback(els.tenantFeedback, 'Save failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteTenant(id) {
|
|
try {
|
|
await fetch('/api/tenants/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
await loadTenants();
|
|
showFeedback(els.tenantFeedback, 'Tenant removed.', 'ok');
|
|
} catch (err) {
|
|
showFeedback(els.tenantFeedback, 'Delete failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function openTenantForm() {
|
|
els.addTenantForm.removeAttribute('hidden');
|
|
els.addTenantBtn.setAttribute('hidden', '');
|
|
els.newTenantName.focus();
|
|
}
|
|
|
|
function closeTenantForm() {
|
|
els.addTenantForm.setAttribute('hidden', '');
|
|
els.addTenantBtn.removeAttribute('hidden');
|
|
els.newTenantName.value = '';
|
|
els.newTenantTenantId.value = '';
|
|
if (els.newTenantPrimaryDomain) els.newTenantPrimaryDomain.value = '';
|
|
els.newTenantClientId.value = '';
|
|
els.newTenantClientSecret.value = '';
|
|
if (els.connectedTenantId) els.connectedTenantId.value = '';
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Scan credentials helper
|
|
// -------------------------------------------------------------------------
|
|
|
|
function onScanTenantChange() {
|
|
const val = els.scanTenantSelect.value;
|
|
if (val === '__manual__') {
|
|
els.manualCredentialsBlock.removeAttribute('hidden');
|
|
} else {
|
|
els.manualCredentialsBlock.setAttribute('hidden', '');
|
|
}
|
|
}
|
|
|
|
function readScanAuth() {
|
|
const val = els.scanTenantSelect.value;
|
|
if (!val) {
|
|
throw new Error('Select a tenant profile first.');
|
|
}
|
|
if (val === '__manual__') {
|
|
const tenantId = (els.tenantId.value || '').trim();
|
|
const clientId = (els.clientId.value || '').trim();
|
|
const clientSecret = (els.clientSecret.value || '').trim();
|
|
if (!tenantId || !clientId || !clientSecret) {
|
|
throw new Error('Tenant ID, Client ID, and Client Secret are required.');
|
|
}
|
|
return { tenant_id: tenantId, client_id: clientId, client_secret: clientSecret };
|
|
}
|
|
return { tenant_profile_id: val };
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Onboarding
|
|
// -------------------------------------------------------------------------
|
|
|
|
async function createScanAppAutomatically(event) {
|
|
event.preventDefault();
|
|
const connectedTenantId = (els.connectedTenantId.value || '').trim();
|
|
const displayName = (els.scanAppDisplayName.value || '').trim() || 'Clearview Scan App';
|
|
if (!connectedTenantId) {
|
|
showFeedback(els.tenantFeedback, 'Connect Microsoft first to load tenant id.', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const result = await requestJson('/api/onboarding/create-scan-app', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tenant_id: connectedTenantId, display_name: displayName }),
|
|
});
|
|
els.newTenantTenantId.value = result.tenant_id;
|
|
els.newTenantClientId.value = result.client_id;
|
|
els.newTenantClientSecret.value = result.client_secret;
|
|
if (!els.newTenantName.value) {
|
|
els.newTenantName.value = displayName;
|
|
}
|
|
showFeedback(els.tenantFeedback, 'Scan app created. Fill in a name and click Save Tenant.', 'ok');
|
|
} catch (err) {
|
|
showFeedback(els.tenantFeedback, 'Auto app creation failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function startMicrosoftConnect() {
|
|
try {
|
|
const payload = await requestJson('/api/onboarding/microsoft/connect-url');
|
|
if (!payload.connect_url) {
|
|
throw new Error('Missing connect URL from server');
|
|
}
|
|
window.location.href = payload.connect_url;
|
|
} catch (err) {
|
|
showFeedback(els.tenantFeedback, 'Connect flow failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function consumeOnboardingQueryState() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const status = params.get('onboarding_status');
|
|
if (!status) return;
|
|
|
|
if (status === 'connected') {
|
|
const tenantId = params.get('tenant_id') || '';
|
|
navigateTo('tenants');
|
|
openTenantForm();
|
|
if (tenantId && els.newTenantTenantId) {
|
|
els.newTenantTenantId.value = tenantId;
|
|
}
|
|
if (els.connectedTenantId) {
|
|
els.connectedTenantId.value = tenantId;
|
|
}
|
|
showFeedback(els.tenantFeedback, 'Microsoft connected. Fill in the tenant details and save.', 'ok');
|
|
} else if (status === 'error') {
|
|
const message = params.get('onboarding_message') || 'Unknown onboarding error';
|
|
showFeedback(els.tenantFeedback, 'Microsoft connect failed: ' + message, 'error');
|
|
}
|
|
|
|
window.history.replaceState({}, document.title, window.location.origin + window.location.pathname);
|
|
}
|
|
|
|
async function initOnboardingSection() {
|
|
try {
|
|
const status = await requestJson('/api/onboarding/status');
|
|
if (status.automated_available) {
|
|
els.tenantSetupAutomated.removeAttribute('hidden');
|
|
} else {
|
|
els.tenantSetupManual.removeAttribute('hidden');
|
|
}
|
|
} catch (err) {
|
|
els.tenantSetupManual.removeAttribute('hidden');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Scan job creation
|
|
// -------------------------------------------------------------------------
|
|
|
|
async function createManualJob(event) {
|
|
event.preventDefault();
|
|
const urls = els.manualUrls.value
|
|
.split(/\r?\n/)
|
|
.map(function (line) { return line.trim(); })
|
|
.filter(Boolean);
|
|
if (!urls.length) {
|
|
showFeedback(els.submitFeedback, 'Enter at least one SharePoint site URL.', 'error');
|
|
return;
|
|
}
|
|
let auth;
|
|
try {
|
|
auth = readScanAuth();
|
|
} catch (err) {
|
|
showFeedback(els.submitFeedback, err.message, 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const mode = (els.sharepointScanMode && els.sharepointScanMode.value) || 'sharepoint';
|
|
const payload = Object.assign({ scan_type: mode, site_urls: urls, skip_default_sites: !!els.manualSkipDefaults.checked }, auth);
|
|
const result = await requestJson('/api/scan-jobs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
showFeedback(
|
|
els.submitFeedback,
|
|
'Job queued: ' + result.job.id + ' | accepted=' + result.accepted_urls.length +
|
|
', skipped default=' + result.skipped_default_urls.length +
|
|
', invalid=' + result.invalid_urls.length,
|
|
'ok'
|
|
);
|
|
els.manualUrls.value = '';
|
|
state.selectedJobId = result.job.id;
|
|
await refreshJobs();
|
|
await refreshSelectedJob();
|
|
} catch (err) {
|
|
showFeedback(els.submitFeedback, 'Manual scan failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function createCsvJob(event) {
|
|
event.preventDefault();
|
|
const file = els.csvFile.files && els.csvFile.files[0];
|
|
if (!file) {
|
|
showFeedback(els.submitFeedback, 'Select a CSV file first.', 'error');
|
|
return;
|
|
}
|
|
let auth;
|
|
try {
|
|
auth = readScanAuth();
|
|
} catch (err) {
|
|
showFeedback(els.submitFeedback, err.message, 'error');
|
|
return;
|
|
}
|
|
const skipDefaults = els.csvSkipDefaults.checked ? 'true' : 'false';
|
|
const mode = (els.sharepointScanMode && els.sharepointScanMode.value) || 'sharepoint';
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('scan_type', mode);
|
|
if (auth.tenant_profile_id) {
|
|
formData.append('tenant_profile_id', auth.tenant_profile_id);
|
|
} else {
|
|
formData.append('tenant_id', auth.tenant_id);
|
|
formData.append('client_id', auth.client_id);
|
|
formData.append('client_secret', auth.client_secret);
|
|
}
|
|
try {
|
|
const result = await requestJson('/api/scan-jobs/import-csv?skip_default_sites=' + skipDefaults, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
showFeedback(
|
|
els.submitFeedback,
|
|
'CSV job queued: ' + result.job.id + ' | accepted=' + result.accepted_urls.length +
|
|
', skipped default=' + result.skipped_default_urls.length +
|
|
', invalid=' + result.invalid_urls.length,
|
|
'ok'
|
|
);
|
|
els.csvFile.value = '';
|
|
state.selectedJobId = result.job.id;
|
|
await refreshJobs();
|
|
await refreshSelectedJob();
|
|
} catch (err) {
|
|
showFeedback(els.submitFeedback, 'CSV import failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Jobs list
|
|
// -------------------------------------------------------------------------
|
|
|
|
function renderDashRecent(jobs) {
|
|
if (!els.dashRecentJobs) return;
|
|
if (!jobs.length) {
|
|
els.dashRecentJobs.innerHTML = '<tr><td colspan="6">No jobs yet.</td></tr>';
|
|
return;
|
|
}
|
|
els.dashRecentJobs.innerHTML = jobs.slice(0, 5).map(function (job) {
|
|
var jobIdSafe = escHtml(job.id);
|
|
var tenantLabel = job.tenant_name
|
|
? escHtml(job.tenant_name)
|
|
: '<span style="color:var(--cv-text-secondary)">manual</span>';
|
|
var progress = job.total_targets > 0 ? (job.processed_targets + '/' + job.total_targets) : '0/0';
|
|
return '<tr style="cursor:pointer" data-dash-job="' + jobIdSafe + '">' +
|
|
'<td><code>' + jobIdSafe + '</code></td>' +
|
|
'<td>' + escHtml(job.scan_type || 'sharepoint') + '</td>' +
|
|
'<td>' + tenantLabel + '</td>' +
|
|
'<td>' + statusBadge(job.status) + '</td>' +
|
|
'<td>' + progress + '</td>' +
|
|
'<td>' + formatDate(job.updated_at) + '</td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
els.dashRecentJobs.querySelectorAll('[data-dash-job]').forEach(function (row) {
|
|
row.addEventListener('click', function () {
|
|
state.selectedJobId = row.getAttribute('data-dash-job');
|
|
navigateTo('jobs');
|
|
refreshSelectedJob().catch(function () {});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function refreshJobs() {
|
|
const filterTenant = els.jobTenantFilter.value;
|
|
const filterType = els.jobTypeFilter ? els.jobTypeFilter.value : '';
|
|
let url = '/api/scan-jobs?limit=50';
|
|
if (filterTenant) {
|
|
url += '&tenant_profile_id=' + encodeURIComponent(filterTenant);
|
|
}
|
|
if (filterType) {
|
|
url += '&scan_type=' + encodeURIComponent(filterType);
|
|
}
|
|
const jobs = await requestJson(url);
|
|
|
|
els.statJobs.textContent = String(jobs.length);
|
|
els.statRunning.textContent = String(jobs.filter(function (j) {
|
|
return j.status === 'running' || j.status === 'queued';
|
|
}).length);
|
|
if (els.statErrors) {
|
|
els.statErrors.textContent = String(jobs.filter(function (j) {
|
|
return j.status === 'completed_with_errors' || (j.failed_targets || 0) > 0;
|
|
}).length);
|
|
}
|
|
renderDashRecent(jobs);
|
|
|
|
if (!jobs.length) {
|
|
els.jobsTableBody.innerHTML = '<tr><td colspan="9">No jobs yet.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
if (!state.selectedJobId) {
|
|
state.selectedJobId = jobs[0].id;
|
|
}
|
|
|
|
els.jobsTableBody.innerHTML = jobs.map(function (job) {
|
|
const progress = job.total_targets > 0
|
|
? (job.processed_targets + '/' + job.total_targets)
|
|
: '0/0';
|
|
const tenantLabel = job.tenant_name
|
|
? '<span class="tenant-tag">' + escHtml(job.tenant_name) + '</span>'
|
|
: '<span style="color:var(--cv-text-secondary);font-size:0.82rem">manual</span>';
|
|
const scanType = job.scan_type || 'sharepoint';
|
|
var typeLabel;
|
|
if (scanType === 'mailbox') {
|
|
typeLabel = '<span class="risk info">Mailbox</span>';
|
|
} else if (scanType === 'sharepoint_root') {
|
|
typeLabel = '<span class="risk warn">SP Root</span>';
|
|
} else if (scanType === 'entra_groups') {
|
|
typeLabel = '<span class="risk high">Entra</span>';
|
|
} else {
|
|
typeLabel = '<span class="risk ok">SharePoint</span>';
|
|
}
|
|
const jobIdSafe = escHtml(job.id);
|
|
return (
|
|
'<tr>' +
|
|
'<td><code>' + jobIdSafe + '</code></td>' +
|
|
'<td>' + typeLabel + '</td>' +
|
|
'<td>' + tenantLabel + '</td>' +
|
|
'<td>' + escHtml(job.source_type) + '</td>' +
|
|
'<td>' + statusBadge(job.status) + '</td>' +
|
|
'<td>' + progress + '</td>' +
|
|
'<td>' + (job.items_scanned > 0 ? job.items_scanned : '-') + '</td>' +
|
|
'<td>' + formatDate(job.updated_at) + '</td>' +
|
|
'<td>' +
|
|
'<div style="display:flex;gap:0.4rem">' +
|
|
'<button class="btn btn-outline btn-small" data-job-inspect="' + jobIdSafe + '">Inspect</button>' +
|
|
(job.status === 'queued' || job.status === 'running'
|
|
? '<button class="btn btn-outline btn-small" data-job-cancel="' + jobIdSafe + '">Cancel</button>'
|
|
: '<button class="btn btn-outline btn-small" data-job-delete="' + jobIdSafe + '">Delete</button>') +
|
|
'</div>' +
|
|
'</td>' +
|
|
'</tr>'
|
|
);
|
|
}).join('');
|
|
|
|
els.jobsTableBody.querySelectorAll('[data-job-inspect]').forEach(function (button) {
|
|
button.addEventListener('click', function () {
|
|
state.selectedJobId = button.getAttribute('data-job-inspect');
|
|
navigateTo('jobs');
|
|
refreshSelectedJob().catch(function () {
|
|
showFeedback(els.submitFeedback, 'Failed to load selected job details.', 'error');
|
|
});
|
|
});
|
|
});
|
|
|
|
els.jobsTableBody.querySelectorAll('[data-job-cancel]').forEach(function (button) {
|
|
button.addEventListener('click', function () {
|
|
const jobId = button.getAttribute('data-job-cancel');
|
|
if (!window.confirm('Cancel job ' + jobId + '? The current target will finish before the job stops.')) return;
|
|
cancelJob(jobId);
|
|
});
|
|
});
|
|
|
|
els.jobsTableBody.querySelectorAll('[data-job-delete]').forEach(function (button) {
|
|
button.addEventListener('click', function () {
|
|
const jobId = button.getAttribute('data-job-delete');
|
|
if (!window.confirm('Delete job ' + jobId + '? This also removes all targets and deviations for this job.')) return;
|
|
deleteJob(jobId);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function cancelJob(jobId) {
|
|
try {
|
|
await requestJson('/api/scan-jobs/' + encodeURIComponent(jobId) + '/cancel', { method: 'POST' });
|
|
await tick();
|
|
} catch (err) {
|
|
showFeedback(els.submitFeedback, 'Cancel failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteJob(jobId) {
|
|
try {
|
|
await fetch('/api/scan-jobs/' + encodeURIComponent(jobId), { method: 'DELETE' });
|
|
if (state.selectedJobId === jobId) {
|
|
state.selectedJobId = null;
|
|
state.selectedJobData = null;
|
|
els.selectedJobId.textContent = 'No selection';
|
|
els.jobSummary.textContent = 'Select a job to inspect targets and deviations.';
|
|
els.targetsTableBody.innerHTML = '<tr><td colspan="6">No job selected.</td></tr>';
|
|
els.deviationsTableBody.innerHTML = '<tr><td colspan="6">No deviation data yet.</td></tr>';
|
|
els.jobSiteFilter.innerHTML = '<option value="">All sites</option>';
|
|
els.exportJobBtn.setAttribute('hidden', '');
|
|
els.sharingLinksResolveBlock.setAttribute('hidden', '');
|
|
}
|
|
await refreshJobs();
|
|
} catch (err) {
|
|
showFeedback(els.submitFeedback, 'Delete failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderJobTables(job) {
|
|
const scanTypeNow = job.scan_type || 'sharepoint';
|
|
const isMailbox = scanTypeNow === 'mailbox';
|
|
const isEntra = scanTypeNow === 'entra_groups';
|
|
const siteFilter = els.jobSiteFilter.value;
|
|
|
|
const filteredTargets = siteFilter
|
|
? job.targets.filter(function (t) { return t.site_url === siteFilter; })
|
|
: job.targets;
|
|
|
|
const filteredDeviations = siteFilter
|
|
? job.deviations.filter(function (d) { return d.site_url === siteFilter; })
|
|
: job.deviations;
|
|
|
|
// Header swap based on scan type
|
|
if (els.targetsHeading) {
|
|
els.targetsHeading.textContent = isMailbox ? 'Mailboxes' : isEntra ? 'Groups' : 'Targets';
|
|
}
|
|
if (els.targetsTableHead) {
|
|
var targetsHead;
|
|
if (isMailbox) {
|
|
targetsHead = '<tr><th>Mailbox</th><th>Status</th><th>Attempts</th><th>Error</th><th>Connection test</th><th></th></tr>';
|
|
} else if (isEntra) {
|
|
targetsHead = '<tr><th>Group</th><th>Status</th><th>Attempts</th><th>Error</th><th>Connection test</th><th></th></tr>';
|
|
} else {
|
|
targetsHead = '<tr><th>URL</th><th>Status</th><th>Attempts</th><th>Error</th><th>Connection test</th><th></th></tr>';
|
|
}
|
|
els.targetsTableHead.innerHTML = targetsHead;
|
|
}
|
|
if (els.deviationsTableHead) {
|
|
var devHead;
|
|
if (isMailbox) {
|
|
devHead = '<tr><th>Mailbox</th><th>Object</th><th>Permission Type</th><th>Principal</th><th>Access Rights</th><th></th></tr>';
|
|
} else if (isEntra) {
|
|
devHead = '<tr><th>Group</th><th>Group Type</th><th>User</th><th>Role</th><th></th><th></th></tr>';
|
|
} else {
|
|
devHead = '<tr><th>Site</th><th>Object</th><th>Type</th><th>Principal</th><th>Role</th><th>Delta</th></tr>';
|
|
}
|
|
els.deviationsTableHead.innerHTML = devHead;
|
|
}
|
|
|
|
// Hide SharingLinks/Resolve Groups for non-SharePoint jobs
|
|
if (isMailbox || isEntra) {
|
|
if (els.sharingLinksResolveBlock) els.sharingLinksResolveBlock.setAttribute('hidden', '');
|
|
if (els.resolveGroupsBlock) els.resolveGroupsBlock.setAttribute('hidden', '');
|
|
} else if (els.resolveGroupsBlock) {
|
|
els.resolveGroupsBlock.removeAttribute('hidden');
|
|
}
|
|
|
|
els.targetsTableBody.innerHTML = filteredTargets.length
|
|
? filteredTargets.map(function (target) {
|
|
return (
|
|
'<tr data-target-id="' + target.id + '">' +
|
|
'<td>' + escHtml(target.site_url) + '</td>' +
|
|
'<td>' + statusBadge(target.status) + '</td>' +
|
|
'<td>' + target.attempts + '</td>' +
|
|
'<td>' + escHtml(target.error_message || '-') + '</td>' +
|
|
'<td class="probe-cell">' + renderProbeStatus(target) + '</td>' +
|
|
'<td><button type="button" class="btn btn-outline btn-small probe-btn" data-target-id="' + target.id + '">Test</button></td>' +
|
|
'</tr>'
|
|
);
|
|
}).join('')
|
|
: '<tr><td colspan="6">No targets.</td></tr>';
|
|
|
|
if (isMailbox) {
|
|
els.deviationsTableBody.innerHTML = filteredDeviations.length
|
|
? filteredDeviations.map(function (d) {
|
|
return (
|
|
'<tr>' +
|
|
'<td class="col-site">' + escHtml(d.site_url) + '</td>' +
|
|
'<td class="col-object">' + escHtml(d.object_url) + '</td>' +
|
|
'<td class="col-type">' + escHtml(d.permission_type || d.object_type) + '</td>' +
|
|
'<td class="col-principal">' + escHtml(d.principal) + '</td>' +
|
|
'<td class="col-role">' + escHtml(d.role_name) + '</td>' +
|
|
'<td></td>' +
|
|
'</tr>'
|
|
);
|
|
}).join('')
|
|
: '<tr><td colspan="6">No mailbox permissions found for this job.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
if (isEntra) {
|
|
els.deviationsTableBody.innerHTML = filteredDeviations.length
|
|
? filteredDeviations.map(function (d) {
|
|
return (
|
|
'<tr>' +
|
|
'<td class="col-site">' + escHtml(d.object_url) + '</td>' +
|
|
'<td class="col-type">' + escHtml(d.permission_type || '') + '</td>' +
|
|
'<td class="col-principal">' + escHtml(d.principal) + '</td>' +
|
|
'<td class="col-role">' + escHtml(d.role_name) + '</td>' +
|
|
'<td></td>' +
|
|
'<td></td>' +
|
|
'</tr>'
|
|
);
|
|
}).join('')
|
|
: '<tr><td colspan="6">No group memberships found for this job.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
els.deviationsTableBody.innerHTML = filteredDeviations.length
|
|
? filteredDeviations.map(function (deviation) {
|
|
const siteShort = shortSite(deviation.site_url);
|
|
const objRel = relPath(deviation.site_url, deviation.object_url);
|
|
const linkType = sharingLinkType(deviation.principal);
|
|
const riskCls = sharingLinkRiskClass(linkType);
|
|
var principalCell;
|
|
if (linkType) {
|
|
var badge = '<span class="risk ' + riskCls + '">' + escHtml(linkType) + '</span>';
|
|
var members = '';
|
|
if (deviation.resolved_members !== null && deviation.resolved_members !== undefined) {
|
|
var memberText = deviation.resolved_members || '(public link)';
|
|
members = '<br><span class="cell-members">' + escHtml(memberText) + '</span>';
|
|
}
|
|
principalCell = '<td class="col-principal" title="' + escHtml(deviation.principal) + '">' + badge + members + '</td>';
|
|
} else {
|
|
const principalShort = shortPrincipal(deviation.principal);
|
|
var membersBlock = '';
|
|
if (deviation.resolved_members) {
|
|
membersBlock = '<br><span class="cell-members">' + escHtml(deviation.resolved_members) + '</span>';
|
|
}
|
|
principalCell = '<td class="col-principal"><span class="cell-truncate" title="' + escHtml(deviation.principal) + '">' + escHtml(principalShort) + '</span>' + membersBlock + '</td>';
|
|
}
|
|
return (
|
|
'<tr>' +
|
|
'<td class="col-site"><span class="cell-truncate" title="' + escHtml(deviation.site_url) + '">' + escHtml(siteShort) + '</span></td>' +
|
|
'<td class="col-object"><span class="cell-truncate" title="' + escHtml(deviation.object_url) + '">' + escHtml(objRel) + '</span></td>' +
|
|
'<td class="col-type">' + escHtml(deviation.object_type) + '</td>' +
|
|
principalCell +
|
|
'<td class="col-role">' + escHtml(deviation.role_name) + '</td>' +
|
|
'<td class="col-delta">' + escHtml(deviation.delta_type) + '</td>' +
|
|
'</tr>'
|
|
);
|
|
}).join('')
|
|
: '<tr><td colspan="6">No permission deviations found for this job.</td></tr>';
|
|
}
|
|
|
|
async function refreshSelectedJob() {
|
|
if (!state.selectedJobId) return;
|
|
const job = await requestJson('/api/scan-jobs/' + encodeURIComponent(state.selectedJobId));
|
|
|
|
els.selectedJobId.textContent = job.id;
|
|
|
|
if (job.scan_activity && job.status === 'running') {
|
|
els.jobActivity.textContent = job.scan_activity;
|
|
els.jobActivity.removeAttribute('hidden');
|
|
} else {
|
|
els.jobActivity.setAttribute('hidden', '');
|
|
}
|
|
|
|
const tenantInfo = job.tenant_name ? ' | <strong>Tenant:</strong> ' + escHtml(job.tenant_name) : '';
|
|
els.jobSummary.innerHTML =
|
|
'<strong>Status:</strong> ' + job.status +
|
|
tenantInfo +
|
|
' | <strong>Targets:</strong> ' + job.total_targets +
|
|
' | <strong>Processed:</strong> ' + job.processed_targets +
|
|
' | <strong>Success:</strong> ' + job.successful_targets +
|
|
' | <strong>Failed:</strong> ' + job.failed_targets +
|
|
' | <strong>Items scanned:</strong> ' + job.items_scanned +
|
|
(job.warning_message ? ' | <strong>Warning:</strong> ' + escHtml(job.warning_message) : '') +
|
|
(job.error_message ? ' | <strong>Error:</strong> ' + escHtml(job.error_message) : '');
|
|
|
|
// Populate site filter dropdown (preserve selection if still valid)
|
|
const prevSite = els.jobSiteFilter.value;
|
|
const siteUrls = job.targets.map(function (t) { return t.site_url; });
|
|
const uniqueSites = siteUrls.filter(function (v, i, a) { return a.indexOf(v) === i; }).sort();
|
|
els.jobSiteFilter.innerHTML =
|
|
'<option value="">All sites</option>' +
|
|
uniqueSites.map(function (url) {
|
|
return '<option value="' + escHtml(url) + '">' + escHtml(url) + '</option>';
|
|
}).join('');
|
|
if (prevSite && uniqueSites.indexOf(prevSite) !== -1) {
|
|
els.jobSiteFilter.value = prevSite;
|
|
}
|
|
|
|
// Store job data for re-render on filter change
|
|
state.selectedJobData = job;
|
|
|
|
// Show export button
|
|
els.exportJobBtn.removeAttribute('hidden');
|
|
|
|
// Build resolve sharing links section
|
|
await _renderResolveBlock(job);
|
|
|
|
renderJobTables(job);
|
|
}
|
|
|
|
async function _renderResolveBlock(job) {
|
|
// Preserve current selection before re-render (auto-refresh runs every few seconds).
|
|
if (state.selectedJobId === job.id) {
|
|
var currentSelected = Array.from(
|
|
els.sharingLinksTypes.querySelectorAll('.sharing-link-type-check:checked')
|
|
).map(function (cb) { return cb.value; });
|
|
state.sharingLinkSelectionByJob[job.id] = currentSelected;
|
|
}
|
|
|
|
if (job.status === 'queued' || job.status === 'running') {
|
|
els.sharingLinksResolveBlock.setAttribute('hidden', '');
|
|
return;
|
|
}
|
|
|
|
if ((job.scan_type || 'sharepoint') === 'mailbox') {
|
|
els.sharingLinksResolveBlock.setAttribute('hidden', '');
|
|
return;
|
|
}
|
|
|
|
var typeCounts = {};
|
|
try {
|
|
var typeData = await requestJson('/api/scan-jobs/' + encodeURIComponent(job.id) + '/sharing-link-types');
|
|
typeCounts = typeData.type_counts || {};
|
|
} catch (_err) {
|
|
// Fallback to currently loaded deviations when aggregate endpoint fails.
|
|
job.deviations.forEach(function (dev) {
|
|
var lt = sharingLinkType(dev.principal);
|
|
if (lt) {
|
|
typeCounts[lt] = (typeCounts[lt] || 0) + 1;
|
|
}
|
|
});
|
|
}
|
|
|
|
var types = Object.keys(typeCounts);
|
|
if (!types.length) {
|
|
els.sharingLinksResolveBlock.setAttribute('hidden', '');
|
|
return;
|
|
}
|
|
|
|
types.sort();
|
|
var rememberedSelection = state.sharingLinkSelectionByJob[job.id];
|
|
els.sharingLinksTypes.innerHTML = types.map(function (lt) {
|
|
var isChecked;
|
|
if (Array.isArray(rememberedSelection)) {
|
|
isChecked = rememberedSelection.indexOf(lt) !== -1;
|
|
} else {
|
|
isChecked = SHARING_LINK_DEFAULT_CHECKED.indexOf(lt) !== -1;
|
|
}
|
|
var checked = isChecked ? 'checked' : '';
|
|
var riskCls = sharingLinkRiskClass(lt);
|
|
return (
|
|
'<label class="checkline">' +
|
|
'<input type="checkbox" class="sharing-link-type-check" value="' + escHtml(lt) + '" ' + checked + '>' +
|
|
'<span><span class="risk ' + riskCls + '">' + escHtml(lt) + '</span>' +
|
|
' <span style="color:var(--cv-text-secondary);font-size:0.82rem">(' + typeCounts[lt] + ')</span></span>' +
|
|
'</label>'
|
|
);
|
|
}).join('');
|
|
|
|
els.sharingLinksTypes.querySelectorAll('.sharing-link-type-check').forEach(function (cb) {
|
|
cb.addEventListener('change', function () {
|
|
state.sharingLinkSelectionByJob[job.id] = Array.from(
|
|
els.sharingLinksTypes.querySelectorAll('.sharing-link-type-check:checked')
|
|
).map(function (x) { return x.value; });
|
|
});
|
|
});
|
|
|
|
els.sharingLinksResolveBlock.removeAttribute('hidden');
|
|
showFeedback(els.resolveFeedback, '', '');
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Auto refresh
|
|
// -------------------------------------------------------------------------
|
|
|
|
async function tick() {
|
|
try {
|
|
await refreshJobs();
|
|
await refreshSelectedJob();
|
|
} catch (err) {
|
|
showFeedback(els.submitFeedback, 'Refresh error: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function startAutoRefresh() {
|
|
if (state.refreshTimer) {
|
|
window.clearInterval(state.refreshTimer);
|
|
}
|
|
state.refreshTimer = window.setInterval(function () {
|
|
tick().catch(function () {
|
|
showFeedback(els.submitFeedback, 'Auto refresh failed.', 'error');
|
|
});
|
|
}, 5000);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// XSS helper
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Link types that are resolved by default (checked in the UI)
|
|
var SHARING_LINK_DEFAULT_CHECKED = ['AnonymousEdit', 'AnonymousView', 'Flexible'];
|
|
|
|
function extractSharingLinkGroupName(principal) {
|
|
if (!principal) return null;
|
|
var text = String(principal).trim();
|
|
var segments = text.split('|').map(function (s) { return s.trim(); }).filter(Boolean);
|
|
|
|
for (var i = segments.length - 1; i >= 0; i -= 1) {
|
|
if (/^sharinglinks\./i.test(segments[i])) {
|
|
return segments[i];
|
|
}
|
|
}
|
|
if (/^sharinglinks\./i.test(text)) {
|
|
return text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sharingLinkType(principal) {
|
|
var groupName = extractSharingLinkGroupName(principal);
|
|
if (!groupName) return null;
|
|
var parts = groupName.split('.');
|
|
return parts.length >= 3 ? parts[2] : null;
|
|
}
|
|
|
|
function sharingLinkRiskClass(linkType) {
|
|
if (!linkType) return null;
|
|
if (linkType.startsWith('Anonymous')) return 'critical';
|
|
if (linkType === 'Flexible') return 'high';
|
|
if (linkType.startsWith('Organization')) return 'info';
|
|
if (linkType.startsWith('Direct')) return 'ok';
|
|
return 'warn';
|
|
}
|
|
|
|
function relPath(siteUrl, objectUrl) {
|
|
if (!objectUrl) return '-';
|
|
const base = (siteUrl || '').replace(/\/$/, '');
|
|
if (base && objectUrl.startsWith(base)) {
|
|
const rel = objectUrl.slice(base.length) || '/';
|
|
return rel.startsWith('/') ? rel : '/' + rel;
|
|
}
|
|
return objectUrl;
|
|
}
|
|
|
|
function shortPrincipal(principal) {
|
|
if (!principal) return '-';
|
|
const idx = principal.lastIndexOf('|');
|
|
return idx !== -1 ? principal.slice(idx + 1) : principal;
|
|
}
|
|
|
|
function shortSite(siteUrl) {
|
|
if (!siteUrl) return '-';
|
|
const clean = siteUrl.replace(/\/$/, '');
|
|
const idx = clean.lastIndexOf('/');
|
|
return idx !== -1 ? clean.slice(idx + 1) : clean;
|
|
}
|
|
|
|
function escHtml(str) {
|
|
if (!str) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Event wiring
|
|
// -------------------------------------------------------------------------
|
|
|
|
els.downloadCertBtn.addEventListener('click', function () {
|
|
const pem = els.certPem.value;
|
|
if (!pem) return;
|
|
const filename = els.downloadCertBtn.getAttribute('data-filename') || 'clearview.cer';
|
|
const blob = new Blob([pem], { type: 'application/x-pem-file' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
els.copyCertBtn.addEventListener('click', function () {
|
|
navigator.clipboard.writeText(els.certPem.value).then(function () {
|
|
els.copyCertBtn.textContent = 'Copied!';
|
|
setTimeout(function () { els.copyCertBtn.textContent = 'Copy to clipboard'; }, 2000);
|
|
});
|
|
});
|
|
els.closeCertBtn.addEventListener('click', function () {
|
|
els.certBlock.setAttribute('hidden', '');
|
|
els.certPem.value = '';
|
|
});
|
|
|
|
els.addTenantBtn.addEventListener('click', openTenantForm);
|
|
els.cancelTenantBtn.addEventListener('click', closeTenantForm);
|
|
els.saveTenantBtn.addEventListener('click', function () {
|
|
saveTenant().catch(function (err) {
|
|
showFeedback(els.tenantFeedback, 'Unexpected error: ' + err.message, 'error');
|
|
});
|
|
});
|
|
|
|
if (els.onboardingForm) {
|
|
els.onboardingForm.addEventListener('submit', createScanAppAutomatically);
|
|
}
|
|
if (els.connectMicrosoftBtn) {
|
|
els.connectMicrosoftBtn.addEventListener('click', function () {
|
|
startMicrosoftConnect().catch(function (err) {
|
|
showFeedback(els.tenantFeedback, 'Connect flow failed: ' + err.message, 'error');
|
|
});
|
|
});
|
|
}
|
|
|
|
els.scanTenantSelect.addEventListener('change', onScanTenantChange);
|
|
els.manualScanForm.addEventListener('submit', createManualJob);
|
|
els.csvScanForm.addEventListener('submit', createCsvJob);
|
|
|
|
els.targetsTableBody.addEventListener('click', function (ev) {
|
|
var btn = ev.target.closest('.probe-btn');
|
|
if (!btn) return;
|
|
var targetId = btn.getAttribute('data-target-id');
|
|
if (!targetId) return;
|
|
testTargetConnection(targetId, btn);
|
|
});
|
|
|
|
els.refreshJobsBtn.addEventListener('click', function () {
|
|
tick().catch(function () {
|
|
showFeedback(els.submitFeedback, 'Refresh failed.', 'error');
|
|
});
|
|
});
|
|
|
|
els.jobTenantFilter.addEventListener('change', function () {
|
|
tick().catch(function () {
|
|
showFeedback(els.submitFeedback, 'Filter failed.', 'error');
|
|
});
|
|
});
|
|
|
|
els.jobSiteFilter.addEventListener('change', async function () {
|
|
if (!state.selectedJobId) return;
|
|
const siteFilter = els.jobSiteFilter.value;
|
|
if (siteFilter) {
|
|
// Fetch server-side filtered data (no 1000-row limit)
|
|
const filtered = await requestJson(
|
|
'/api/scan-jobs/' + encodeURIComponent(state.selectedJobId) +
|
|
'?site_url=' + encodeURIComponent(siteFilter)
|
|
);
|
|
renderJobTables(filtered);
|
|
} else {
|
|
// "All sites" — use the already-loaded full job data
|
|
renderJobTables(state.selectedJobData);
|
|
}
|
|
});
|
|
|
|
els.resolveSharingLinksBtn.addEventListener('click', function () {
|
|
if (!state.selectedJobId) return;
|
|
var checked = Array.from(
|
|
els.sharingLinksTypes.querySelectorAll('.sharing-link-type-check:checked')
|
|
).map(function (cb) { return cb.value; });
|
|
if (!checked.length) {
|
|
showFeedback(els.resolveFeedback, 'Select at least one link type.', 'error');
|
|
return;
|
|
}
|
|
els.resolveSharingLinksBtn.disabled = true;
|
|
showFeedback(els.resolveFeedback, 'Resolving…', '');
|
|
requestJson('/api/scan-jobs/' + encodeURIComponent(state.selectedJobId) + '/resolve-sharing-links', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ link_types: checked }),
|
|
}).then(function (result) {
|
|
showFeedback(
|
|
els.resolveFeedback,
|
|
result.resolved_groups + ' groups resolved, ' + result.updated_deviations + ' deviations updated.',
|
|
'ok'
|
|
);
|
|
return refreshSelectedJob();
|
|
}).catch(function (err) {
|
|
showFeedback(els.resolveFeedback, 'Resolve failed: ' + err.message, 'error');
|
|
}).finally(function () {
|
|
els.resolveSharingLinksBtn.disabled = false;
|
|
});
|
|
});
|
|
|
|
if (els.resolveGroupsBtn) {
|
|
els.resolveGroupsBtn.addEventListener('click', function () {
|
|
if (!state.selectedJobId) return;
|
|
els.resolveGroupsBtn.disabled = true;
|
|
showFeedback(els.resolveGroupsFeedback, 'Resolving SharePoint groups…', '');
|
|
requestJson('/api/scan-jobs/' + encodeURIComponent(state.selectedJobId) + '/resolve-groups', {
|
|
method: 'POST',
|
|
}).then(function (result) {
|
|
showFeedback(
|
|
els.resolveGroupsFeedback,
|
|
result.resolved_groups + ' groups resolved, ' + result.skipped_groups + ' skipped (no readable members), ' +
|
|
result.updated_deviations + ' deviations updated.',
|
|
'ok'
|
|
);
|
|
return refreshSelectedJob();
|
|
}).catch(function (err) {
|
|
showFeedback(els.resolveGroupsFeedback, 'Resolve failed: ' + err.message, 'error');
|
|
}).finally(function () {
|
|
els.resolveGroupsBtn.disabled = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
els.exportJobBtn.addEventListener('click', function () {
|
|
if (!state.selectedJobId) return;
|
|
const siteFilter = els.jobSiteFilter.value;
|
|
let url = '/api/scan-jobs/' + encodeURIComponent(state.selectedJobId) + '/export';
|
|
if (siteFilter) {
|
|
url += '?site_url=' + encodeURIComponent(siteFilter);
|
|
}
|
|
window.location.href = url;
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Mailbox scan creation
|
|
// -------------------------------------------------------------------------
|
|
|
|
function readMailboxScanAuth() {
|
|
const val = els.mailboxScanTenantSelect ? els.mailboxScanTenantSelect.value : '';
|
|
if (!val) {
|
|
throw new Error('Select a tenant profile with a certificate.');
|
|
}
|
|
return { tenant_profile_id: val };
|
|
}
|
|
|
|
async function createManualMailboxJob(event) {
|
|
event.preventDefault();
|
|
const upns = (els.manualMailboxes.value || '')
|
|
.split(/\r?\n/)
|
|
.map(function (line) { return line.trim().toLowerCase(); })
|
|
.filter(Boolean);
|
|
if (!upns.length) {
|
|
showFeedback(els.mailboxSubmitFeedback, 'Enter at least one UPN.', 'error');
|
|
return;
|
|
}
|
|
let auth;
|
|
try {
|
|
auth = readMailboxScanAuth();
|
|
} catch (err) {
|
|
showFeedback(els.mailboxSubmitFeedback, err.message, 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const payload = Object.assign({ scan_type: 'mailbox', mailboxes: upns, skip_default_sites: false }, auth);
|
|
const result = await requestJson('/api/scan-jobs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
showFeedback(
|
|
els.mailboxSubmitFeedback,
|
|
'Mailbox job queued: ' + result.job.id + ' | accepted=' + result.accepted_urls.length +
|
|
', invalid=' + result.invalid_urls.length,
|
|
'ok'
|
|
);
|
|
els.manualMailboxes.value = '';
|
|
state.selectedJobId = result.job.id;
|
|
navigateTo('jobs');
|
|
await refreshJobs();
|
|
await refreshSelectedJob();
|
|
} catch (err) {
|
|
showFeedback(els.mailboxSubmitFeedback, 'Mailbox scan failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function createCsvMailboxJob(event) {
|
|
event.preventDefault();
|
|
const file = els.csvMailboxFile.files && els.csvMailboxFile.files[0];
|
|
if (!file) {
|
|
showFeedback(els.mailboxSubmitFeedback, 'Select a CSV file first.', 'error');
|
|
return;
|
|
}
|
|
let auth;
|
|
try {
|
|
auth = readMailboxScanAuth();
|
|
} catch (err) {
|
|
showFeedback(els.mailboxSubmitFeedback, err.message, 'error');
|
|
return;
|
|
}
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('scan_type', 'mailbox');
|
|
formData.append('tenant_profile_id', auth.tenant_profile_id);
|
|
try {
|
|
const result = await requestJson('/api/scan-jobs/import-csv?skip_default_sites=false', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
showFeedback(
|
|
els.mailboxSubmitFeedback,
|
|
'CSV mailbox job queued: ' + result.job.id + ' | accepted=' + result.accepted_urls.length +
|
|
', invalid=' + result.invalid_urls.length,
|
|
'ok'
|
|
);
|
|
els.csvMailboxFile.value = '';
|
|
state.selectedJobId = result.job.id;
|
|
navigateTo('jobs');
|
|
await refreshJobs();
|
|
await refreshSelectedJob();
|
|
} catch (err) {
|
|
showFeedback(els.mailboxSubmitFeedback, 'CSV import failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function createAllMailboxesJob(event) {
|
|
event.preventDefault();
|
|
const org = (els.allMailboxesOrg.value || '').trim().toLowerCase();
|
|
if (!org || org.indexOf('.') === -1) {
|
|
showFeedback(els.mailboxSubmitFeedback, 'Enter the tenant primary domain (e.g. contoso.onmicrosoft.com).', 'error');
|
|
return;
|
|
}
|
|
let auth;
|
|
try {
|
|
auth = readMailboxScanAuth();
|
|
} catch (err) {
|
|
showFeedback(els.mailboxSubmitFeedback, err.message, 'error');
|
|
return;
|
|
}
|
|
const submitBtn = els.allMailboxesForm.querySelector('button[type="submit"]');
|
|
if (submitBtn) submitBtn.disabled = true;
|
|
showFeedback(els.mailboxSubmitFeedback, 'Enumerating all mailboxes — this can take up to a minute…', '');
|
|
try {
|
|
const payload = Object.assign({
|
|
scan_type: 'mailbox',
|
|
scan_all_mailboxes: true,
|
|
organization: org,
|
|
skip_default_sites: false,
|
|
}, auth);
|
|
const result = await requestJson('/api/scan-jobs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
showFeedback(
|
|
els.mailboxSubmitFeedback,
|
|
'All-mailboxes job queued: ' + result.job.id + ' | accepted=' + result.accepted_urls.length,
|
|
'ok'
|
|
);
|
|
state.selectedJobId = result.job.id;
|
|
navigateTo('jobs');
|
|
await refreshJobs();
|
|
await refreshSelectedJob();
|
|
} catch (err) {
|
|
showFeedback(els.mailboxSubmitFeedback, 'Scan-all failed: ' + err.message, 'error');
|
|
} finally {
|
|
if (submitBtn) submitBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
if (els.manualMailboxForm) {
|
|
els.manualMailboxForm.addEventListener('submit', createManualMailboxJob);
|
|
}
|
|
if (els.csvMailboxForm) {
|
|
els.csvMailboxForm.addEventListener('submit', createCsvMailboxJob);
|
|
}
|
|
if (els.allMailboxesForm) {
|
|
els.allMailboxesForm.addEventListener('submit', createAllMailboxesJob);
|
|
}
|
|
if (els.mailboxScanTenantSelect) {
|
|
els.mailboxScanTenantSelect.addEventListener('change', function () {
|
|
var id = els.mailboxScanTenantSelect.value;
|
|
var tenant = state.tenants.find(function (t) { return t.id === id; });
|
|
if (tenant && tenant.primary_domain && els.allMailboxesOrg) {
|
|
els.allMailboxesOrg.value = tenant.primary_domain;
|
|
}
|
|
});
|
|
}
|
|
if (els.jobTypeFilter) {
|
|
els.jobTypeFilter.addEventListener('change', function () {
|
|
tick().catch(function () { /* ignore */ });
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Entra group scan creation
|
|
// -------------------------------------------------------------------------
|
|
|
|
function readEntraScanAuth() {
|
|
const val = els.entraScanTenantSelect ? els.entraScanTenantSelect.value : '';
|
|
if (!val) {
|
|
throw new Error('Select a tenant profile with a certificate.');
|
|
}
|
|
return { tenant_profile_id: val };
|
|
}
|
|
|
|
async function createManualEntraJob(event) {
|
|
event.preventDefault();
|
|
const ids = (els.manualEntraIds.value || '')
|
|
.split(/\r?\n/)
|
|
.map(function (s) { return s.trim(); })
|
|
.filter(Boolean);
|
|
if (!ids.length) {
|
|
showFeedback(els.entraSubmitFeedback, 'Enter at least one Object ID, mail, or display name.', 'error');
|
|
return;
|
|
}
|
|
let auth;
|
|
try { auth = readEntraScanAuth(); } catch (err) {
|
|
showFeedback(els.entraSubmitFeedback, err.message, 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const payload = Object.assign({ scan_type: 'entra_groups', group_ids: ids, skip_default_sites: false }, auth);
|
|
const result = await requestJson('/api/scan-jobs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
showFeedback(els.entraSubmitFeedback,
|
|
'Entra job queued: ' + result.job.id + ' | accepted=' + result.accepted_urls.length +
|
|
', invalid=' + result.invalid_urls.length, 'ok');
|
|
els.manualEntraIds.value = '';
|
|
state.selectedJobId = result.job.id;
|
|
navigateTo('jobs');
|
|
await refreshJobs();
|
|
await refreshSelectedJob();
|
|
} catch (err) {
|
|
showFeedback(els.entraSubmitFeedback, 'Entra scan failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function createCsvEntraJob(event) {
|
|
event.preventDefault();
|
|
const file = els.csvEntraFile.files && els.csvEntraFile.files[0];
|
|
if (!file) {
|
|
showFeedback(els.entraSubmitFeedback, 'Select a CSV file first.', 'error');
|
|
return;
|
|
}
|
|
let auth;
|
|
try { auth = readEntraScanAuth(); } catch (err) {
|
|
showFeedback(els.entraSubmitFeedback, err.message, 'error');
|
|
return;
|
|
}
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('scan_type', 'entra_groups');
|
|
formData.append('tenant_profile_id', auth.tenant_profile_id);
|
|
try {
|
|
const result = await requestJson('/api/scan-jobs/import-csv?skip_default_sites=false', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
showFeedback(els.entraSubmitFeedback,
|
|
'CSV Entra job queued: ' + result.job.id + ' | accepted=' + result.accepted_urls.length, 'ok');
|
|
els.csvEntraFile.value = '';
|
|
state.selectedJobId = result.job.id;
|
|
navigateTo('jobs');
|
|
await refreshJobs();
|
|
await refreshSelectedJob();
|
|
} catch (err) {
|
|
showFeedback(els.entraSubmitFeedback, 'CSV import failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function createAllEntraJob(event) {
|
|
event.preventDefault();
|
|
let auth;
|
|
try { auth = readEntraScanAuth(); } catch (err) {
|
|
showFeedback(els.entraSubmitFeedback, err.message, 'error');
|
|
return;
|
|
}
|
|
const submitBtn = els.allEntraForm.querySelector('button[type="submit"]');
|
|
if (submitBtn) submitBtn.disabled = true;
|
|
showFeedback(els.entraSubmitFeedback, 'Enumerating all groups in tenant — this can take up to two minutes…', '');
|
|
try {
|
|
const payload = Object.assign({
|
|
scan_type: 'entra_groups',
|
|
scan_all_groups: true,
|
|
skip_default_sites: false,
|
|
}, auth);
|
|
const result = await requestJson('/api/scan-jobs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
showFeedback(els.entraSubmitFeedback,
|
|
'All-groups job queued: ' + result.job.id + ' | accepted=' + result.accepted_urls.length, 'ok');
|
|
state.selectedJobId = result.job.id;
|
|
navigateTo('jobs');
|
|
await refreshJobs();
|
|
await refreshSelectedJob();
|
|
} catch (err) {
|
|
showFeedback(els.entraSubmitFeedback, 'Scan-all failed: ' + err.message, 'error');
|
|
} finally {
|
|
if (submitBtn) submitBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
if (els.manualEntraForm) els.manualEntraForm.addEventListener('submit', createManualEntraJob);
|
|
if (els.csvEntraForm) els.csvEntraForm.addEventListener('submit', createCsvEntraJob);
|
|
if (els.allEntraForm) els.allEntraForm.addEventListener('submit', createAllEntraJob);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Hash router
|
|
// -------------------------------------------------------------------------
|
|
|
|
function parseRoute() {
|
|
var hash = (window.location.hash || '').replace(/^#\/?/, '');
|
|
if (!hash) return 'dashboard';
|
|
if (hash.indexOf('/') !== -1) {
|
|
var parts = hash.split('/');
|
|
if (parts[0] === 'scan' && parts[1]) return 'scan-' + parts[1];
|
|
return parts[0];
|
|
}
|
|
return hash;
|
|
}
|
|
|
|
function applyRoute(route, moveFocus) {
|
|
if (!ROUTE_TITLES[route]) {
|
|
route = 'dashboard';
|
|
}
|
|
state.currentRoute = route;
|
|
var activePage = null;
|
|
document.querySelectorAll('.route-page').forEach(function (page) {
|
|
if (page.getAttribute('data-route-page') === route) {
|
|
page.removeAttribute('hidden');
|
|
activePage = page;
|
|
} else {
|
|
page.setAttribute('hidden', '');
|
|
}
|
|
});
|
|
document.querySelectorAll('.sidebar-nav .nav-link').forEach(function (link) {
|
|
if (link.getAttribute('data-route') === route) {
|
|
link.classList.add('active');
|
|
} else {
|
|
link.classList.remove('active');
|
|
}
|
|
});
|
|
if (els.contentTitle) {
|
|
els.contentTitle.textContent = ROUTE_TITLES[route];
|
|
}
|
|
document.title = 'Clearview | ' + ROUTE_TITLES[route];
|
|
// On user navigation, move focus to the new page's first heading so
|
|
// screen-reader and keyboard users land in the freshly shown content.
|
|
if (moveFocus && activePage) {
|
|
var heading = activePage.querySelector('h1, h2');
|
|
if (heading) {
|
|
heading.setAttribute('tabindex', '-1');
|
|
heading.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
function navigateTo(route) {
|
|
var hash;
|
|
if (route === 'scan-sharepoint') hash = '#/scan/sharepoint';
|
|
else if (route === 'scan-mailbox') hash = '#/scan/mailbox';
|
|
else if (route === 'scan-entra') hash = '#/scan/entra';
|
|
else hash = '#/' + route;
|
|
if (window.location.hash !== hash) {
|
|
window.location.hash = hash;
|
|
} else {
|
|
applyRoute(route, true);
|
|
}
|
|
}
|
|
|
|
window.addEventListener('hashchange', function () {
|
|
applyRoute(parseRoute(), true);
|
|
});
|
|
|
|
applyRoute(parseRoute());
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Init
|
|
// -------------------------------------------------------------------------
|
|
|
|
function loadVersion() {
|
|
if (!els.appVersion) return;
|
|
requestJson('/api/version')
|
|
.then(function (data) {
|
|
if (data && data.version) els.appVersion.textContent = data.version;
|
|
})
|
|
.catch(function () { /* leave placeholder on failure */ });
|
|
}
|
|
|
|
loadVersion();
|
|
consumeOnboardingQueryState();
|
|
initOnboardingSection().catch(function () {
|
|
els.tenantSetupManual.removeAttribute('hidden');
|
|
});
|
|
loadTenants()
|
|
.then(function () { return tick(); })
|
|
.catch(function () {
|
|
showFeedback(els.submitFeedback, 'Initial load failed.', 'error');
|
|
});
|
|
startAutoRefresh();
|
|
})();
|