diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..c8d5b5c
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Clearview build wrapper. Keeps project-specific version handling out of the
+# shared build-and-push.sh script.
+#
+# Usage:
+# ./build.sh t # increment explicit dev/test build segment, then push :dev
+# ./build.sh r # validate release version state, then run release build
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$repo_root"
+
+mode="${1:-}"
+case "$mode" in
+ t)
+ ./scripts/bump-dev-build.py
+ ;;
+ r)
+ ./scripts/check-release-version.py
+ ;;
+ *)
+ echo "usage: ./build.sh {t|r}" >&2
+ exit 2
+ ;;
+esac
+
+exec ./build-and-push.sh "$@"
diff --git a/containers/clearview/Dockerfile b/containers/clearview/Dockerfile
index b940b14..91b8181 100644
--- a/containers/clearview/Dockerfile
+++ b/containers/clearview/Dockerfile
@@ -1,11 +1,33 @@
-FROM python:3.12-slim
+FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app/src
+# Suppress PowerShell telemetry inside the container
+ENV POWERSHELL_TELEMETRY_OPTOUT=1
+ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
WORKDIR /app
+# ---------------------------------------------------------------------------
+# PowerShell 7 + ExchangeOnlineManagement module
+# Required for Exchange Online mailbox permission scanning.
+# ---------------------------------------------------------------------------
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates curl \
+ && curl -fsSL https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb \
+ -o /tmp/packages-microsoft-prod.deb \
+ && dpkg -i /tmp/packages-microsoft-prod.deb \
+ && rm /tmp/packages-microsoft-prod.deb \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends powershell \
+ && pwsh -NoProfile -NonInteractive -Command \
+ "Set-PSRepository -Name PSGallery -InstallationPolicy Trusted; \
+ Install-Module -Name ExchangeOnlineManagement -Scope AllUsers -Force -AllowClobber" \
+ && apt-get purge -y curl \
+ && apt-get autoremove -y \
+ && rm -rf /var/lib/apt/lists/*
+
COPY requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
diff --git a/containers/clearview/alembic.ini b/containers/clearview/alembic.ini
new file mode 100644
index 0000000..4c8bc67
--- /dev/null
+++ b/containers/clearview/alembic.ini
@@ -0,0 +1,45 @@
+# Alembic config for manual CLI use during development, e.g.:
+# cd containers/clearview && DATABASE_URL=postgresql://... PYTHONPATH=src alembic revision -m "msg"
+#
+# The application itself does NOT read this file: clearview_app.db_migrate builds
+# an Alembic Config programmatically and env.py takes the database URL from
+# DATABASE_URL via clearview_app.config. sqlalchemy.url is therefore left blank.
+
+[alembic]
+script_location = src/clearview_app/migrations
+prepend_sys_path = src
+sqlalchemy.url =
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARNING
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARNING
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/containers/clearview/requirements.txt b/containers/clearview/requirements.txt
index fdc85c8..5a01edc 100644
--- a/containers/clearview/requirements.txt
+++ b/containers/clearview/requirements.txt
@@ -1,9 +1,13 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.36
+alembic==1.14.0
psycopg[binary]==3.2.3
python-multipart==0.0.12
requests==2.32.3
cryptography==44.0.2
msal==1.32.0
openpyxl==3.1.5
+argon2-cffi==23.1.0
+pytest==8.3.3
+httpx==0.27.2
diff --git a/containers/clearview/site/app.js b/containers/clearview/site/app.js
index e48462a..c451e2c 100644
--- a/containers/clearview/site/app.js
+++ b/containers/clearview/site/app.js
@@ -1,9 +1,82 @@
+// -------------------------------------------------------------------------
+// Auth gate: must complete before the main app IIFE renders. While the
+// gate is in flight, `` hides the UI via CSS so the
+// dashboard never flashes for an unauthenticated user.
+// -------------------------------------------------------------------------
+document.documentElement.dataset.authPending = '1';
+
+window.__authReady = (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 false;
+ }
+ if (!r.ok) {
+ window.location.replace('/login.html');
+ return false;
+ }
+ const me = await r.json();
+ window.__clearviewUser = me;
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', function () { applyAuthUi(me); }, { once: true });
+ } else {
+ applyAuthUi(me);
+ }
+ delete document.documentElement.dataset.authPending;
+ return true;
+ } catch (e) {
+ window.location.replace('/login.html');
+ return false;
+ }
+})();
+
+function applyAuthUi(me) {
+ renderUserBadge(me);
+ if (me.role !== 'admin') {
+ document.querySelectorAll('[data-admin-only]').forEach(function (el) {
+ el.style.display = 'none';
+ });
+ }
+}
+
+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 = {
@@ -24,6 +97,7 @@
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'),
@@ -43,11 +117,35 @@
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'),
@@ -60,10 +158,15 @@
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'),
};
// -------------------------------------------------------------------------
@@ -71,7 +174,11 @@
// -------------------------------------------------------------------------
async function requestJson(url, options) {
- const response = await fetch(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 {
@@ -97,6 +204,43 @@
return date.toLocaleString();
}
+ function renderProbeStatus(target) {
+ if (!target.last_probe_at) {
+ return 'Not tested yet';
+ }
+ const when = formatDate(target.last_probe_at);
+ const msg = target.last_probe_message || '';
+ if (target.last_probe_ok) {
+ return 'OK ' + escHtml(when) + '';
+ }
+ return 'Failed ' + escHtml(when) + '
' + escHtml(msg) + '';
+ }
+
+ 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'
@@ -176,11 +320,9 @@
els.tenantsTableBody.querySelectorAll('[data-tenant-scan]').forEach(function (btn) {
btn.addEventListener('click', function () {
const id = btn.getAttribute('data-tenant-scan');
- // Pre-select this tenant in the scan form
els.scanTenantSelect.value = id;
onScanTenantChange();
- // Scroll to scan panel
- els.manualScanForm.closest('.panel').scrollIntoView({ behavior: 'smooth' });
+ navigateTo('scan-sharepoint');
});
});
@@ -198,7 +340,7 @@
}
function populateTenantDropdowns() {
- // Scan tenant select
+ // SharePoint scan tenant select (supports manual creds)
const scanVal = els.scanTenantSelect.value;
els.scanTenantSelect.innerHTML =
'' +
@@ -210,6 +352,36 @@
els.scanTenantSelect.value = scanVal;
}
+ // Entra scan tenant select (cert required for Graph)
+ if (els.entraScanTenantSelect) {
+ const ev = els.entraScanTenantSelect.value;
+ els.entraScanTenantSelect.innerHTML =
+ '' +
+ state.tenants.map(function (t) {
+ var label = escHtml(t.name);
+ if (!t.has_certificate) label += ' (no certificate)';
+ return '';
+ }).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 =
+ '' +
+ state.tenants.map(function (t) {
+ var label = escHtml(t.name);
+ if (!t.has_certificate) {
+ label += ' (no certificate)';
+ }
+ return '';
+ }).join('');
+ if (mbVal) {
+ els.mailboxScanTenantSelect.value = mbVal;
+ }
+ }
+
// Job tenant filter select
const filterVal = els.jobTenantFilter.value;
els.jobTenantFilter.innerHTML =
@@ -244,6 +416,7 @@
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();
@@ -256,7 +429,13 @@
await requestJson('/api/tenants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: name, tenant_id: tenantId, client_id: clientId, client_secret: clientSecret }),
+ 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();
@@ -287,6 +466,7 @@
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 = '';
@@ -371,6 +551,7 @@
if (status === 'connected') {
const tenantId = params.get('tenant_id') || '';
+ navigateTo('tenants');
openTenantForm();
if (tenantId && els.newTenantTenantId) {
els.newTenantTenantId.value = tenantId;
@@ -422,7 +603,8 @@
return;
}
try {
- const payload = Object.assign({ site_urls: urls, skip_default_sites: !!els.manualSkipDefaults.checked }, auth);
+ 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' },
@@ -459,8 +641,10 @@
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 {
@@ -493,21 +677,61 @@
// Jobs list
// -------------------------------------------------------------------------
+ function renderDashRecent(jobs) {
+ if (!els.dashRecentJobs) return;
+ if (!jobs.length) {
+ els.dashRecentJobs.innerHTML = '
| No jobs yet. |
';
+ 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)
+ : 'manual';
+ var progress = job.total_targets > 0 ? (job.processed_targets + '/' + job.total_targets) : '0/0';
+ return '' +
+ '' + jobIdSafe + ' | ' +
+ '' + escHtml(job.scan_type || 'sharepoint') + ' | ' +
+ '' + tenantLabel + ' | ' +
+ '' + statusBadge(job.status) + ' | ' +
+ '' + progress + ' | ' +
+ '' + formatDate(job.updated_at) + ' | ' +
+ '
';
+ }).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 = '| No jobs yet. |
';
+ els.jobsTableBody.innerHTML = '| No jobs yet. |
';
return;
}
@@ -522,21 +746,34 @@
const tenantLabel = job.tenant_name
? '' + escHtml(job.tenant_name) + ''
: 'manual';
+ const scanType = job.scan_type || 'sharepoint';
+ var typeLabel;
+ if (scanType === 'mailbox') {
+ typeLabel = 'Mailbox';
+ } else if (scanType === 'sharepoint_root') {
+ typeLabel = 'SP Root';
+ } else if (scanType === 'entra_groups') {
+ typeLabel = 'Entra';
+ } else {
+ typeLabel = 'SharePoint';
+ }
+ const jobIdSafe = escHtml(job.id);
return (
'' +
- '' + job.id + ' | ' +
+ '' + jobIdSafe + ' | ' +
+ '' + typeLabel + ' | ' +
'' + tenantLabel + ' | ' +
- '' + job.source_type + ' | ' +
+ '' + escHtml(job.source_type) + ' | ' +
'' + statusBadge(job.status) + ' | ' +
'' + progress + ' | ' +
'' + (job.items_scanned > 0 ? job.items_scanned : '-') + ' | ' +
'' + formatDate(job.updated_at) + ' | ' +
'' +
' ' +
- '' +
+ '' +
(job.status === 'queued' || job.status === 'running'
- ? ''
- : '') +
+ ? ''
+ : '') +
' ' +
' | ' +
'
'
@@ -546,6 +783,7 @@
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');
});
@@ -586,7 +824,7 @@
state.selectedJobData = null;
els.selectedJobId.textContent = 'No selection';
els.jobSummary.textContent = 'Select a job to inspect targets and deviations.';
- els.targetsTableBody.innerHTML = '| No job selected. |
';
+ els.targetsTableBody.innerHTML = '| No job selected. |
';
els.deviationsTableBody.innerHTML = '| No deviation data yet. |
';
els.jobSiteFilter.innerHTML = '';
els.exportJobBtn.setAttribute('hidden', '');
@@ -599,6 +837,9 @@
}
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
@@ -609,18 +850,91 @@
? 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 = '| Mailbox | Status | Attempts | Error | Connection test | |
';
+ } else if (isEntra) {
+ targetsHead = '| Group | Status | Attempts | Error | Connection test | |
';
+ } else {
+ targetsHead = '| URL | Status | Attempts | Error | Connection test | |
';
+ }
+ els.targetsTableHead.innerHTML = targetsHead;
+ }
+ if (els.deviationsTableHead) {
+ var devHead;
+ if (isMailbox) {
+ devHead = '| Mailbox | Object | Permission Type | Principal | Access Rights | |
';
+ } else if (isEntra) {
+ devHead = '| Group | Group Type | User | Role | | |
';
+ } else {
+ devHead = '| Site | Object | Type | Principal | Role | Delta |
';
+ }
+ 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 (
- '' +
+ '
' +
'| ' + escHtml(target.site_url) + ' | ' +
'' + statusBadge(target.status) + ' | ' +
'' + target.attempts + ' | ' +
'' + escHtml(target.error_message || '-') + ' | ' +
+ '' + renderProbeStatus(target) + ' | ' +
+ ' | ' +
'
'
);
}).join('')
- : '| No targets. |
';
+ : '| No targets. |
';
+
+ if (isMailbox) {
+ els.deviationsTableBody.innerHTML = filteredDeviations.length
+ ? filteredDeviations.map(function (d) {
+ return (
+ '' +
+ '| ' + escHtml(d.site_url) + ' | ' +
+ '' + escHtml(d.object_url) + ' | ' +
+ '' + escHtml(d.permission_type || d.object_type) + ' | ' +
+ '' + escHtml(d.principal) + ' | ' +
+ '' + escHtml(d.role_name) + ' | ' +
+ ' | ' +
+ '
'
+ );
+ }).join('')
+ : '| No mailbox permissions found for this job. |
';
+ return;
+ }
+
+ if (isEntra) {
+ els.deviationsTableBody.innerHTML = filteredDeviations.length
+ ? filteredDeviations.map(function (d) {
+ return (
+ '' +
+ '| ' + escHtml(d.object_url) + ' | ' +
+ '' + escHtml(d.permission_type || '') + ' | ' +
+ '' + escHtml(d.principal) + ' | ' +
+ '' + escHtml(d.role_name) + ' | ' +
+ ' | ' +
+ ' | ' +
+ '
'
+ );
+ }).join('')
+ : '| No group memberships found for this job. |
';
+ return;
+ }
els.deviationsTableBody.innerHTML = filteredDeviations.length
? filteredDeviations.map(function (deviation) {
@@ -639,7 +953,11 @@
principalCell = '' + badge + members + ' | ';
} else {
const principalShort = shortPrincipal(deviation.principal);
- principalCell = '' + escHtml(principalShort) + ' | ';
+ var membersBlock = '';
+ if (deviation.resolved_members) {
+ membersBlock = '
' + escHtml(deviation.resolved_members) + '';
+ }
+ principalCell = '' + escHtml(principalShort) + '' + membersBlock + ' | ';
}
return (
'' +
@@ -700,25 +1018,43 @@
els.exportJobBtn.removeAttribute('hidden');
// Build resolve sharing links section
- _renderResolveBlock(job);
+ await _renderResolveBlock(job);
renderJobTables(job);
}
- function _renderResolveBlock(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;
}
- // Collect unique link types present in this job's deviations
+ if ((job.scan_type || 'sharepoint') === 'mailbox') {
+ els.sharingLinksResolveBlock.setAttribute('hidden', '');
+ return;
+ }
+
var typeCounts = {};
- job.deviations.forEach(function (dev) {
- var lt = sharingLinkType(dev.principal);
- if (lt) {
- typeCounts[lt] = (typeCounts[lt] || 0) + 1;
- }
- });
+ 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) {
@@ -727,8 +1063,15 @@
}
types.sort();
+ var rememberedSelection = state.sharingLinkSelectionByJob[job.id];
els.sharingLinksTypes.innerHTML = types.map(function (lt) {
- var checked = SHARING_LINK_DEFAULT_CHECKED.indexOf(lt) !== -1 ? 'checked' : '';
+ 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 (
'