From beede0e49789c97eb18fd243c90c942925f12940 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Wed, 22 Apr 2026 22:51:15 -0400 Subject: [PATCH] Add admin status view and asset versioning --- tests/test_api_admin.py | 37 ++++++++++++++++++++++ web/app.py | 10 ++++-- web/config.py | 25 +++++++++++++++ web/routers/admin.py | 35 +++++++++++++++++++++ web/static/css/base.css | 4 +++ web/static/index.html | 22 ++++++++----- web/static/js/admin.js | 69 +++++++++++++++++++++++++++++++++++++++++ web/static/js/api.js | 7 +++++ web/static/js/app.js | 5 +++ web/static/js/auth.js | 7 +++++ 10 files changed, 210 insertions(+), 11 deletions(-) create mode 100644 tests/test_api_admin.py create mode 100644 web/routers/admin.py create mode 100644 web/static/js/admin.js diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py new file mode 100644 index 0000000..86895c5 --- /dev/null +++ b/tests/test_api_admin.py @@ -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 diff --git a/web/app.py b/web/app.py index ebc66ec..4e3f42c 100644 --- a/web/app.py +++ b/web/app.py @@ -11,11 +11,11 @@ From the project root. from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, HTMLResponse import os 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( 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(verify.router, prefix="/api/verify", tags=["verify"]) app.include_router(audit.router, prefix="/api/audit", tags=["audit"]) +app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) # Static files (frontend) _static_dir = os.path.join(os.path.dirname(__file__), "static") @@ -45,5 +46,8 @@ def health(): def index(): index_path = os.path.join(_static_dir, "index.html") 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"} diff --git a/web/config.py b/web/config.py index 78a677b..ae3eb57 100644 --- a/web/config.py +++ b/web/config.py @@ -6,11 +6,34 @@ All values come from .env or environment variables. """ import os +import subprocess from dotenv import 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: # Adobe Sign OAuth adobe_client_id: str = os.getenv("ADOBE_CLIENT_ID", "") @@ -40,6 +63,8 @@ class Settings: # App version: str = "2.0" + build_id: str = _detect_build_id("dev") + asset_version: str = os.getenv("ASSET_VERSION", build_id) @property def admin_emails(self) -> set[str]: diff --git a/web/routers/admin.py b/web/routers/admin.py new file mode 100644 index 0000000..c695eb5 --- /dev/null +++ b/web/routers/admin.py @@ -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, + }, + } diff --git a/web/static/css/base.css b/web/static/css/base.css index 0547ee8..28f6c96 100644 --- a/web/static/css/base.css +++ b/web/static/css/base.css @@ -170,6 +170,10 @@ tr:hover td { background: #FAFBFC; } .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; } .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 { diff --git a/web/static/index.html b/web/static/index.html index 0148c5d..30c4def 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -4,13 +4,13 @@ docusign — Template Migration Console - - - - - - - + + + + + + + @@ -105,6 +105,12 @@ Settings +
  • @@ -172,7 +178,7 @@ - + diff --git a/web/static/js/admin.js b/web/static/js/admin.js new file mode 100644 index 0000000..a2e156b --- /dev/null +++ b/web/static/js/admin.js @@ -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 = `
    `; + + try { + const data = await api.admin.status(); + const session = data.session || {}; + const env = data.environment || {}; + + outlet.innerHTML = ` + + +
    +
    Application
    +
    + ${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)} +
    +
    + +
    +
    Current Session
    +
    + ${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 || '—')} +
    +
    + +
    +
    Environment
    +
    + ${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)} +
    +
    + `; + } catch (e) { + outlet.innerHTML = `
    Failed to load admin status: ${escHtml(e.message)}
    `; + } +} + +function statusRow(label, value, mono = false) { + return ` +
    +
    ${escHtml(label)}
    +
    ${escHtml(value || '—')}
    +
    + `; +} diff --git a/web/static/js/api.js b/web/static/js/api.js index 63b9035..e760290 100644 --- a/web/static/js/api.js +++ b/web/static/js/api.js @@ -105,4 +105,11 @@ export const api = { }, }, + // ── Admin ──────────────────────────────────────────────────────────────── + admin: { + status() { + return GET('/api/admin/status'); + }, + }, + }; diff --git a/web/static/js/app.js b/web/static/js/app.js index bc66d59..15f9fdf 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -46,6 +46,11 @@ router.register('#/settings', async () => { renderSettings(); }); +router.register('#/admin', async () => { + const { renderAdminStatus } = await import('./admin.js'); + await renderAdminStatus(); +}); + router.register('#/help', async () => { const { renderHelp } = await import('./help.js'); renderHelp(); diff --git a/web/static/js/auth.js b/web/static/js/auth.js index fbfc87b..504b94d 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -47,6 +47,7 @@ export function renderAuthChips() { onClickDocusign ); renderAvatar(); + renderAdminNav(); } function renderAvatar() { @@ -70,6 +71,12 @@ function renderChip(id, connected, label, onClick) { el.onclick = onClick; } +function renderAdminNav() { + const adminItem = document.getElementById('nav-admin-status-item'); + if (!adminItem) return; + adminItem.hidden = !state.auth.isAdmin; +} + // ── Click handlers ───────────────────────────────────────────────────────── async function onClickAdobe() {