clearview/containers/clearview/site/app.js

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// -------------------------------------------------------------------------
// 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();
})();