Add admin status view and asset versioning

This commit is contained in:
Paul Huliganga 2026-04-22 22:51:15 -04:00
parent 57e8c761f1
commit beede0e497
10 changed files with 210 additions and 11 deletions

37
tests/test_api_admin.py Normal file
View File

@ -0,0 +1,37 @@
from fastapi.testclient import TestClient
from web.app import app
from web.session import _COOKIE_NAME, create_test_session
client = TestClient(app, raise_server_exceptions=True)
def test_admin_status_requires_admin():
cookie = create_test_session({"docusign_user_email": "user@example.com"})
resp = client.get("/api/admin/status", cookies={_COOKIE_NAME: cookie})
assert resp.status_code == 403
def test_admin_status_returns_build_details(monkeypatch):
import web.config as cfg
monkeypatch.setattr(cfg.settings, "admin_emails_raw", "admin@example.com")
monkeypatch.setattr(cfg.settings, "build_id", "testbuild")
monkeypatch.setattr(cfg.settings, "asset_version", "testbuild")
cookie = create_test_session({"docusign_user_email": "admin@example.com"})
resp = client.get("/api/admin/status", cookies={_COOKIE_NAME: cookie})
assert resp.status_code == 200
data = resp.json()
assert data["build_id"] == "testbuild"
assert data["asset_version"] == "testbuild"
def test_index_includes_asset_version(monkeypatch):
import web.config as cfg
monkeypatch.setattr(cfg.settings, "asset_version", "asset123")
resp = client.get("/")
assert resp.status_code == 200
assert "/static/js/app.js?v=asset123" in resp.text
assert "/static/css/base.css?v=asset123" in resp.text

View File

