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 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"}
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
.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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue