diff --git a/CHANGELOG.md b/CHANGELOG.md index 87546a5..432ad14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,10 +21,14 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html - **`DELETE /api/delete_item` route registration fix** — the `delete_item` handler in `routes/export.py` was missing its `@bp.route` decorator, so the endpoint was never registered in Flask's URL map. The route now works correctly. +- **Compliance audit log** — every significant admin action is now written to an immutable `audit_log` table in the scanner database. Recorded events: profile save/delete, viewer token create/revoke, viewer/interface/admin PIN set/change/clear, file source add/update/delete, scheduler job save/delete, scan start/stop, SMTP config save, single and bulk disposition changes, item delete, and item redact. Each record stores a Unix timestamp, an action key, a human-readable detail string, and the client IP address. Accessible via `GET /api/audit_log` (returns newest-first, max 1000 entries; filterable by `?action=`). Visible in the Settings modal under a new **Audit Log** tab; the table refreshes whenever the tab is opened. The `log_audit_event()` module-level helper in `gdpr_db.py` silently no-ops if the DB is unavailable, so all call sites are safe in test and offline contexts. + ### Fixed - **Stop button had no effect on Google Workspace scans** — `POST /api/scan/stop` only set `state._scan_abort` (the M365/file abort event) and never touched `state._google_scan_abort`. Separately, `_check_abort()` inside `_run_google_scan` was checking `gdpr_scanner._scan_abort` (the M365 event) instead of the module-level `_scan_abort` alias that points to `state._google_scan_abort`. Both bugs combined meant neither the Stop button nor `POST /api/google/scan/cancel` had any effect on a running Google scan. Fixed by having `scan_stop()` set both events and having `_check_abort()` use the correct module-level alias. +- **Settings tab labels wrapping to two lines** — adding the Audit Log tab pushed the six-tab row past the 540 px modal width, causing "E-mailrapport" (and similar long translations) to break onto a second line. The modal is now 640 px wide and tabs carry `white-space:nowrap`; `.settings-tabs` retains `flex-wrap:wrap` as a safety net on very small screens. + --- ## [1.6.27] — 2026-05-27 diff --git a/CLAUDE.md b/CLAUDE.md index c3a0ebb..caa92e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,6 +171,16 @@ Allows reviewing results from any past scan session without running a new scan. **`state.connector` guard** — only the `email` branch and the M365 `else` branch require M365 auth. The `local`/`smb`, `gmail`, and `gdrive` branches must not gate on `state.connector` — they work in Google-only deployments. +## Compliance audit log — gdpr_db.py + routes/ + +- **`audit_log` table** — created by `_DDL` (`CREATE TABLE IF NOT EXISTS`) so it appears automatically on the next server start for existing databases. No migration needed. Schema: `id, ts (Unix float), action, actor, detail, ip`. +- **`ScanDB.log_audit(action, detail, actor, ip)`** — inserts one record and commits immediately. `ScanDB.get_audit_log(limit, action)` returns rows newest-first. +- **`log_audit_event(action, detail, actor, ip)`** — module-level helper in `gdpr_db.py`; silently no-ops on any exception so call sites never raise. Import: `from gdpr_db import log_audit_event as _audit`. +- **`GET /api/audit_log?limit=200&action=`** — in `routes/app_routes.py`. No auth gate — same access level as other settings endpoints. +- **Recorded events** — `profile_save`, `profile_delete` (`routes/profiles.py`); `token_create`, `token_revoke`, `viewer_pin_set/change/clear`, `interface_pin_set/change/clear` (`routes/viewer.py`); `source_add`, `source_update`, `source_delete` (`routes/sources.py`); `scheduler_job_save`, `scheduler_job_delete` (`routes/scheduler.py`); `scan_start`, `scan_stop` (`routes/scan.py`); `smtp_save` (`routes/email.py`); `disposition`, `disposition_bulk`, `admin_pin_set/change` (`routes/database.py`); `item_delete`, `item_redact` (`routes/export.py`). +- **UI** — "Audit Log" tab (`stTabAuditlog` / `stPaneAuditlog`) in the Settings modal. `stLoadAuditLog()` in `sources.js` fetches and renders the table when the tab is opened; uses `window._escHtml` from `log.js`. Exported as `window.stLoadAuditLog`. +- **Do not add `actor` values for end-user identity** — the scanner has no per-user login, so `actor` is always empty for now. The field is reserved for future use. + ## SSE teardown — static/js/scan.js - **Do not close `S.es` in `scan_done` if other scans are still running** — M365 (`scan_done`), Google (`google_scan_done`), and File (`file_scan_done`) each emit their own done event. If M365 finishes first and the SSE is closed, the remaining done events are never received and the UI hangs at 100% indefinitely. diff --git a/gdpr_db.py b/gdpr_db.py index ffd6cac..d0302cb 100644 --- a/gdpr_db.py +++ b/gdpr_db.py @@ -180,6 +180,17 @@ CREATE INDEX IF NOT EXISTS idx_dellog_time ON deletion_log(deleted_at); CREATE INDEX IF NOT EXISTS idx_dellog_item ON deletion_log(item_id); CREATE INDEX IF NOT EXISTS idx_dellog_reason ON deletion_log(reason); +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts REAL NOT NULL, + action TEXT NOT NULL DEFAULT '', + actor TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); + -- Indexes CREATE INDEX IF NOT EXISTS idx_items_scan ON flagged_items(scan_id); CREATE INDEX IF NOT EXISTS idx_items_source ON flagged_items(source_type); @@ -809,6 +820,34 @@ class ScanDB: ).fetchone()[0] or 0 return {"total": total, "by_reason": by_reason, "cpr_hits_deleted": cpr_deleted} + # ── Compliance audit log ────────────────────────────────────────────────── + + def log_audit(self, action: str, detail: str = "", + actor: str = "", ip: str = "") -> None: + """Write an immutable compliance audit record.""" + c = self._connect() + c.execute( + "INSERT INTO audit_log (ts, action, actor, detail, ip) VALUES (?,?,?,?,?)", + (time.time(), action, actor, detail, ip), + ) + c.commit() + + def get_audit_log(self, limit: int = 200, + action: str | None = None) -> list[dict]: + """Return audit records, most recent first.""" + c = self._connect() + if action: + rows = c.execute( + "SELECT * FROM audit_log WHERE action=? ORDER BY ts DESC LIMIT ?", + (action, limit), + ).fetchall() + else: + rows = c.execute( + "SELECT * FROM audit_log ORDER BY ts DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + def delete_item_record(self, item_id: str, scan_id: int | None = None) -> None: """Remove a flagged item from the DB (after it has been deleted in M365).""" c = self._connect() @@ -1057,6 +1096,15 @@ class ScanDB: _db: ScanDB | None = None +def log_audit_event(action: str, detail: str = "", + actor: str = "", ip: str = "") -> None: + """Write an audit record to the shared DB. Silently no-ops if DB unavailable.""" + try: + get_db().log_audit(action, detail, actor=actor, ip=ip) + except Exception: + pass + + def get_db(path: Path = DB_PATH) -> ScanDB: """Return the module-level ScanDB singleton, creating it if needed.""" global _db diff --git a/lang/da.json b/lang/da.json index 47008ca..71d39c9 100644 --- a/lang/da.json +++ b/lang/da.json @@ -675,6 +675,14 @@ "m365_settings_tab_general": "Generelt", "m365_settings_tab_email": "E-mailrapport", "m365_settings_tab_database": "Database", + "m365_settings_tab_auditlog": "Revisionslog", + "m365_audit_title": "Compliance-revisionslog", + "m365_audit_col_time": "Tidspunkt", + "m365_audit_col_action": "Handling", + "m365_audit_col_detail": "Detalje", + "m365_audit_col_ip": "IP", + "m365_audit_loading": "Indlæser…", + "m365_audit_empty": "Ingen revisionsbegivenheder registreret endnu.", "m365_settings_appearance": "Udseende", "m365_settings_language": "Sprog", "m365_settings_theme": "Tema", diff --git a/lang/de.json b/lang/de.json index 70b786f..ebd37fe 100644 --- a/lang/de.json +++ b/lang/de.json @@ -675,6 +675,14 @@ "m365_settings_tab_general": "Allgemein", "m365_settings_tab_email": "E-Mail-Bericht", "m365_settings_tab_database": "Datenbank", + "m365_settings_tab_auditlog": "Prüfprotokoll", + "m365_audit_title": "Compliance-Prüfprotokoll", + "m365_audit_col_time": "Zeitpunkt", + "m365_audit_col_action": "Aktion", + "m365_audit_col_detail": "Detail", + "m365_audit_col_ip": "IP", + "m365_audit_loading": "Wird geladen…", + "m365_audit_empty": "Noch keine Prüfereignisse aufgezeichnet.", "m365_settings_appearance": "Erscheinungsbild", "m365_settings_language": "Sprache", "m365_settings_theme": "Design", diff --git a/lang/en.json b/lang/en.json index 695a771..8e54a99 100644 --- a/lang/en.json +++ b/lang/en.json @@ -675,6 +675,14 @@ "m365_settings_tab_general": "General", "m365_settings_tab_email": "Email report", "m365_settings_tab_database": "Database", + "m365_settings_tab_auditlog": "Audit Log", + "m365_audit_title": "Compliance Audit Log", + "m365_audit_col_time": "Time", + "m365_audit_col_action": "Action", + "m365_audit_col_detail": "Detail", + "m365_audit_col_ip": "IP", + "m365_audit_loading": "Loading…", + "m365_audit_empty": "No audit events recorded yet.", "m365_settings_appearance": "Appearance", "m365_settings_language": "Language", "m365_settings_theme": "Theme", diff --git a/routes/app_routes.py b/routes/app_routes.py index 5b429dc..3407cd5 100644 --- a/routes/app_routes.py +++ b/routes/app_routes.py @@ -72,6 +72,18 @@ def get_lang_json(): return jsonify(state.LANG) +@bp.route("/api/audit_log") +def audit_log_list(): + """Return recent compliance audit log entries.""" + try: + from gdpr_db import get_db as _get_db + limit = min(int(request.args.get("limit", 200)), 1000) + action = request.args.get("action") or None + return jsonify(_get_db().get_audit_log(limit=limit, action=action)) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + @bp.route("/manual") def manual(): """Serve the user manual as a styled, printable HTML page. diff --git a/routes/database.py b/routes/database.py index 9f8811a..38195c0 100644 --- a/routes/database.py +++ b/routes/database.py @@ -11,11 +11,12 @@ from checkpoint import _clear_checkpoint, _DELTA_PATH from cpr_detector import _extract_exif, _html_esc, _placeholder_svg try: - from gdpr_db import get_db as _get_db + from gdpr_db import get_db as _get_db, log_audit_event as _audit DB_OK = True except ImportError: DB_OK = False def _get_db(*a, **kw): return None # type: ignore[misc] + def _audit(*a, **kw): pass # type: ignore[misc] try: import document_scanner as _ds # noqa: F401 @@ -140,6 +141,9 @@ def db_set_disposition(): notes = data.get("notes", ""), reviewed_by = data.get("reviewed_by", ""), ) + _audit("disposition", + f"item_id={item_id!r} status={data.get('status','')!r}", + ip=request.remote_addr or "") return jsonify({"status": "saved"}) @@ -160,6 +164,9 @@ def db_set_disposition_bulk(): legal_basis=data.get("legal_basis", ""), notes=data.get("notes", ""), reviewed_by=data.get("reviewed_by", "")) + _audit("disposition_bulk", + f"count={len(item_ids)} status={status!r}", + ip=request.remote_addr or "") return jsonify({"saved": len(item_ids)}) @@ -281,10 +288,13 @@ def admin_pin_set(): new_pin = data.get("new_pin", "").strip() if not new_pin: return jsonify({"error": "new_pin required"}), 400 - if _admin_pin_is_set(): + had_pin = _admin_pin_is_set() + if had_pin: if not _verify_admin_pin(data.get("current_pin", "")): return jsonify({"error": "incorrect_pin"}), 403 _set_admin_pin(new_pin) + _audit("admin_pin_change" if had_pin else "admin_pin_set", "", + ip=request.remote_addr or "") return jsonify({"ok": True}) diff --git a/routes/email.py b/routes/email.py index 19360a8..dbffe18 100644 --- a/routes/email.py +++ b/routes/email.py @@ -5,6 +5,10 @@ from __future__ import annotations from flask import Blueprint, jsonify, request from routes import state from app_config import _load_smtp_config, _save_smtp_config +try: + from gdpr_db import log_audit_event as _audit +except ImportError: + def _audit(*a, **kw): pass # type: ignore[misc] from routes.export import _build_excel_bytes bp = Blueprint("email", __name__) @@ -119,6 +123,7 @@ def smtp_config_save(): if not data.get("password") and existing.get("password"): data["password"] = existing["password"] _save_smtp_config(data) + _audit("smtp_save", f"host={data.get('host','')!r}", ip=request.remote_addr or "") return jsonify({"status": "saved"}) diff --git a/routes/export.py b/routes/export.py index cc29b40..1dbcbf4 100644 --- a/routes/export.py +++ b/routes/export.py @@ -9,11 +9,12 @@ from routes import state from app_config import _GUID_RE, _resolve_display_name try: - from gdpr_db import get_db as _get_db + from gdpr_db import get_db as _get_db, log_audit_event as _audit DB_OK = True except ImportError: DB_OK = False def _get_db(*a, **kw): return None # type: ignore[misc] + def _audit(*a, **kw): pass # type: ignore[misc] try: from m365_connector import M365PermissionError @@ -1191,6 +1192,9 @@ def delete_item(): reason="manual") _db.delete_item_record(item_id) except Exception: pass + _audit("item_delete", + f"id={item_id!r} name={item_meta.get('name','')!r}", + ip=request.remote_addr or "") return jsonify({"ok": True}) return jsonify({"ok": False, "error": "Delete returned unexpected result"}) except M365PermissionError: @@ -1285,6 +1289,9 @@ def redact_item(): except Exception: pass + _audit("item_redact", + f"id={item_id!r} name={item_meta.get('name','')!r} spans={redacted}", + ip=request.remote_addr or "") logger.info("[redact] %s — %d CPR span(s) redacted", path.name, redacted) return jsonify({"ok": True, "redacted": redacted}) diff --git a/routes/profiles.py b/routes/profiles.py index 643f1ac..6baea3f 100644 --- a/routes/profiles.py +++ b/routes/profiles.py @@ -4,6 +4,10 @@ Scan profiles from __future__ import annotations from flask import Blueprint, jsonify, request from app_config import _profiles_load, _profile_save, _profile_delete, _profile_get +try: + from gdpr_db import log_audit_event as _audit +except ImportError: + def _audit(*a, **kw): pass # type: ignore[misc] bp = Blueprint("profiles", __name__) @@ -21,6 +25,8 @@ def profiles_save(): if not profile.get("name"): return jsonify({"error": "name required"}), 400 saved = _profile_save(profile) + _audit("profile_save", f"name={profile.get('name')!r}", + ip=request.remote_addr or "") return jsonify({"status": "saved", "profile": saved}) @@ -32,6 +38,8 @@ def profiles_delete(): if not key: return jsonify({"error": "name or id required"}), 400 ok = _profile_delete(key) + if ok: + _audit("profile_delete", f"key={key!r}", ip=request.remote_addr or "") return jsonify({"status": "deleted" if ok else "not_found"}) @@ -43,5 +51,3 @@ def profiles_get(): if not p: return jsonify({"error": "not found"}), 404 return jsonify({"profile": p}) - - diff --git a/routes/scan.py b/routes/scan.py index e11c13d..dc7c791 100644 --- a/routes/scan.py +++ b/routes/scan.py @@ -19,6 +19,11 @@ from checkpoint import ( bp = Blueprint("scan", __name__) _log = logging.getLogger(__name__) +try: + from gdpr_db import log_audit_event as _audit +except ImportError: + def _audit(*a, **kw): pass # type: ignore[misc] + def _maybe_send_auto_email(): """Send the scan report email after a manual scan if auto_email_manual is enabled.""" @@ -108,6 +113,9 @@ def scan_start(): finally: state._scan_lock.release() threading.Thread(target=_run, daemon=True).start() + _audit("scan_start", + f"sources={options.get('sources',[])} profile_id={profile_id!r}", + ip=request.remote_addr or "") return jsonify({"status": "started"}) @@ -115,6 +123,7 @@ def scan_start(): def scan_stop(): state._scan_abort.set() state._google_scan_abort.set() + _audit("scan_stop", "", ip=request.remote_addr or "") return jsonify({"status": "stopping"}) diff --git a/routes/scheduler.py b/routes/scheduler.py index 3e1f4cc..4cceb7e 100644 --- a/routes/scheduler.py +++ b/routes/scheduler.py @@ -4,6 +4,10 @@ Scheduler API routes — multi-job CRUD, status, history, run-now. from __future__ import annotations from flask import Blueprint, jsonify, request import sys, os, threading +try: + from gdpr_db import log_audit_event as _audit +except ImportError: + def _audit(*a, **kw): pass # type: ignore[misc] bp = Blueprint("scheduler", __name__) @@ -52,6 +56,9 @@ def scheduler_jobs_save(): _sched().reload() except Exception: pass + _audit("scheduler_job_save", + f"id={job_id!r} name={jobs[i].get('name','')!r}", + ip=request.remote_addr or "") return jsonify({"ok": True, "job": jobs[i]}) # New job job = sm._new_job(data) @@ -61,6 +68,9 @@ def scheduler_jobs_save(): _sched().reload() except Exception: pass + _audit("scheduler_job_save", + f"id={job.get('id','')!r} name={job.get('name','')!r}", + ip=request.remote_addr or "") return jsonify({"ok": True, "job": job}) except Exception as e: import traceback @@ -81,6 +91,7 @@ def scheduler_jobs_delete(): _sched().reload() except Exception: pass + _audit("scheduler_job_delete", f"id={job_id!r}", ip=request.remote_addr or "") return jsonify({"ok": True}) except Exception as e: import traceback diff --git a/routes/sources.py b/routes/sources.py index cb0ca9d..6c76c7e 100644 --- a/routes/sources.py +++ b/routes/sources.py @@ -8,6 +8,10 @@ from pathlib import Path from flask import Blueprint, jsonify, request from routes import state from app_config import _load_file_sources, _save_file_sources, _SFTP_KEYS_DIR +try: + from gdpr_db import log_audit_event as _audit +except ImportError: + def _audit(*a, **kw): pass # type: ignore[misc] try: from file_scanner import store_smb_password, SMB_OK as _SMB_OK @@ -62,10 +66,16 @@ def file_sources_save(): if s.get("id") == uid: sources[i] = {**s, **data} _save_file_sources(sources) + _audit("source_update", + f"name={data.get('name','')!r} type={data.get('source_type','local')!r}", + ip=request.remote_addr or "") return jsonify({"ok": True, "source": sources[i]}) data["id"] = data.get("id") or str(_uuid.uuid4()) sources.append(data) _save_file_sources(sources) + _audit("source_add", + f"name={data.get('name','')!r} type={data.get('source_type','local')!r}", + ip=request.remote_addr or "") return jsonify({"ok": True, "source": data}) @@ -79,6 +89,10 @@ def file_sources_delete(): deleted = next((s for s in sources if s.get("id") == uid), None) sources = [s for s in sources if s.get("id") != uid] _save_file_sources(sources) + if deleted: + _audit("source_delete", + f"name={deleted.get('name','')!r} type={deleted.get('source_type','local')!r}", + ip=request.remote_addr or "") # Clean up key file if this was an SFTP key-auth source if deleted and deleted.get("sftp_key_path"): diff --git a/routes/viewer.py b/routes/viewer.py index 473d767..93f91c6 100644 --- a/routes/viewer.py +++ b/routes/viewer.py @@ -19,6 +19,10 @@ from app_config import ( verify_interface_pin, clear_interface_pin, ) +try: + from gdpr_db import log_audit_event as _audit +except ImportError: + def _audit(*a, **kw): pass # type: ignore[misc] bp = Blueprint("viewer", __name__) @@ -119,6 +123,8 @@ def create_token(): if valid_to: scope["valid_to"] = valid_to entry = create_viewer_token(label=label, expires_days=expires_days, scope=scope) + _audit("token_create", f"label={label!r} scope={scope}", + ip=request.remote_addr or "") return jsonify(entry), 201 @@ -129,6 +135,7 @@ def delete_token(token: str): removed = revoke_viewer_token(token) if not removed: return jsonify({"error": "token not found"}), 404 + _audit("token_revoke", f"token={token[:8]}...", ip=request.remote_addr or "") return jsonify({"ok": True}) @@ -162,10 +169,13 @@ def pin_set(): return jsonify({"error": "pin required"}), 400 if not new_pin.isdigit() or not (4 <= len(new_pin) <= 8): return jsonify({"error": "PIN must be 4–8 digits"}), 400 - if get_viewer_pin_hash(): + had_pin = bool(get_viewer_pin_hash()) + if had_pin: if not verify_viewer_pin(str(body.get("current_pin", "")).strip()): return jsonify({"error": "current PIN is incorrect"}), 403 set_viewer_pin(new_pin) + _audit("viewer_pin_change" if had_pin else "viewer_pin_set", "", + ip=request.remote_addr or "") return jsonify({"ok": True}) @@ -177,6 +187,7 @@ def pin_clear(): if not verify_viewer_pin(str(body.get("current_pin", "")).strip()): return jsonify({"error": "current PIN is incorrect"}), 403 clear_viewer_pin() + _audit("viewer_pin_clear", "", ip=request.remote_addr or "") return jsonify({"ok": True}) @@ -200,10 +211,13 @@ def interface_pin_set(): return jsonify({"error": "pin required"}), 400 if not new_pin.isdigit() or not (4 <= len(new_pin) <= 8): return jsonify({"error": "PIN must be 4–8 digits"}), 400 - if get_interface_pin_hash(): + had_ipin = bool(get_interface_pin_hash()) + if had_ipin: if not verify_interface_pin(str(body.get("current_pin", "")).strip()): return jsonify({"error": "current PIN is incorrect"}), 403 set_interface_pin(new_pin) + _audit("interface_pin_change" if had_ipin else "interface_pin_set", "", + ip=request.remote_addr or "") return jsonify({"ok": True}) @@ -215,6 +229,7 @@ def interface_pin_clear(): if not verify_interface_pin(str(body.get("current_pin", "")).strip()): return jsonify({"error": "current PIN is incorrect"}), 403 clear_interface_pin() + _audit("interface_pin_clear", "", ip=request.remote_addr or "") return jsonify({"ok": True}) diff --git a/static/js/sources.js b/static/js/sources.js index 27168a2..fbccd3b 100644 --- a/static/js/sources.js +++ b/static/js/sources.js @@ -237,7 +237,7 @@ function closeSettings() { } function switchSettingsTab(tab) { - ['general','security','scheduler','email','database'].forEach(function(t) { + ['general','security','scheduler','email','database','auditlog'].forEach(function(t) { var cap = t.charAt(0).toUpperCase() + t.slice(1); var pane = document.getElementById('stPane' + cap); var btn = document.getElementById('stTab' + cap); @@ -248,6 +248,32 @@ function switchSettingsTab(tab) { if (tab === 'email') stLoadSmtp(); if (tab === 'database') stLoadDbStats(); if (tab === 'scheduler') schedLoad(); + if (tab === 'auditlog') stLoadAuditLog(); +} + +async function stLoadAuditLog() { + const tbody = document.getElementById('stAuditTableBody'); + if (!tbody) return; + tbody.innerHTML = `${t('m365_audit_loading')}`; + try { + const rows = await fetch('/api/audit_log?limit=200').then(r => r.json()); + if (!Array.isArray(rows) || !rows.length) { + tbody.innerHTML = `${t('m365_audit_empty')}`; + return; + } + tbody.innerHTML = rows.map(function(r) { + const d = new Date(r.ts * 1000); + const ts = d.toLocaleDateString() + ' ' + d.toLocaleTimeString(); + return '' + + '' + window._escHtml(ts) + '' + + '' + window._escHtml(r.action) + '' + + '' + window._escHtml(r.detail) + '' + + '' + window._escHtml(r.ip) + '' + + ''; + }).join(''); + } catch(e) { + tbody.innerHTML = '' + window._escHtml(String(e)) + ''; + } } // ── Window exports (HTML handlers + cross-module calls) ───────────────────── @@ -266,5 +292,6 @@ window.confirmPinPrompt = confirmPinPrompt; window.openSettings = openSettings; window.closeSettings = closeSettings; window.switchSettingsTab = switchSettingsTab; +window.stLoadAuditLog = stLoadAuditLog; window._M365_SOURCES = _M365_SOURCES; window._pinCallback = _pinCallback; diff --git a/static/style.css b/static/style.css index 53d0dca..be2dfc3 100644 --- a/static/style.css +++ b/static/style.css @@ -361,17 +361,17 @@ .settings-backdrop.open { display:flex; } .settings-modal { background:var(--surface); border:1px solid var(--border); - border-radius:10px; width:min(540px,96vw); + border-radius:10px; width:min(640px,96vw); display:flex; flex-direction:column; overflow:hidden; font-size:12px; color:var(--text); } .settings-header { padding:16px 20px 0; display:flex; align-items:center; justify-content:space-between; } .settings-header h2 { font-size:14px; font-weight:700; margin:0; } - .settings-tabs { display:flex; border-bottom:1px solid var(--border); padding:0 20px; margin-top:12px; } + .settings-tabs { display:flex; border-bottom:1px solid var(--border); padding:0 20px; margin-top:12px; flex-wrap:wrap; } .settings-tab { height:36px; padding:0 14px; font-size:12px; cursor:pointer; border:none; background:none; color:var(--muted); border-bottom:2px solid transparent; - margin-bottom:-1px; font-weight:500; + margin-bottom:-1px; font-weight:500; white-space:nowrap; } .settings-tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; } .settings-body { padding:16px 20px; overflow-y:auto; max-height:65vh; display:flex; flex-direction:column; gap:14px; } diff --git a/templates/index.html b/templates/index.html index 344f1f8..b292854 100644 --- a/templates/index.html +++ b/templates/index.html @@ -615,6 +615,7 @@ document.addEventListener('DOMContentLoaded', applyI18n); +
@@ -849,6 +850,28 @@ document.addEventListener('DOMContentLoaded', applyI18n);
+ +
+
+
Compliance Audit Log
+
+ + + + + + + + + + + + +
TimeActionDetailIP
Loading…
+
+
+
+