Add compliance audit log

Immutable audit_log table in the scanner DB records every significant
admin action (profile save/delete, token create/revoke, PIN changes,
source add/update/delete, scheduler job changes, scan start/stop, SMTP
save, dispositions, item delete/redact). GET /api/audit_log exposes
entries newest-first. New Audit Log tab in the Settings modal renders
the table on demand. Settings modal widened 540→640 px and tab labels
set to white-space:nowrap so the six-tab row fits on one line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
StyxX65 2026-05-28 10:51:23 +02:00
parent 4ef2dfb352
commit 744813f4ac
18 changed files with 236 additions and 11 deletions

View File

@ -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

View File

@ -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=<filter>`** — 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.

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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.

View File

@ -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})

View File

@ -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"})

View File

@ -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})

View File

@ -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})

View File

@ -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"})

View File

@ -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

View File

@ -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"):

View File

@ -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 48 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 48 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})

View File

@ -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 = `<tr><td colspan="4" style="padding:8px;color:var(--muted)">${t('m365_audit_loading')}</td></tr>`;
try {
const rows = await fetch('/api/audit_log?limit=200').then(r => r.json());
if (!Array.isArray(rows) || !rows.length) {
tbody.innerHTML = `<tr><td colspan="4" style="padding:8px;color:var(--muted)">${t('m365_audit_empty')}</td></tr>`;
return;
}
tbody.innerHTML = rows.map(function(r) {
const d = new Date(r.ts * 1000);
const ts = d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
return '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:4px 8px;white-space:nowrap;color:var(--muted);font-size:11px">' + window._escHtml(ts) + '</td>'
+ '<td style="padding:4px 8px"><span style="font-family:monospace;background:var(--bg);border:1px solid var(--border);border-radius:3px;padding:1px 4px;font-size:11px">' + window._escHtml(r.action) + '</span></td>'
+ '<td style="padding:4px 8px;color:var(--text);font-size:12px">' + window._escHtml(r.detail) + '</td>'
+ '<td style="padding:4px 8px;color:var(--muted);font-size:11px">' + window._escHtml(r.ip) + '</td>'
+ '</tr>';
}).join('');
} catch(e) {
tbody.innerHTML = '<tr><td colspan="4" style="padding:8px;color:var(--danger)">' + window._escHtml(String(e)) + '</td></tr>';
}
}
// ── 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;

View File

@ -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; }

View File

@ -615,6 +615,7 @@ document.addEventListener('DOMContentLoaded', applyI18n);
<button class="settings-tab" id="stTabScheduler" onclick="switchSettingsTab('scheduler')" data-i18n="m365_settings_tab_scheduler">Scheduler</button>
<button class="settings-tab" id="stTabEmail" onclick="switchSettingsTab('email')" data-i18n="m365_settings_tab_email">Email report</button>
<button class="settings-tab" id="stTabDatabase" onclick="switchSettingsTab('database')" data-i18n="m365_settings_tab_database">Database</button>
<button class="settings-tab" id="stTabAuditlog" onclick="switchSettingsTab('auditlog')" data-i18n="m365_settings_tab_auditlog">Audit Log</button>
</div>
<div class="settings-body">
@ -849,6 +850,28 @@ document.addEventListener('DOMContentLoaded', applyI18n);
</div>
</div>
<!-- ── Audit Log pane ─────────────────────────────────────────────────── -->
<div class="settings-pane" id="stPaneAuditlog">
<div class="settings-group">
<div class="settings-group-title" data-i18n="m365_audit_title">Compliance Audit Log</div>
<div style="overflow-x:auto">
<table id="stAuditTable" style="width:100%;border-collapse:collapse;font-size:12px">
<thead>
<tr style="text-align:left">
<th style="padding:4px 8px;border-bottom:1px solid var(--border);color:var(--muted);font-weight:500" data-i18n="m365_audit_col_time">Time</th>
<th style="padding:4px 8px;border-bottom:1px solid var(--border);color:var(--muted);font-weight:500" data-i18n="m365_audit_col_action">Action</th>
<th style="padding:4px 8px;border-bottom:1px solid var(--border);color:var(--muted);font-weight:500" data-i18n="m365_audit_col_detail">Detail</th>
<th style="padding:4px 8px;border-bottom:1px solid var(--border);color:var(--muted);font-weight:500" data-i18n="m365_audit_col_ip">IP</th>
</tr>
</thead>
<tbody id="stAuditTableBody">
<tr><td colspan="4" style="padding:8px;color:var(--muted)" data-i18n="m365_audit_loading">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div><!-- /.settings-body -->
<div class="settings-footer">
<button onclick="closeSettings()" style="background:none;border:1px solid var(--border);color:var(--muted);height:26px;padding:0 14px;border-radius:6px;font-size:12px;cursor:pointer;box-sizing:border-box" data-i18n="btn_close">Close</button>