@ -11,11 +11,11 @@ From the project root.
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, HTMLResponse
import os import os
from web.config import settings from web.config import settings
from web.routers import auth, templates, migrate, verify, audit from web.routers import auth, templates, migrate, verify, audit, admin
app = FastAPI( app = FastAPI(
title="Adobe Sign → DocuSign Migrator", title="Adobe Sign → DocuSign Migrator",
@ -29,6 +29,7 @@ app.include_router(templates.router, prefix="/api/templates", tags=["templates"]
app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"]) app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"])
app.include_router(verify.router, prefix="/api/verify", tags=["verify"]) app.include_router(verify.router, prefix="/api/verify", tags=["verify"])
app.include_router(audit.router, prefix="/api/audit", tags=["audit"]) app.include_router(audit.router, prefix="/api/audit", tags=["audit"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
# Static files (frontend) # Static files (frontend)
_static_dir = os.path.join(os.path.dirname(__file__), "static") _static_dir = os.path.join(os.path.dirname(__file__), "static")
@ -45,5 +46,8 @@ def health():
def index(): def index():
index_path = os.path.join(_static_dir, "index.html") index_path = os.path.join(_static_dir, "index.html")
if os.path.exists(index_path): if os.path.exists(index_path):
return FileResponse(index_path) with open(index_path, encoding="utf-8") as f:
html = f.read()
html = html.replace("{{ASSET_VERSION}}", settings.asset_version)
return HTMLResponse(html)
return {"message": "Adobe Sign → DocuSign Migrator API", "docs": "/api/docs"} return {"message": "Adobe Sign → DocuSign Migrator API", "docs": "/api/docs"}

View File

@ -6,11 +6,34 @@ All values come from .env or environment variables.
""" """
import os import os
import subprocess
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
def _project_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
def _detect_build_id(default: str) -> str:
env_build = os.getenv("APP_BUILD_ID", "").strip()
if env_build:
return env_build
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=_project_root(),
check=True,
capture_output=True,
text=True,
)
build_id = result.stdout.strip()
return build_id or default
except Exception:
return default
class Settings: class Settings:
# Adobe Sign OAuth # Adobe Sign OAuth
adobe_client_id: str = os.getenv("ADOBE_CLIENT_ID", "") adobe_client_id: str = os.getenv("ADOBE_CLIENT_ID", "")
@ -40,6 +63,8 @@ class Settings:
# App # App
version: str = "2.0" version: str = "2.0"
build_id: str = _detect_build_id("dev")
asset_version: str = os.getenv("ASSET_VERSION", build_id)
@property @property
def admin_emails(self) -> set[str]: def admin_emails(self) -> set[str]:

35
web/routers/admin.py Normal file
View File

@ -0,0 +1,35 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from web.audit import is_admin_session
from web.config import settings
from web.session import get_session, session_public_view
router = APIRouter()
@router.get("/status")
def admin_status(request: Request):
session = get_session(request)
if not is_admin_session(session):
return JSONResponse({"error": "admin access required"}, status_code=403)
public_session = session_public_view(session)
return {
"version": settings.version,
"build_id": settings.build_id,
"asset_version": settings.asset_version,
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"session": public_session,
"environment": {
"docusign_base_url": settings.docusign_base_url,
"docusign_auth_server": settings.docusign_auth_server,
"docusign_redirect_uri": settings.docusign_redirect_uri,
"adobe_sign_base_url": settings.adobe_sign_base_url,
"adobe_redirect_uri": settings.adobe_redirect_uri,
"session_store_dir": settings.session_store_dir,
"audit_log_file": settings.audit_log_file,
},
}

View File

@ -170,6 +170,10 @@ tr:hover td { background: #FAFBFC; }
.cb { width: 15px; height: 15px; accent-color: var(--cobalt); cursor: pointer; flex-shrink: 0; } .cb { width: 15px; height: 15px; accent-color: var(--cobalt); cursor: pointer; flex-shrink: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
.activity-details { min-width: 320px; font-size: var(--font-size-sm); color: var(--text-muted); } .activity-details { min-width: 320px; font-size: var(--font-size-sm); color: var(--text-muted); }
.admin-status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px 20px; }
.admin-status-row { display: flex; flex-direction: column; gap: 4px; }
.admin-status-label { font-size: var(--font-size-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
.admin-status-value { font-size: var(--font-size-base); color: var(--text); word-break: break-word; }
/* ── Empty state ── */ /* ── Empty state ── */
.empty-state { .empty-state {

View File

@ -4,13 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>docusign — Template Migration Console</title> <title>docusign — Template Migration Console</title>
<link rel="stylesheet" href="/static/css/tokens.css" /> <link rel="stylesheet" href="/static/css/tokens.css?v={{ASSET_VERSION}}" />
<link rel="stylesheet" href="/static/css/base.css" /> <link rel="stylesheet" href="/static/css/base.css?v={{ASSET_VERSION}}" />
<link rel="stylesheet" href="/static/css/nav.css" /> <link rel="stylesheet" href="/static/css/nav.css?v={{ASSET_VERSION}}" />
<link rel="stylesheet" href="/static/css/cards.css" /> <link rel="stylesheet" href="/static/css/cards.css?v={{ASSET_VERSION}}" />
<link rel="stylesheet" href="/static/css/modals.css" /> <link rel="stylesheet" href="/static/css/modals.css?v={{ASSET_VERSION}}" />
<link rel="stylesheet" href="/static/css/tables.css" /> <link rel="stylesheet" href="/static/css/tables.css?v={{ASSET_VERSION}}" />
<link rel="stylesheet" href="/static/css/forms.css" /> <link rel="stylesheet" href="/static/css/forms.css?v={{ASSET_VERSION}}" />
</head> </head>
<body> <body>
@ -105,6 +105,12 @@
<span class="nav-label">Settings</span> <span class="nav-label">Settings</span>
</a> </a>
</li> </li>
<li id="nav-admin-status-item" hidden>
<a class="nav-item" data-route="#/admin" href="#/admin">
<span class="nav-icon">🛠</span>
<span class="nav-label">Admin Status</span>
</a>
</li>
<li> <li>
<a class="nav-item" data-route="#/help" href="#/help"> <a class="nav-item" data-route="#/help" href="#/help">
<span class="nav-icon"></span> <span class="nav-icon"></span>
@ -172,7 +178,7 @@
<!-- ═══════════════════════════════════════════════════════════════ <!-- ═══════════════════════════════════════════════════════════════
APP ENTRY POINT APP ENTRY POINT
═══════════════════════════════════════════════════════════════ --> ═══════════════════════════════════════════════════════════════ -->
<script type="module" src="/static/js/app.js"></script> <script type="module" src="/static/js/app.js?v={{ASSET_VERSION}}"></script>
</body> </body>
</html> </html>

69
web/static/js/admin.js Normal file
View File

@ -0,0 +1,69 @@
import { api } from './api.js';
import { escHtml } from './utils.js';
export async function renderAdminStatus() {
const outlet = document.getElementById('router-outlet');
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
try {
const data = await api.admin.status();
const session = data.session || {};
const env = data.environment || {};
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Admin Status</div>
<div class="page-subtitle">Lightweight deploy, environment, and current-session status for admins.</div>
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Application</span></div>
<div class="card-body admin-status-grid">
${statusRow('Version', data.version)}
${statusRow('Build ID', data.build_id, true)}
${statusRow('Asset Version', data.asset_version, true)}
${statusRow('Server Time (UTC)', data.timestamp_utc, true)}
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Current Session</span></div>
<div class="card-body admin-status-grid">
${statusRow('Session ID', session.session_id, true)}
${statusRow('Adobe', session.adobe ? 'Connected' : 'Disconnected')}
${statusRow('DocuSign', session.docusign ? 'Connected' : 'Disconnected')}
${statusRow('Adobe Auth Mode', session.adobe_auth_mode, true)}
${statusRow('DocuSign Auth Mode', session.docusign_auth_mode, true)}
${statusRow('Adobe Account', session.adobe_account_name || session.adobe_user_email || '—')}
${statusRow('DocuSign Account', session.docusign_selected_account_name || session.docusign_user_email || '—')}
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Environment</span></div>
<div class="card-body admin-status-grid">
${statusRow('DocuSign Base URL', env.docusign_base_url, true)}
${statusRow('DocuSign Auth Server', env.docusign_auth_server, true)}
${statusRow('DocuSign Redirect URI', env.docusign_redirect_uri, true)}
${statusRow('Adobe Base URL', env.adobe_sign_base_url, true)}
${statusRow('Adobe Redirect URI', env.adobe_redirect_uri, true)}
${statusRow('Session Store', env.session_store_dir, true)}
${statusRow('Audit Log', env.audit_log_file, true)}
</div>
</div>
`;
} catch (e) {
outlet.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load admin status: ${escHtml(e.message)}</div>`;
}
}
function statusRow(label, value, mono = false) {
return `
<div class="admin-status-row">
<div class="admin-status-label">${escHtml(label)}</div>
<div class="admin-status-value ${mono ? 'mono' : ''}">${escHtml(value || '—')}</div>
</div>
`;
}

View File

@ -105,4 +105,11 @@ export const api = {
}, },
}, },
// ── Admin ────────────────────────────────────────────────────────────────
admin: {
status() {
return GET('/api/admin/status');
},
},
}; };

View File

@ -46,6 +46,11 @@ router.register('#/settings', async () => {
renderSettings(); renderSettings();
}); });
router.register('#/admin', async () => {
const { renderAdminStatus } = await import('./admin.js');
await renderAdminStatus();
});
router.register('#/help', async () => { router.register('#/help', async () => {
const { renderHelp } = await import('./help.js'); const { renderHelp } = await import('./help.js');
renderHelp(); renderHelp();

View File

@ -47,6 +47,7 @@ export function renderAuthChips() {
onClickDocusign onClickDocusign
); );
renderAvatar(); renderAvatar();
renderAdminNav();
} }
function renderAvatar() { function renderAvatar() {
@ -70,6 +71,12 @@ function renderChip(id, connected, label, onClick) {
el.onclick = onClick; el.onclick = onClick;
} }
function renderAdminNav() {
const adminItem = document.getElementById('nav-admin-status-item');
if (!adminItem) return;
adminItem.hidden = !state.auth.isAdmin;
}
// ── Click handlers ───────────────────────────────────────────────────────── // ── Click handlers ─────────────────────────────────────────────────────────
async function onClickAdobe() { async function onClickAdobe() {