diff --git a/api.php b/api.php index 1a85cfa..465b150 100644 --- a/api.php +++ b/api.php @@ -33,6 +33,30 @@ define('ALLOWED_EXTENSIONS', ['pdf']); // Cloud Run configuration define('CLOUD_RUN_URL', getenv('CLOUD_RUN_URL') ?: ''); + +/** + * Extract user identity from Azure AD JWT Bearer token. + * Decodes the payload (no signature verification needed — we trust Azure AD issuer; + * the MSAL client already validated the token before sending it here). + * + * @return string|null Azure AD Object ID (oid claim) or null if not available + */ +function extractUserFromToken(): ?array { + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) return null; + + $parts = explode('.', $m[1]); + if (count($parts) !== 3) return null; + + $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); + if (!$payload) return null; + + return [ + 'oid' => $payload['oid'] ?? null, + 'name' => $payload['name'] ?? ($payload['unique_name'] ?? ($payload['upn'] ?? null)), + 'email'=> $payload['email'] ?? ($payload['upn'] ?? null), + ]; +} define('CLOUD_RUN_TIMEOUT', 900); // 15 minutes define('GCP_SA_KEY_PATH', getenv('GCP_SA_KEY_PATH') ?: __DIR__ . '/pdf-api-invoker-key.json'); define('RATE_LIMIT_DIR', __DIR__ . '/rate_limits'); @@ -393,6 +417,9 @@ function handleUpload() { error('Failed to save file'); } + // Attach authenticated user to this job + $user = extractUserFromToken(); + // Create job metadata $job_data = [ 'job_id' => $job_id, @@ -400,7 +427,10 @@ function handleUpload() { 'uploaded_at' => date('Y-m-d H:i:s'), 'file_size' => $file['size'], 'status' => 'uploaded', - 'filepath' => $filepath + 'filepath' => $filepath, + 'user_id' => $user['oid'] ?? null, + 'user_name' => $user['name'] ?? null, + 'user_email'=> $user['email'] ?? null, ]; file_put_contents( @@ -659,17 +689,35 @@ function handleResult() { * List all jobs */ function handleList() { - $jobs = []; + $user = extractUserFromToken(); + $current_user_id = $user['oid'] ?? null; + $jobs = []; $files = glob(RESULTS_DIR . '/*.meta.json'); foreach ($files as $file) { $job_data = json_decode(file_get_contents($file), true); - // Check if completed + // User isolation: only return jobs belonging to the authenticated user. + // Jobs without a user_id (created before this feature) are excluded when + // a user is authenticated to prevent cross-user data leakage. + if ($current_user_id !== null) { + if (($job_data['user_id'] ?? null) !== $current_user_id) continue; + } elseif (($job_data['user_id'] ?? null) !== null) { + // Unauthenticated caller (dev mode) — skip user-owned jobs + continue; + } + + // Enrich with result summary if available $result_file = str_replace('.meta.json', '.result.json', $file); if (file_exists($result_file)) { $job_data['status'] = 'completed'; + $result = json_decode(file_get_contents($result_file), true); + $job_data['score'] = $result['score'] ?? null; + $job_data['grade'] = $result['grade'] ?? null; + $job_data['total_issues'] = $result['total_issues'] ?? null; + $job_data['critical_count'] = $result['severity_counts']['critical'] ?? 0; + $job_data['error_count'] = $result['severity_counts']['error'] ?? 0; } $jobs[] = $job_data; diff --git a/css/styles.css b/css/styles.css index 4af3118..f76fcc6 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1527,6 +1527,137 @@ h1::before { align-items: center; } +/* ── Document History Table ── */ +.history-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.history-table th { + text-align: left; + padding: 10px 14px; + background: var(--surface-alt); + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + border-bottom: 2px solid var(--border); +} + +.history-table td { + padding: 11px 14px; + border-bottom: 1px solid var(--border); + vertical-align: middle; + color: var(--text); +} + +.history-table tbody tr:hover { + background: var(--surface-alt); +} + +.history-filename { + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} + +.history-score { + font-family: var(--font-display); + font-weight: 800; + font-size: 18px; +} + +.history-score small { + font-size: 11px; + font-weight: 400; + color: var(--text-muted); +} + +.history-score-a { color: var(--success); } +.history-score-b { color: var(--warning); } +.history-score-f { color: var(--error); } + +.history-grade { + display: inline-block; + margin-left: 6px; + font-family: var(--font-display); + font-weight: 700; + font-size: 13px; + color: var(--text-muted); +} + +.history-badge-done { + display: inline-block; + padding: 3px 10px; + background: rgba(5, 150, 105, 0.12); + color: var(--success); + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 700; +} + +.history-badge-pending { + display: inline-block; + padding: 3px 10px; + background: var(--surface-alt); + color: var(--text-muted); + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 700; +} + +.history-crit { + display: inline-block; + padding: 2px 8px; + background: rgba(220, 38, 38, 0.1); + color: var(--critical); + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; + margin-right: 4px; +} + +.history-err { + display: inline-block; + padding: 2px 8px; + background: rgba(239, 68, 68, 0.1); + color: var(--error); + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; +} + +.history-actions { + white-space: nowrap; + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.history-action-btn { + display: inline-block; + padding: 5px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 12px; + font-weight: 600; + font-family: var(--font-display); + cursor: pointer; + text-decoration: none; + transition: border-color 0.15s, color 0.15s; +} + +.history-action-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + /* ── Reduced Motion ── */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { diff --git a/index.html b/index.html index e5f69e1..837cbbe 100644 --- a/index.html +++ b/index.html @@ -48,6 +48,18 @@
+ + + +

