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 = 'MailboxStatusAttemptsErrorConnection test'; + } else if (isEntra) { + targetsHead = 'GroupStatusAttemptsErrorConnection test'; + } else { + targetsHead = 'URLStatusAttemptsErrorConnection test'; + } + els.targetsTableHead.innerHTML = targetsHead; + } + if (els.deviationsTableHead) { + var devHead; + if (isMailbox) { + devHead = 'MailboxObjectPermission TypePrincipalAccess Rights'; + } else if (isEntra) { + devHead = 'GroupGroup TypeUserRole'; + } else { + devHead = 'SiteObjectTypePrincipalRoleDelta'; + } + 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 ( '