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:
parent
7fe26e7dc4
commit
e60639c58d
5 changed files with 309 additions and 3 deletions
54
api.php
54
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;
|
||||
|
|
|
|||
131
css/styles.css
131
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 {
|
||||
|
|
|
|||
12
index.html
12
index.html
|
|
@ -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;">↺ 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
112
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' ? '<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, '&')
|
||||
.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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue