Add tester activity audit log

This commit is contained in:
Paul Huliganga 2026-04-22 11:10:57 -04:00
parent aaa72be54e
commit fb54426bea
14 changed files with 531 additions and 4 deletions

1
.gitignore vendored
View File

@ -10,5 +10,6 @@ __pycache__/
downloads/ downloads/
migration-output/ migration-output/
.session-store/ .session-store/
.audit-log.jsonl
*.pdf *.pdf
private.key private.key

122
tests/test_api_audit.py Normal file
View File

@ -0,0 +1,122 @@
"""
tests/test_api_audit.py
-----------------------
Tests for audit logging and the recent activity endpoint.
"""
import json
import os
from unittest.mock import patch
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
from web.app import app
from web.session import _COOKIE_NAME, create_test_session
import web.routers.migrate as migrate_module
client = TestClient(app, raise_server_exceptions=True)
ADOBE_BASE = "https://api.eu2.adobesign.com/api/rest/v6"
DS_BASE = "https://demo.docusign.net/restapi"
DS_ACCOUNT = "test-account-id"
ADOBE_ID = "adobe-123"
DS_NEW_ID = "ds-new-456"
@pytest.fixture(autouse=True)
def temp_audit_env(tmp_path, monkeypatch):
import web.config as cfg
monkeypatch.setattr(cfg.settings, "session_store_dir", str(tmp_path / ".session-store"))
monkeypatch.setattr(cfg.settings, "audit_log_file", str(tmp_path / ".audit-log.jsonl"))
monkeypatch.setattr(cfg.settings, "docusign_account_id", DS_ACCOUNT)
monkeypatch.setattr(cfg.settings, "docusign_base_url", DS_BASE)
monkeypatch.setattr(cfg.settings, "adobe_sign_base_url", ADOBE_BASE)
monkeypatch.setattr(migrate_module, "_HISTORY_FILE", str(tmp_path / ".history.json"))
client.cookies.clear()
yield
client.cookies.clear()
def test_adobe_connect_writes_audit_event(monkeypatch):
monkeypatch.setenv("ADOBE_ACCESS_TOKEN", "existing-token")
monkeypatch.setenv("ADOBE_REFRESH_TOKEN", "existing-refresh")
with patch("adobe_api._refresh_access_token", return_value="refreshed-token"), \
patch("web.routers.auth._fetch_adobe_profile", return_value={
"adobe_user_name": "Paul Adobe",
"adobe_user_email": "paul@example.com",
"adobe_account_name": "Paul Sandbox",
"adobe_account_id": "adobe-account-123",
}):
resp = client.get("/api/auth/adobe/connect")
assert resp.status_code == 200
activity = client.get("/api/audit/recent")
assert activity.status_code == 200
events = activity.json()["events"]
assert events
assert events[0]["action"] == "adobe_connected"
assert events[0]["adobe_account_name"] == "Paul Sandbox"
assert events[0]["details"]["auth_mode"] == "shared_env"
def _mock_compose(template_dir: str, output_path: str):
with open(output_path, "w", encoding="utf-8") as f:
json.dump({"name": "Test NDA", "description": "mocked"}, f)
def _mock_download(template_id, access_token, output_dir):
os.makedirs(output_dir, exist_ok=True)
with open(os.path.join(output_dir, "metadata.json"), "w", encoding="utf-8") as f:
json.dump({"name": "Test NDA", "id": template_id}, f)
with open(os.path.join(output_dir, "form_fields.json"), "w", encoding="utf-8") as f:
json.dump({"fields": []}, f)
with open(os.path.join(output_dir, "documents.json"), "w", encoding="utf-8") as f:
json.dump({"documents": []}, f)
return True
async def _async_wrap(fn, *args, **kwargs):
return fn(*args, **kwargs)
@respx.mock
def test_migration_writes_audit_event():
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(200, json={"envelopeTemplates": []})
)
respx.post(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(201, json={"templateId": DS_NEW_ID})
)
session_cookie = create_test_session({
"adobe_access_token": "adobe-tok",
"docusign_access_token": "ds-tok",
"adobe_account_name": "Adobe QA",
"docusign_user_name": "Paul Example",
})
with (
patch.object(migrate_module, "_download_adobe_template", new=lambda *args, **kwargs: _async_wrap(_mock_download, *args, **kwargs)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
):
resp = client.post(
"/api/migrate",
json={"adobe_template_ids": [ADOBE_ID]},
cookies={_COOKIE_NAME: session_cookie},
)
assert resp.status_code == 200
activity = client.get("/api/audit/recent", cookies={_COOKIE_NAME: session_cookie})
assert activity.status_code == 200
events = activity.json()["events"]
migrate_event = next(event for event in events if event["action"] == "migration_run")
assert migrate_event["adobe_account_name"] == "Adobe QA"
assert migrate_event["details"]["template_count"] == 1
assert migrate_event["details"]["success_count"] == 1

View File

@ -15,7 +15,7 @@ from fastapi.responses import FileResponse
import os import os
from web.config import settings from web.config import settings
from web.routers import auth, templates, migrate, verify from web.routers import auth, templates, migrate, verify, audit
app = FastAPI( app = FastAPI(
title="Adobe Sign → DocuSign Migrator", title="Adobe Sign → DocuSign Migrator",
@ -28,6 +28,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(templates.router, prefix="/api/templates", tags=["templates"]) 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"])
# 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")

121
web/audit.py Normal file
View File

@ -0,0 +1,121 @@
from __future__ import annotations
import json
import os
from collections import deque
from datetime import datetime, timezone
from typing import Any
from fastapi import Request
from web.config import settings
def _audit_path() -> str:
return settings.audit_log_file
def _ensure_parent_dir() -> None:
parent = os.path.dirname(_audit_path())
if parent:
os.makedirs(parent, exist_ok=True)
def _client_ip(request: Request) -> str | None:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return None
def _session_identity(session: dict[str, Any]) -> dict[str, Any]:
return {
"session_id": session.get("_session_id"),
"adobe_user_name": session.get("adobe_user_name"),
"adobe_user_email": session.get("adobe_user_email"),
"adobe_account_name": session.get("adobe_account_name"),
"adobe_account_id": session.get("adobe_account_id"),
"docusign_user_name": session.get("docusign_user_name"),
"docusign_user_email": session.get("docusign_user_email"),
"docusign_account_name": session.get("docusign_selected_account_name"),
"docusign_account_id": session.get("docusign_selected_account_id"),
}
def request_context(request: Request) -> dict[str, Any]:
return {
"path": str(request.url.path),
"method": request.method,
"ip": _client_ip(request),
"user_agent": request.headers.get("user-agent"),
}
def build_event_with_context(
context: dict[str, Any],
session: dict[str, Any],
action: str,
details: dict[str, Any] | None = None,
) -> dict[str, Any]:
event = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"action": action,
"path": context.get("path"),
"method": context.get("method"),
"ip": context.get("ip"),
"user_agent": context.get("user_agent"),
**_session_identity(session),
}
if details:
event["details"] = details
return event
def build_event(request: Request, session: dict[str, Any], action: str, details: dict[str, Any] | None = None) -> dict[str, Any]:
return build_event_with_context(request_context(request), session, action, details)
def log_event(request: Request, session: dict[str, Any], action: str, details: dict[str, Any] | None = None) -> dict[str, Any]:
event = build_event(request, session, action, details)
_ensure_parent_dir()
with open(_audit_path(), "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=True) + "\n")
return event
def log_context_event(
context: dict[str, Any],
session: dict[str, Any],
action: str,
details: dict[str, Any] | None = None,
) -> dict[str, Any]:
event = build_event_with_context(context, session, action, details)
_ensure_parent_dir()
with open(_audit_path(), "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=True) + "\n")
return event
def recent_events(limit: int = 100) -> list[dict[str, Any]]:
path = _audit_path()
if not os.path.exists(path):
return []
lines: deque[str] = deque(maxlen=max(1, min(limit, 500)))
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
lines.append(line)
events: list[dict[str, Any]] = []
for line in reversed(lines):
try:
item = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(item, dict):
events.append(item)
return events

View File

@ -32,6 +32,10 @@ class Settings:
"SESSION_STORE_DIR", "SESSION_STORE_DIR",
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".session-store")), os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".session-store")),
) )
audit_log_file: str = os.getenv(
"AUDIT_LOG_FILE",
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".audit-log.jsonl")),
)
# App # App
version: str = "2.0" version: str = "2.0"

11
web/routers/audit.py Normal file
View File

@ -0,0 +1,11 @@
from fastapi import APIRouter
from web.audit import recent_events
router = APIRouter()
@router.get("/recent")
def get_recent_events(limit: int = 100):
limit = max(1, min(limit, 500))
return {"events": recent_events(limit)}

View File

@ -20,6 +20,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from web.audit import log_event
from web.config import settings from web.config import settings
from web.docusign_context import ( from web.docusign_context import (
DocusignContextError, DocusignContextError,
@ -191,6 +192,12 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
session["adobe_refresh_token"] = token_data.get("refresh_token") session["adobe_refresh_token"] = token_data.get("refresh_token")
session["adobe_auth_mode"] = "session_oauth" session["adobe_auth_mode"] = "session_oauth"
session = await _merge_adobe_profile(session, session["adobe_access_token"]) session = await _merge_adobe_profile(session, session["adobe_access_token"])
log_event(
request,
session,
"adobe_connected",
{"auth_mode": "session_oauth", "source": "manual_exchange"},
)
response = JSONResponse({"connected": True}) response = JSONResponse({"connected": True})
save_session(response, session) save_session(response, session)
@ -230,6 +237,12 @@ async def adobe_connect_env(request: Request):
session["adobe_refresh_token"] = refresh_token session["adobe_refresh_token"] = refresh_token
session["adobe_auth_mode"] = "shared_env" session["adobe_auth_mode"] = "shared_env"
session = await _merge_adobe_profile(session, token) session = await _merge_adobe_profile(session, token)
log_event(
request,
session,
"adobe_connected",
{"auth_mode": "shared_env", "source": "server_env"},
)
response = JSONResponse({"connected": True}) response = JSONResponse({"connected": True})
save_session(response, session) save_session(response, session)
@ -239,6 +252,7 @@ async def adobe_connect_env(request: Request):
@router.get("/adobe/disconnect") @router.get("/adobe/disconnect")
def adobe_disconnect(request: Request): def adobe_disconnect(request: Request):
session = get_session(request) session = get_session(request)
previous_account_name = session.get("adobe_account_name")
session.pop("adobe_access_token", None) session.pop("adobe_access_token", None)
session.pop("adobe_refresh_token", None) session.pop("adobe_refresh_token", None)
session.pop("adobe_user_name", None) session.pop("adobe_user_name", None)
@ -246,6 +260,12 @@ def adobe_disconnect(request: Request):
session.pop("adobe_account_name", None) session.pop("adobe_account_name", None)
session.pop("adobe_account_id", None) session.pop("adobe_account_id", None)
session["adobe_auth_mode"] = "disconnected" session["adobe_auth_mode"] = "disconnected"
log_event(
request,
session,
"adobe_disconnected",
{"previous_account_name": previous_account_name},
)
response = JSONResponse({"disconnected": "adobe"}) response = JSONResponse({"disconnected": "adobe"})
save_session(response, session) save_session(response, session)
return response return response
@ -287,11 +307,18 @@ async def docusign_connect(request: Request):
state = secrets.token_urlsafe(24) state = secrets.token_urlsafe(24)
session["docusign_oauth_state"] = state session["docusign_oauth_state"] = state
session["docusign_auth_mode"] = "authorization_pending" session["docusign_auth_mode"] = "authorization_pending"
authorization_url = build_authorization_url(state=state)
log_event(
request,
session,
"docusign_authorization_requested",
{"auth_mode": "authorization_pending"},
)
response = JSONResponse( response = JSONResponse(
{ {
"connected": False, "connected": False,
"authorization_required": True, "authorization_required": True,
"authorization_url": build_authorization_url(state=state), "authorization_url": authorization_url,
}, },
status_code=200, status_code=200,
) )
@ -307,6 +334,16 @@ async def docusign_connect(request: Request):
session["docusign_access_token"] = token session["docusign_access_token"] = token
session["docusign_auth_mode"] = "session_oauth" session["docusign_auth_mode"] = "session_oauth"
log_event(
request,
session,
"docusign_connected",
{
"auth_mode": "session_oauth",
"accounts_count": session.get("docusign_accounts_count", 0),
"selection_required": account_picker_required(session),
},
)
response = JSONResponse({ response = JSONResponse({
"connected": True, "connected": True,
"account_selection_required": account_picker_required(session), "account_selection_required": account_picker_required(session),
@ -327,7 +364,14 @@ def docusign_start(request: Request):
state = secrets.token_urlsafe(24) state = secrets.token_urlsafe(24)
session["docusign_oauth_state"] = state session["docusign_oauth_state"] = state
session["docusign_auth_mode"] = "authorization_pending" session["docusign_auth_mode"] = "authorization_pending"
response = RedirectResponse(build_authorization_url(state=state)) authorization_url = build_authorization_url(state=state)
log_event(
request,
session,
"docusign_authorization_started",
{"auth_mode": "authorization_pending"},
)
response = RedirectResponse(authorization_url)
save_session(response, session) save_session(response, session)
return response return response
@ -357,6 +401,16 @@ async def docusign_callback(request: Request, code: str = "", state: str = ""):
session.pop("docusign_oauth_state", None) session.pop("docusign_oauth_state", None)
session["docusign_auth_mode"] = "session_oauth" session["docusign_auth_mode"] = "session_oauth"
log_event(
request,
session,
"docusign_connected",
{
"auth_mode": "session_oauth",
"accounts_count": session.get("docusign_accounts_count", 0),
"selection_required": account_picker_required(session),
},
)
response = RedirectResponse("/#/settings" if account_picker_required(session) else "/") response = RedirectResponse("/#/settings" if account_picker_required(session) else "/")
save_session(response, session) save_session(response, session)
@ -384,6 +438,15 @@ def docusign_account_select(body: DocusignAccountSelectRequest, request: Request
session = select_account(session, body.account_id) session = select_account(session, body.account_id)
except DocusignContextError as e: except DocusignContextError as e:
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code) return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
log_event(
request,
session,
"docusign_account_selected",
{
"selected_account_id": session.get("docusign_selected_account_id"),
"selected_account_name": session.get("docusign_selected_account_name"),
},
)
response = JSONResponse( response = JSONResponse(
{ {
@ -398,6 +461,7 @@ def docusign_account_select(body: DocusignAccountSelectRequest, request: Request
@router.get("/docusign/disconnect") @router.get("/docusign/disconnect")
def docusign_disconnect(request: Request): def docusign_disconnect(request: Request):
session = get_session(request) session = get_session(request)
previous_account_name = session.get("docusign_selected_account_name")
session.pop("docusign_access_token", None) session.pop("docusign_access_token", None)
session.pop("docusign_refresh_token", None) session.pop("docusign_refresh_token", None)
session.pop("docusign_token_expiry", None) session.pop("docusign_token_expiry", None)
@ -408,6 +472,12 @@ def docusign_disconnect(request: Request):
session.pop("docusign_accounts_count", None) session.pop("docusign_accounts_count", None)
clear_selected_account(session) clear_selected_account(session)
session["docusign_auth_mode"] = "disconnected" session["docusign_auth_mode"] = "disconnected"
log_event(
request,
session,
"docusign_disconnected",
{"previous_account_name": previous_account_name},
)
response = JSONResponse({"disconnected": "docusign"}) response = JSONResponse({"disconnected": "docusign"})
save_session(response, session) save_session(response, session)
return response return response

View File

@ -23,6 +23,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from web.audit import log_context_event, log_event, request_context
from web.config import settings from web.config import settings
from web.docusign_context import DocusignContextError, current_account from web.docusign_context import DocusignContextError, current_account
from web.session import get_session from web.session import get_session
@ -381,6 +382,21 @@ async def run_migration(body: MigrateRequest, request: Request):
history = _load_history() history = _load_history()
history.extend(scoped_results) history.extend(scoped_results)
_save_history(history) _save_history(history)
log_event(
request,
session,
"migration_run",
{
"template_count": len(ids),
"dry_run": body.options.dry_run,
"overwrite_if_exists": body.options.overwrite_if_exists,
"include_documents": body.options.include_documents,
"success_count": sum(1 for result in results if result["status"] == "success"),
"failed_count": sum(1 for result in results if result["status"] in ("failed", "blocked")),
"skipped_count": sum(1 for result in results if result["status"] == "skipped"),
"dry_run_count": sum(1 for result in results if result["status"] == "dry_run"),
},
)
return {"results": list(scoped_results)} return {"results": list(scoped_results)}
@ -399,6 +415,8 @@ def migration_history(request: Request):
async def _run_batch_job( async def _run_batch_job(
job_id: str, job_id: str,
owner_session_id: str, owner_session_id: str,
request_info: dict,
session_snapshot: dict,
ids: List[str], ids: List[str],
adobe_token: str, adobe_token: str,
ds_token: str, ds_token: str,
@ -443,6 +461,19 @@ async def _run_batch_job(
"skipped": skipped, "skipped": skipped,
"dry_run": dry_runs, "dry_run": dry_runs,
} }
log_context_event(
request_info,
session_snapshot,
"migration_batch_completed",
{
"job_id": job_id,
"template_count": len(ids),
"success": success,
"failed": failed,
"skipped": skipped,
"dry_run": dry_runs,
},
)
@router.post("/batch") @router.post("/batch")
@ -477,10 +508,22 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
"summary": None, "summary": None,
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
} }
log_event(
request,
session,
"migration_batch_started",
{
"job_id": job_id,
"template_count": len(ids),
"dry_run": body.options.dry_run,
"overwrite_if_exists": body.options.overwrite_if_exists,
"include_documents": body.options.include_documents,
},
)
asyncio.create_task( asyncio.create_task(
_run_batch_job( _run_batch_job(
job_id, session_scope, ids, job_id, session_scope, request_context(request), dict(session), ids,
session["adobe_access_token"], session["adobe_access_token"],
session["docusign_access_token"], session["docusign_access_token"],
account["account_id"], account["account_id"],

View File

@ -12,6 +12,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from web.audit import log_event
from web.docusign_context import DocusignContextError, current_account from web.docusign_context import DocusignContextError, current_account
from web.session import get_session from web.session import get_session
@ -92,6 +93,17 @@ async def send_test_envelope(body: SendRequest, request: Request):
) )
data = resp.json() data = resp.json()
log_event(
request,
session,
"verification_sent",
{
"template_id": body.template_id,
"recipient_email": body.recipient_email,
"recipient_name": body.recipient_name,
"envelope_id": data.get("envelopeId"),
},
)
return {"envelope_id": data.get("envelopeId"), "roles": role_names} return {"envelope_id": data.get("envelopeId"), "roles": role_names}
@ -150,4 +162,10 @@ async def void_envelope(envelope_id: str, body: VoidRequest, request: Request):
status_code=502, status_code=502,
) )
log_event(
request,
session,
"verification_voided",
{"envelope_id": envelope_id, "reason": body.reason},
)
return {"voided": True, "envelope_id": envelope_id} return {"voided": True, "envelope_id": envelope_id}

View File

@ -169,6 +169,7 @@ tr:hover td { background: #FAFBFC; }
.tag { display: inline-block; padding: 1px 7px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600; background: var(--ecru); color: var(--text-muted); margin-right: 4px; } .tag { display: inline-block; padding: 1px 7px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600; background: var(--ecru); color: var(--text-muted); margin-right: 4px; }
.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); }
/* ── Empty state ── */ /* ── Empty state ── */
.empty-state { .empty-state {

View File

@ -91,6 +91,12 @@
<span class="nav-label">History &amp; Audit</span> <span class="nav-label">History &amp; Audit</span>
</a> </a>
</li> </li>
<li>
<a class="nav-item" data-route="#/activity" href="#/activity">
<span class="nav-icon">🧾</span>
<span class="nav-label">Activity</span>
</a>
</li>
<li class="nav-section-label">Admin</li> <li class="nav-section-label">Admin</li>
<li> <li>

117
web/static/js/activity.js Normal file
View File

@ -0,0 +1,117 @@
// Recent activity view for tester/admin auditing
import { api } from './api.js';
import { escHtml, formatDateTime } from './utils.js';
const ACTION_LABELS = {
adobe_connected: 'Adobe connected',
adobe_disconnected: 'Adobe disconnected',
docusign_authorization_requested: 'DocuSign auth requested',
docusign_authorization_started: 'DocuSign auth started',
docusign_connected: 'DocuSign connected',
docusign_account_selected: 'DocuSign account selected',
docusign_disconnected: 'DocuSign disconnected',
migration_run: 'Migration run',
migration_batch_started: 'Batch migration started',
migration_batch_completed: 'Batch migration completed',
verification_sent: 'Verification sent',
verification_voided: 'Verification voided',
};
export async function renderActivity() {
const outlet = document.getElementById('router-outlet');
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
try {
const data = await api.audit.recent(150);
const events = data.events || [];
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Recent Activity</div>
<div class="page-subtitle">Who connected, selected accounts, migrated templates, and sent verification envelopes.</div>
</div>
</div>
${events.length === 0 ? `
<div class="empty-state">
<div class="empty-state-icon">🧾</div>
<div class="empty-state-title">No activity yet</div>
<div class="empty-state-sub">Recent tester actions will appear here after people connect and use the app.</div>
</div>
` : `
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>DocuSign</th>
<th>Adobe</th>
<th>Session</th>
<th>IP</th>
<th>Details</th>
</tr>
</thead>
<tbody>
${events.map(renderEventRow).join('')}
</tbody>
</table>
</div>
</div>
`}
`;
} catch (e) {
outlet.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load activity: ${escHtml(e.message)}</div>`;
}
}
function renderEventRow(event) {
const docusignLabel = event.docusign_account_name || event.docusign_user_name || event.docusign_user_email || '—';
const adobeLabel = event.adobe_account_name || event.adobe_user_name || event.adobe_user_email || '—';
const sessionId = event.session_id ? `${event.session_id.slice(0, 10)}` : '—';
const detailText = formatDetails(event.details);
return `
<tr>
<td style="white-space: nowrap">${escHtml(formatDateTime(event.timestamp))}</td>
<td><span class="badge badge-blue">${escHtml(ACTION_LABELS[event.action] || event.action || 'Activity')}</span></td>
<td>
<div class="table-name">${escHtml(docusignLabel)}</div>
<div class="table-sub">${escHtml(event.docusign_account_id || event.docusign_user_email || '')}</div>
</td>
<td>
<div class="table-name">${escHtml(adobeLabel)}</div>
<div class="table-sub">${escHtml(event.adobe_account_id || event.adobe_user_email || '')}</div>
</td>
<td class="mono">${escHtml(sessionId)}</td>
<td>${escHtml(event.ip || '—')}</td>
<td class="activity-details">${escHtml(detailText)}</td>
</tr>
`;
}
function formatDetails(details) {
if (!details || typeof details !== 'object') {
return '—';
}
const parts = Object.entries(details)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => `${humanizeKey(key)}: ${formatValue(value)}`);
return parts.length ? parts.join(' | ') : '—';
}
function humanizeKey(key) {
return key.replace(/_/g, ' ');
}
function formatValue(value) {
if (typeof value === 'boolean') {
return value ? 'yes' : 'no';
}
return String(value);
}

View File

@ -95,4 +95,11 @@ export const api = {
}, },
}, },
// ── Activity Audit ───────────────────────────────────────────────────────
audit: {
recent(limit = 100) {
return GET(`/api/audit/recent?limit=${encodeURIComponent(limit)}`);
},
},
}; };

View File

@ -36,6 +36,11 @@ router.register('#/history', async () => {
await renderHistory(); await renderHistory();
}); });
router.register('#/activity', async () => {
const { renderActivity } = await import('./activity.js');
await renderActivity();
});
router.register('#/settings', async () => { router.register('#/settings', async () => {
const { renderSettings } = await import('./settings.js'); const { renderSettings } = await import('./settings.js');
renderSettings(); renderSettings();