Add tester activity audit log
This commit is contained in:
parent
aaa72be54e
commit
fb54426bea
|
|
@ -10,5 +10,6 @@ __pycache__/
|
|||
downloads/
|
||||
migration-output/
|
||||
.session-store/
|
||||
.audit-log.jsonl
|
||||
*.pdf
|
||||
private.key
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -15,7 +15,7 @@ from fastapi.responses import FileResponse
|
|||
import os
|
||||
|
||||
from web.config import settings
|
||||
from web.routers import auth, templates, migrate, verify
|
||||
from web.routers import auth, templates, migrate, verify, audit
|
||||
|
||||
app = FastAPI(
|
||||
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(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"])
|
||||
|
||||
# Static files (frontend)
|
||||
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -32,6 +32,10 @@ class Settings:
|
|||
"SESSION_STORE_DIR",
|
||||
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
|
||||
version: str = "2.0"
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
@ -20,6 +20,7 @@ from fastapi import APIRouter, Request
|
|||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from web.audit import log_event
|
||||
from web.config import settings
|
||||
from web.docusign_context import (
|
||||
DocusignContextError,
|
||||
|
|
@ -191,6 +192,12 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
|||
session["adobe_refresh_token"] = token_data.get("refresh_token")
|
||||
session["adobe_auth_mode"] = "session_oauth"
|
||||
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})
|
||||
save_session(response, session)
|
||||
|
|
@ -230,6 +237,12 @@ async def adobe_connect_env(request: Request):
|
|||
session["adobe_refresh_token"] = refresh_token
|
||||
session["adobe_auth_mode"] = "shared_env"
|
||||
session = await _merge_adobe_profile(session, token)
|
||||
log_event(
|
||||
request,
|
||||
session,
|
||||
"adobe_connected",
|
||||
{"auth_mode": "shared_env", "source": "server_env"},
|
||||
)
|
||||
|
||||
response = JSONResponse({"connected": True})
|
||||
save_session(response, session)
|
||||
|
|
@ -239,6 +252,7 @@ async def adobe_connect_env(request: Request):
|
|||
@router.get("/adobe/disconnect")
|
||||
def adobe_disconnect(request: Request):
|
||||
session = get_session(request)
|
||||
previous_account_name = session.get("adobe_account_name")
|
||||
session.pop("adobe_access_token", None)
|
||||
session.pop("adobe_refresh_token", 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_id", None)
|
||||
session["adobe_auth_mode"] = "disconnected"
|
||||
log_event(
|
||||
request,
|
||||
session,
|
||||
"adobe_disconnected",
|
||||
{"previous_account_name": previous_account_name},
|
||||
)
|
||||
response = JSONResponse({"disconnected": "adobe"})
|
||||
save_session(response, session)
|
||||
return response
|
||||
|
|
@ -287,11 +307,18 @@ async def docusign_connect(request: Request):
|
|||
state = secrets.token_urlsafe(24)
|
||||
session["docusign_oauth_state"] = state
|
||||
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(
|
||||
{
|
||||
"connected": False,
|
||||
"authorization_required": True,
|
||||
"authorization_url": build_authorization_url(state=state),
|
||||
"authorization_url": authorization_url,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
|
@ -307,6 +334,16 @@ async def docusign_connect(request: Request):
|
|||
|
||||
session["docusign_access_token"] = token
|
||||
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({
|
||||
"connected": True,
|
||||
"account_selection_required": account_picker_required(session),
|
||||
|
|
@ -327,7 +364,14 @@ def docusign_start(request: Request):
|
|||
state = secrets.token_urlsafe(24)
|
||||
session["docusign_oauth_state"] = state
|
||||
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)
|
||||
return response
|
||||
|
||||
|
|
@ -357,6 +401,16 @@ async def docusign_callback(request: Request, code: str = "", state: str = ""):
|
|||
|
||||
session.pop("docusign_oauth_state", None)
|
||||
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 "/")
|
||||
save_session(response, session)
|
||||
|
|
@ -384,6 +438,15 @@ def docusign_account_select(body: DocusignAccountSelectRequest, request: Request
|
|||
session = select_account(session, body.account_id)
|
||||
except DocusignContextError as e:
|
||||
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(
|
||||
{
|
||||
|
|
@ -398,6 +461,7 @@ def docusign_account_select(body: DocusignAccountSelectRequest, request: Request
|
|||
@router.get("/docusign/disconnect")
|
||||
def docusign_disconnect(request: Request):
|
||||
session = get_session(request)
|
||||
previous_account_name = session.get("docusign_selected_account_name")
|
||||
session.pop("docusign_access_token", None)
|
||||
session.pop("docusign_refresh_token", None)
|
||||
session.pop("docusign_token_expiry", None)
|
||||
|
|
@ -408,6 +472,12 @@ def docusign_disconnect(request: Request):
|
|||
session.pop("docusign_accounts_count", None)
|
||||
clear_selected_account(session)
|
||||
session["docusign_auth_mode"] = "disconnected"
|
||||
log_event(
|
||||
request,
|
||||
session,
|
||||
"docusign_disconnected",
|
||||
{"previous_account_name": previous_account_name},
|
||||
)
|
||||
response = JSONResponse({"disconnected": "docusign"})
|
||||
save_session(response, session)
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from fastapi import APIRouter, Request
|
|||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from web.audit import log_context_event, log_event, request_context
|
||||
from web.config import settings
|
||||
from web.docusign_context import DocusignContextError, current_account
|
||||
from web.session import get_session
|
||||
|
|
@ -381,6 +382,21 @@ async def run_migration(body: MigrateRequest, request: Request):
|
|||
history = _load_history()
|
||||
history.extend(scoped_results)
|
||||
_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)}
|
||||
|
||||
|
|
@ -399,6 +415,8 @@ def migration_history(request: Request):
|
|||
async def _run_batch_job(
|
||||
job_id: str,
|
||||
owner_session_id: str,
|
||||
request_info: dict,
|
||||
session_snapshot: dict,
|
||||
ids: List[str],
|
||||
adobe_token: str,
|
||||
ds_token: str,
|
||||
|
|
@ -443,6 +461,19 @@ async def _run_batch_job(
|
|||
"skipped": skipped,
|
||||
"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")
|
||||
|
|
@ -477,10 +508,22 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
|
|||
"summary": None,
|
||||
"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(
|
||||
_run_batch_job(
|
||||
job_id, session_scope, ids,
|
||||
job_id, session_scope, request_context(request), dict(session), ids,
|
||||
session["adobe_access_token"],
|
||||
session["docusign_access_token"],
|
||||
account["account_id"],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from fastapi import APIRouter, Request
|
|||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from web.audit import log_event
|
||||
from web.docusign_context import DocusignContextError, current_account
|
||||
from web.session import get_session
|
||||
|
||||
|
|
@ -92,6 +93,17 @@ async def send_test_envelope(body: SendRequest, request: Request):
|
|||
)
|
||||
|
||||
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}
|
||||
|
||||
|
||||
|
|
@ -150,4 +162,10 @@ async def void_envelope(envelope_id: str, body: VoidRequest, request: Request):
|
|||
status_code=502,
|
||||
)
|
||||
|
||||
log_event(
|
||||
request,
|
||||
session,
|
||||
"verification_voided",
|
||||
{"envelope_id": envelope_id, "reason": body.reason},
|
||||
)
|
||||
return {"voided": True, "envelope_id": envelope_id}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
.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); }
|
||||
|
||||
/* ── Empty state ── */
|
||||
.empty-state {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,12 @@
|
|||
<span class="nav-label">History & Audit</span>
|
||||
</a>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -95,4 +95,11 @@ export const api = {
|
|||
},
|
||||
},
|
||||
|
||||
// ── Activity Audit ───────────────────────────────────────────────────────
|
||||
audit: {
|
||||
recent(limit = 100) {
|
||||
return GET(`/api/audit/recent?limit=${encodeURIComponent(limit)}`);
|
||||
},
|
||||
},
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ router.register('#/history', async () => {
|
|||
await renderHistory();
|
||||
});
|
||||
|
||||
router.register('#/activity', async () => {
|
||||
const { renderActivity } = await import('./activity.js');
|
||||
await renderActivity();
|
||||
});
|
||||
|
||||
router.register('#/settings', async () => {
|
||||
const { renderSettings } = await import('./settings.js');
|
||||
renderSettings();
|
||||
|
|
|
|||
Loading…
Reference in New Issue