Upload PDF Document

diff --git a/js/app.js b/js/app.js index 42dfffe..1e8831a 100644 --- a/js/app.js +++ b/js/app.js @@ -96,6 +96,118 @@ function showAuthenticatedUI(account) { } const logoutBtn = document.getElementById('logoutBtn'); if (logoutBtn) logoutBtn.style.display = 'inline-block'; + + // Show history section and load jobs + const historySection = document.getElementById('historySection'); + if (historySection) historySection.style.display = ''; + loadHistory(); +} + +// ─── Document History ──────────────────────────────────────────────────────── + +async function loadHistory() { + const wrap = document.getElementById('historyTableWrap'); + if (!wrap) return; + + try { + const data = await apiCall('list'); + renderHistory(data.jobs || []); + } catch (e) { + console.error('Failed to load history:', e); + } +} + +function renderHistory(jobs) { + const wrap = document.getElementById('historyTableWrap'); + const empty = document.getElementById('historyEmpty'); + + if (!jobs.length) { + if (empty) empty.style.display = ''; + // Remove table if it existed + const old = wrap.querySelector('table'); + if (old) old.remove(); + return; + } + if (empty) empty.style.display = 'none'; + + const rows = jobs.map(j => { + const score = j.score != null ? j.score : '—'; + const grade = j.grade || '—'; + const scoreClass = j.score >= 90 ? 'history-score-a' + : j.score >= 70 ? 'history-score-b' + : j.score != null ? 'history-score-f' : ''; + const status = j.status === 'completed' ? 'Done' + : 'Pending'; + const critical = j.critical_count ?? 0; + const errors = j.error_count ?? 0; + const date = j.uploaded_at ? j.uploaded_at.replace('T', ' ').substring(0, 16) : '—'; + const name = escapeHtml(j.original_filename || j.job_id); + + const openBtn = j.status === 'completed' + ? `` + : ''; + const htmlBtn = j.status === 'completed' + ? `HTML` + : ''; + const pdfBtn = j.status === 'completed' + ? `PDF` + : ''; + const jsonBtn = j.status === 'completed' + ? `JSON` + : ''; + + return ` + ${name} + ${date} + ${status} + ${score}${j.score != null ? '/100' : ''} ${grade} + ${critical > 0 ? `${critical} crit` : ''} ${errors > 0 ? `${errors} err` : ''}${!critical && !errors ? '✓ Clean' : ''} + ${openBtn}${htmlBtn}${pdfBtn}${jsonBtn} + `; + }).join(''); + + // Replace or create table + const old = wrap.querySelector('table'); + if (old) old.remove(); + + const table = document.createElement('table'); + table.className = 'history-table'; + table.setAttribute('aria-label', 'Document history'); + table.innerHTML = ` + + Document + Date + Status + Score + Issues + Actions + + ${rows}`; + wrap.appendChild(table); +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +async function openHistoryJob(jobId) { + // Show results section and load the job — reuse the existing display flow + currentJobId = jobId; + const resultsSection = document.getElementById('resultsSection'); + if (resultsSection) resultsSection.style.display = ''; + + try { + const data = await getResult(jobId); + if (data.error) { alert('Could not load report: ' + data.error); return; } + displayResults(data); + resultsSection.scrollIntoView({ behavior: 'smooth' }); + } catch (e) { + alert('Failed to load report.'); + } } async function loginWithMicrosoft() { diff --git a/js/results.js b/js/results.js index f9a3348..86da7c0 100644 --- a/js/results.js +++ b/js/results.js @@ -39,6 +39,9 @@ function displayResults(data) { initializePageViewer(data); displayRemediationOptions(data); displayMatterhorn(data.matterhorn_summary); + + // Refresh history so the new result appears in the table + if (typeof loadHistory === 'function') loadHistory(); } function displayIssues(issues) {