Add SSO user isolation and document history dashboard

- api.php: extractUserFromToken() decodes Azure AD JWT payload (oid/name/email)
- Upload: stores user_id, user_name, user_email in job .meta.json
- handleList(): filters jobs by authenticated user's oid — full user isolation
  (jobs without user_id are excluded for authenticated users to prevent leakage);
  enriches each entry with score, grade, critical/error counts from result JSON
- index.html: "My Documents" history section, shown after login
- js/app.js: showAuthenticatedUI() triggers loadHistory(); full renderHistory()
  renders sortable table with score, grade, severity badges, and Open/HTML/PDF/JSON
  action buttons; openHistoryJob() loads any past result into the results panel
- js/results.js: calls loadHistory() after displayResults() so table refreshes
  immediately after a new check completes
- css/styles.css: history table styles with colour-coded score/grade/severity badges

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-13 14:58:18 +00:00
parent 7fe26e7dc4
commit e60639c58d
5 changed files with 309 additions and 3 deletions

54
api.php
View file

@ -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;

View file

@ -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 {

View file

@ -48,6 +48,18 @@
</header>
<main id="main-content">
<div class="container">
<!-- History Section (shown when authenticated) -->
<div class="card" id="historySection" style="display:none;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="margin:0;">My Documents</h2>
<button class="btn btn-secondary" onclick="loadHistory()" id="historyRefreshBtn" aria-label="Refresh document history" style="padding:8px 16px;font-size:13px;">&#x21BA; Refresh</button>
</div>
<div id="historyTableWrap">
<p style="color:var(--text-muted);font-size:14px;" id="historyEmpty">No documents checked yet. Upload a PDF to get started.</p>
</div>
</div>
<!-- Upload Section -->
<div class="card" id="uploadSection">
<h2>Upload PDF Document</h2>

112
js/app.js
View file

@ -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' ? '<span class="history-badge-done">Done</span>'
: '<span class="history-badge-pending">Pending</span>';
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'
? `<button class="history-action-btn" onclick="openHistoryJob('${j.job_id}')" title="Open report">Open</button>`
: '';
const htmlBtn = j.status === 'completed'
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=html" target="_blank" title="Download HTML report">HTML</a>`
: '';
const pdfBtn = j.status === 'completed'
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=pdf" target="_blank" title="Download PDF report">PDF</a>`
: '';
const jsonBtn = j.status === 'completed'
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=json" target="_blank" title="Download JSON">JSON</a>`
: '';
return `<tr>
<td class="history-filename" title="${escapeHtml(j.original_filename || '')}">${name}</td>
<td>${date}</td>
<td>${status}</td>
<td><span class="history-score ${scoreClass}">${score}${j.score != null ? '<small>/100</small>' : ''}</span> <span class="history-grade">${grade}</span></td>
<td>${critical > 0 ? `<span class="history-crit">${critical} crit</span>` : ''} ${errors > 0 ? `<span class="history-err">${errors} err</span>` : ''}${!critical && !errors ? '<span style="color:var(--success)">✓ Clean</span>' : ''}</td>
<td class="history-actions">${openBtn}${htmlBtn}${pdfBtn}${jsonBtn}</td>
</tr>`;
}).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 = `
<thead><tr>
<th>Document</th>
<th>Date</th>
<th>Status</th>
<th>Score</th>
<th>Issues</th>
<th>Actions</th>
</tr></thead>
<tbody>${rows}</tbody>`;
wrap.appendChild(table);
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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() {

View file

@ -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) {