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:
parent
4ef2dfb352
commit
744813f4ac
@ -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.
|
- **`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
|
### 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.
|
- **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
|
## [1.6.27] — 2026-05-27
|
||||||
|
|||||||
10
CLAUDE.md
10
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.
|
**`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
|
## 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.
|
- **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.
|
||||||
|
|||||||
48
gdpr_db.py
48
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_item ON deletion_log(item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dellog_reason ON deletion_log(reason);
|
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
|
-- Indexes
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_scan ON flagged_items(scan_id);
|
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);
|
CREATE INDEX IF NOT EXISTS idx_items_source ON flagged_items(source_type);
|
||||||
@ -809,6 +820,34 @@ class ScanDB:
|
|||||||
).fetchone()[0] or 0
|
).fetchone()[0] or 0
|
||||||
return {"total": total, "by_reason": by_reason, "cpr_hits_deleted": cpr_deleted}
|
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:
|
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)."""
|
"""Remove a flagged item from the DB (after it has been deleted in M365)."""
|
||||||
c = self._connect()
|
c = self._connect()
|
||||||
@ -1057,6 +1096,15 @@ class ScanDB:
|
|||||||
_db: ScanDB | None = None
|
_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:
|
def get_db(path: Path = DB_PATH) -> ScanDB:
|
||||||
"""Return the module-level ScanDB singleton, creating it if needed."""
|
"""Return the module-level ScanDB singleton, creating it if needed."""
|
||||||
global _db
|
global _db
|
||||||
|
|||||||
@ -675,6 +675,14 @@
|
|||||||
"m365_settings_tab_general": "Generelt",
|
"m365_settings_tab_general": "Generelt",
|
||||||
"m365_settings_tab_email": "E-mailrapport",
|
"m365_settings_tab_email": "E-mailrapport",
|
||||||
"m365_settings_tab_database": "Database",
|
"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_appearance": "Udseende",
|
||||||
"m365_settings_language": "Sprog",
|
"m365_settings_language": "Sprog",
|
||||||
"m365_settings_theme": "Tema",
|
"m365_settings_theme": "Tema",
|
||||||
|
|||||||
@ -675,6 +675,14 @@
|
|||||||
"m365_settings_tab_general": "Allgemein",
|
"m365_settings_tab_general": "Allgemein",
|
||||||
"m365_settings_tab_email": "E-Mail-Bericht",
|
"m365_settings_tab_email": "E-Mail-Bericht",
|
||||||
"m365_settings_tab_database": "Datenbank",
|
"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_appearance": "Erscheinungsbild",
|
||||||
"m365_settings_language": "Sprache",
|
"m365_settings_language": "Sprache",
|
||||||
"m365_settings_theme": "Design",
|
"m365_settings_theme": "Design",
|
||||||
|
|||||||
@ -675,6 +675,14 @@
|
|||||||
"m365_settings_tab_general": "General",
|
"m365_settings_tab_general": "General",
|
||||||
"m365_settings_tab_email": "Email report",
|
"m365_settings_tab_email": "Email report",
|
||||||
"m365_settings_tab_database": "Database",
|
"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_appearance": "Appearance",
|
||||||
"m365_settings_language": "Language",
|
"m365_settings_language": "Language",
|
||||||
"m365_settings_theme": "Theme",
|
"m365_settings_theme": "Theme",
|
||||||
|
|||||||
@ -72,6 +72,18 @@ def get_lang_json():
|
|||||||
return jsonify(state.LANG)
|
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")
|
@bp.route("/manual")
|
||||||
def manual():
|
def manual():
|
||||||
"""Serve the user manual as a styled, printable HTML page.
|
"""Serve the user manual as a styled, printable HTML page.
|
||||||
|
|||||||
@ -11,11 +11,12 @@ from checkpoint import _clear_checkpoint, _DELTA_PATH
|
|||||||
from cpr_detector import _extract_exif, _html_esc, _placeholder_svg
|
from cpr_detector import _extract_exif, _html_esc, _placeholder_svg
|
||||||
|
|
||||||
try:
|
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
|
DB_OK = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
DB_OK = False
|
DB_OK = False
|
||||||
def _get_db(*a, **kw): return None # type: ignore[misc]
|
def _get_db(*a, **kw): return None # type: ignore[misc]
|
||||||
|
def _audit(*a, **kw): pass # type: ignore[misc]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import document_scanner as _ds # noqa: F401
|
import document_scanner as _ds # noqa: F401
|
||||||
@ -140,6 +141,9 @@ def db_set_disposition():
|
|||||||
notes = data.get("notes", ""),
|
notes = data.get("notes", ""),
|
||||||
reviewed_by = data.get("reviewed_by", ""),
|
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"})
|
return jsonify({"status": "saved"})
|
||||||
|
|
||||||
|
|
||||||
@ -160,6 +164,9 @@ def db_set_disposition_bulk():
|
|||||||
legal_basis=data.get("legal_basis", ""),
|
legal_basis=data.get("legal_basis", ""),
|
||||||
notes=data.get("notes", ""),
|
notes=data.get("notes", ""),
|
||||||
reviewed_by=data.get("reviewed_by", ""))
|
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)})
|
return jsonify({"saved": len(item_ids)})
|
||||||
|
|
||||||
|
|
||||||
@ -281,10 +288,13 @@ def admin_pin_set():
|
|||||||
new_pin = data.get("new_pin", "").strip()
|
new_pin = data.get("new_pin", "").strip()
|
||||||
if not new_pin:
|
if not new_pin:
|
||||||
return jsonify({"error": "new_pin required"}), 400
|
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", "")):
|
if not _verify_admin_pin(data.get("current_pin", "")):
|
||||||
return jsonify({"error": "incorrect_pin"}), 403
|
return jsonify({"error": "incorrect_pin"}), 403
|
||||||
_set_admin_pin(new_pin)
|
_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})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,10 @@ from __future__ import annotations
|
|||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from routes import state
|
from routes import state
|
||||||
from app_config import _load_smtp_config, _save_smtp_config
|
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
|
from routes.export import _build_excel_bytes
|
||||||
|
|
||||||
bp = Blueprint("email", __name__)
|
bp = Blueprint("email", __name__)
|
||||||
@ -119,6 +123,7 @@ def smtp_config_save():
|
|||||||
if not data.get("password") and existing.get("password"):
|
if not data.get("password") and existing.get("password"):
|
||||||
data["password"] = existing["password"]
|
data["password"] = existing["password"]
|
||||||
_save_smtp_config(data)
|
_save_smtp_config(data)
|
||||||
|
_audit("smtp_save", f"host={data.get('host','')!r}", ip=request.remote_addr or "")
|
||||||
return jsonify({"status": "saved"})
|
return jsonify({"status": "saved"})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,11 +9,12 @@ from routes import state
|
|||||||
from app_config import _GUID_RE, _resolve_display_name
|
from app_config import _GUID_RE, _resolve_display_name
|
||||||
|
|
||||||
try:
|
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
|
DB_OK = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
DB_OK = False
|
DB_OK = False
|
||||||
def _get_db(*a, **kw): return None # type: ignore[misc]
|
def _get_db(*a, **kw): return None # type: ignore[misc]
|
||||||
|
def _audit(*a, **kw): pass # type: ignore[misc]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from m365_connector import M365PermissionError
|
from m365_connector import M365PermissionError
|
||||||
@ -1191,6 +1192,9 @@ def delete_item():
|
|||||||
reason="manual")
|
reason="manual")
|
||||||
_db.delete_item_record(item_id)
|
_db.delete_item_record(item_id)
|
||||||
except Exception: pass
|
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": True})
|
||||||
return jsonify({"ok": False, "error": "Delete returned unexpected result"})
|
return jsonify({"ok": False, "error": "Delete returned unexpected result"})
|
||||||
except M365PermissionError:
|
except M365PermissionError:
|
||||||
@ -1285,6 +1289,9 @@ def redact_item():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)
|
logger.info("[redact] %s — %d CPR span(s) redacted", path.name, redacted)
|
||||||
return jsonify({"ok": True, "redacted": redacted})
|
return jsonify({"ok": True, "redacted": redacted})
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,10 @@ Scan profiles
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from app_config import _profiles_load, _profile_save, _profile_delete, _profile_get
|
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__)
|
bp = Blueprint("profiles", __name__)
|
||||||
|
|
||||||
@ -21,6 +25,8 @@ def profiles_save():
|
|||||||
if not profile.get("name"):
|
if not profile.get("name"):
|
||||||
return jsonify({"error": "name required"}), 400
|
return jsonify({"error": "name required"}), 400
|
||||||
saved = _profile_save(profile)
|
saved = _profile_save(profile)
|
||||||
|
_audit("profile_save", f"name={profile.get('name')!r}",
|
||||||
|
ip=request.remote_addr or "")
|
||||||
return jsonify({"status": "saved", "profile": saved})
|
return jsonify({"status": "saved", "profile": saved})
|
||||||
|
|
||||||
|
|
||||||
@ -32,6 +38,8 @@ def profiles_delete():
|
|||||||
if not key:
|
if not key:
|
||||||
return jsonify({"error": "name or id required"}), 400
|
return jsonify({"error": "name or id required"}), 400
|
||||||
ok = _profile_delete(key)
|
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"})
|
return jsonify({"status": "deleted" if ok else "not_found"})
|
||||||
|
|
||||||
|
|
||||||
@ -43,5 +51,3 @@ def profiles_get():
|
|||||||
if not p:
|
if not p:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
return jsonify({"profile": p})
|
return jsonify({"profile": p})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,11 @@ from checkpoint import (
|
|||||||
bp = Blueprint("scan", __name__)
|
bp = Blueprint("scan", __name__)
|
||||||
_log = logging.getLogger(__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():
|
def _maybe_send_auto_email():
|
||||||
"""Send the scan report email after a manual scan if auto_email_manual is enabled."""
|
"""Send the scan report email after a manual scan if auto_email_manual is enabled."""
|
||||||
@ -108,6 +113,9 @@ def scan_start():
|
|||||||
finally:
|
finally:
|
||||||
state._scan_lock.release()
|
state._scan_lock.release()
|
||||||
threading.Thread(target=_run, daemon=True).start()
|
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"})
|
return jsonify({"status": "started"})
|
||||||
|
|
||||||
|
|
||||||
@ -115,6 +123,7 @@ def scan_start():
|
|||||||
def scan_stop():
|
def scan_stop():
|
||||||
state._scan_abort.set()
|
state._scan_abort.set()
|
||||||
state._google_scan_abort.set()
|
state._google_scan_abort.set()
|
||||||
|
_audit("scan_stop", "", ip=request.remote_addr or "")
|
||||||
return jsonify({"status": "stopping"})
|
return jsonify({"status": "stopping"})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,10 @@ Scheduler API routes — multi-job CRUD, status, history, run-now.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
import sys, os, threading
|
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__)
|
bp = Blueprint("scheduler", __name__)
|
||||||
|
|
||||||
@ -52,6 +56,9 @@ def scheduler_jobs_save():
|
|||||||
_sched().reload()
|
_sched().reload()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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]})
|
return jsonify({"ok": True, "job": jobs[i]})
|
||||||
# New job
|
# New job
|
||||||
job = sm._new_job(data)
|
job = sm._new_job(data)
|
||||||
@ -61,6 +68,9 @@ def scheduler_jobs_save():
|
|||||||
_sched().reload()
|
_sched().reload()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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})
|
return jsonify({"ok": True, "job": job})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
@ -81,6 +91,7 @@ def scheduler_jobs_delete():
|
|||||||
_sched().reload()
|
_sched().reload()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
_audit("scheduler_job_delete", f"id={job_id!r}", ip=request.remote_addr or "")
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|||||||
@ -8,6 +8,10 @@ from pathlib import Path
|
|||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from routes import state
|
from routes import state
|
||||||
from app_config import _load_file_sources, _save_file_sources, _SFTP_KEYS_DIR
|
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:
|
try:
|
||||||
from file_scanner import store_smb_password, SMB_OK as _SMB_OK
|
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:
|
if s.get("id") == uid:
|
||||||
sources[i] = {**s, **data}
|
sources[i] = {**s, **data}
|
||||||
_save_file_sources(sources)
|
_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]})
|
return jsonify({"ok": True, "source": sources[i]})
|
||||||
data["id"] = data.get("id") or str(_uuid.uuid4())
|
data["id"] = data.get("id") or str(_uuid.uuid4())
|
||||||
sources.append(data)
|
sources.append(data)
|
||||||
_save_file_sources(sources)
|
_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})
|
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)
|
deleted = next((s for s in sources if s.get("id") == uid), None)
|
||||||
sources = [s for s in sources if s.get("id") != uid]
|
sources = [s for s in sources if s.get("id") != uid]
|
||||||
_save_file_sources(sources)
|
_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
|
# Clean up key file if this was an SFTP key-auth source
|
||||||
if deleted and deleted.get("sftp_key_path"):
|
if deleted and deleted.get("sftp_key_path"):
|
||||||
|
|||||||
@ -19,6 +19,10 @@ from app_config import (
|
|||||||
verify_interface_pin,
|
verify_interface_pin,
|
||||||
clear_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__)
|
bp = Blueprint("viewer", __name__)
|
||||||
|
|
||||||
@ -119,6 +123,8 @@ def create_token():
|
|||||||
if valid_to:
|
if valid_to:
|
||||||
scope["valid_to"] = valid_to
|
scope["valid_to"] = valid_to
|
||||||
entry = create_viewer_token(label=label, expires_days=expires_days, scope=scope)
|
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
|
return jsonify(entry), 201
|
||||||
|
|
||||||
|
|
||||||
@ -129,6 +135,7 @@ def delete_token(token: str):
|
|||||||
removed = revoke_viewer_token(token)
|
removed = revoke_viewer_token(token)
|
||||||
if not removed:
|
if not removed:
|
||||||
return jsonify({"error": "token not found"}), 404
|
return jsonify({"error": "token not found"}), 404
|
||||||
|
_audit("token_revoke", f"token={token[:8]}...", ip=request.remote_addr or "")
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@ -162,10 +169,13 @@ def pin_set():
|
|||||||
return jsonify({"error": "pin required"}), 400
|
return jsonify({"error": "pin required"}), 400
|
||||||
if not new_pin.isdigit() or not (4 <= len(new_pin) <= 8):
|
if not new_pin.isdigit() or not (4 <= len(new_pin) <= 8):
|
||||||
return jsonify({"error": "PIN must be 4–8 digits"}), 400
|
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()):
|
if not verify_viewer_pin(str(body.get("current_pin", "")).strip()):
|
||||||
return jsonify({"error": "current PIN is incorrect"}), 403
|
return jsonify({"error": "current PIN is incorrect"}), 403
|
||||||
set_viewer_pin(new_pin)
|
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})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@ -177,6 +187,7 @@ def pin_clear():
|
|||||||
if not verify_viewer_pin(str(body.get("current_pin", "")).strip()):
|
if not verify_viewer_pin(str(body.get("current_pin", "")).strip()):
|
||||||
return jsonify({"error": "current PIN is incorrect"}), 403
|
return jsonify({"error": "current PIN is incorrect"}), 403
|
||||||
clear_viewer_pin()
|
clear_viewer_pin()
|
||||||
|
_audit("viewer_pin_clear", "", ip=request.remote_addr or "")
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@ -200,10 +211,13 @@ def interface_pin_set():
|
|||||||
return jsonify({"error": "pin required"}), 400
|
return jsonify({"error": "pin required"}), 400
|
||||||
if not new_pin.isdigit() or not (4 <= len(new_pin) <= 8):
|
if not new_pin.isdigit() or not (4 <= len(new_pin) <= 8):
|
||||||
return jsonify({"error": "PIN must be 4–8 digits"}), 400
|
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()):
|
if not verify_interface_pin(str(body.get("current_pin", "")).strip()):
|
||||||
return jsonify({"error": "current PIN is incorrect"}), 403
|
return jsonify({"error": "current PIN is incorrect"}), 403
|
||||||
set_interface_pin(new_pin)
|
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})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@ -215,6 +229,7 @@ def interface_pin_clear():
|
|||||||
if not verify_interface_pin(str(body.get("current_pin", "")).strip()):
|
if not verify_interface_pin(str(body.get("current_pin", "")).strip()):
|
||||||
return jsonify({"error": "current PIN is incorrect"}), 403
|
return jsonify({"error": "current PIN is incorrect"}), 403
|
||||||
clear_interface_pin()
|
clear_interface_pin()
|
||||||
|
_audit("interface_pin_clear", "", ip=request.remote_addr or "")
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -237,7 +237,7 @@ function closeSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function switchSettingsTab(tab) {
|
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 cap = t.charAt(0).toUpperCase() + t.slice(1);
|
||||||
var pane = document.getElementById('stPane' + cap);
|
var pane = document.getElementById('stPane' + cap);
|
||||||
var btn = document.getElementById('stTab' + cap);
|
var btn = document.getElementById('stTab' + cap);
|
||||||
@ -248,6 +248,32 @@ function switchSettingsTab(tab) {
|
|||||||
if (tab === 'email') stLoadSmtp();
|
if (tab === 'email') stLoadSmtp();
|
||||||
if (tab === 'database') stLoadDbStats();
|
if (tab === 'database') stLoadDbStats();
|
||||||
if (tab === 'scheduler') schedLoad();
|
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) ─────────────────────
|
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
|
||||||
@ -266,5 +292,6 @@ window.confirmPinPrompt = confirmPinPrompt;
|
|||||||
window.openSettings = openSettings;
|
window.openSettings = openSettings;
|
||||||
window.closeSettings = closeSettings;
|
window.closeSettings = closeSettings;
|
||||||
window.switchSettingsTab = switchSettingsTab;
|
window.switchSettingsTab = switchSettingsTab;
|
||||||
|
window.stLoadAuditLog = stLoadAuditLog;
|
||||||
window._M365_SOURCES = _M365_SOURCES;
|
window._M365_SOURCES = _M365_SOURCES;
|
||||||
window._pinCallback = _pinCallback;
|
window._pinCallback = _pinCallback;
|
||||||
|
|||||||
@ -361,17 +361,17 @@
|
|||||||
.settings-backdrop.open { display:flex; }
|
.settings-backdrop.open { display:flex; }
|
||||||
.settings-modal {
|
.settings-modal {
|
||||||
background:var(--surface); border:1px solid var(--border);
|
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;
|
display:flex; flex-direction:column; overflow:hidden;
|
||||||
font-size:12px; color:var(--text);
|
font-size:12px; color:var(--text);
|
||||||
}
|
}
|
||||||
.settings-header { padding:16px 20px 0; display:flex; align-items:center; justify-content:space-between; }
|
.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-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 {
|
.settings-tab {
|
||||||
height:36px; padding:0 14px; font-size:12px; cursor:pointer; border:none;
|
height:36px; padding:0 14px; font-size:12px; cursor:pointer; border:none;
|
||||||
background:none; color:var(--muted); border-bottom:2px solid transparent;
|
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-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; }
|
.settings-body { padding:16px 20px; overflow-y:auto; max-height:65vh; display:flex; flex-direction:column; gap:14px; }
|
||||||
|
|||||||
@ -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="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="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="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>
|
||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
|
|
||||||
@ -849,6 +850,28 @@ document.addEventListener('DOMContentLoaded', applyI18n);
|
|||||||
</div>
|
</div>
|
||||||
</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><!-- /.settings-body -->
|
||||||
<div class="settings-footer">
|
<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>
|
<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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user