Add admin status view and asset versioning
This commit is contained in:
parent
57e8c761f1
commit
beede0e497
|
|
@ -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
|
||||
10
web/app.py
10
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"}
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>docusign — Template Migration Console</title>
|
||||
<link rel="stylesheet" href="/static/css/tokens.css" />
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
<link rel="stylesheet" href="/static/css/nav.css" />
|
||||
<link rel="stylesheet" href="/static/css/cards.css" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css" />
|
||||
<link rel="stylesheet" href="/static/css/tables.css" />
|
||||
<link rel="stylesheet" href="/static/css/forms.css" />
|
||||
<link rel="stylesheet" href="/static/css/tokens.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/nav.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/cards.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/tables.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/forms.css?v={{ASSET_VERSION}}" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -105,6 +105,12 @@
|
|||
<span class="nav-label">Settings</span>
|
||||
</a>
|
||||
</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>
|
||||
<a class="nav-item" data-route="#/help" href="#/help">
|
||||
<span class="nav-icon">❔</span>
|
||||
|
|
@ -172,7 +178,7 @@
|
|||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -105,4 +105,11 @@ export const api = {
|
|||
},
|
||||
},
|
||||
|
||||
// ── Admin ────────────────────────────────────────────────────────────────
|
||||
admin: {
|
||||
status() {
|
||||
return GET('/api/admin/status');
|
||||
},
|
||||
},
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue