From 318fdc4812a434f7fbb01ead8b6777d1c02265a0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 07:32:41 +0200 Subject: [PATCH] feat(audit): admin audit log (Notification P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append-only who/what/when trail for admin actions + scheduler run summaries, generalizing QuotaEvent. Logged at ACTION / run-summary granularity — never per-row (a scheduler run touching thousands of videos is ONE row). Backend: - migration 0054_audit_log + AuditLog model (actor_id FK SET NULL, action, target_type/id, summary, before/after JSON, created_at). - app/audit.py: AuditAction constants (single source of truth, i18n'd on the client) + record() (adds to the caller's txn) + gc(retention_days). - producers wired: role change / suspend / delete / invite approve-deny-add / demo add-remove-reset / discovery purge (admin.py); config set/reset — secret VALUES never logged, key only (config.py); scheduler interval + maintenance tune (scheduler routes); sync pause/resume (sync.py); and the scheduler _job() choke point writes ONE run-summary row per run — manual runs + errors always, automatic runs only when they changed something (no no-op-poll spam). - retention: audit_gc scheduler job prunes rows older than audit_retention_days (sysconfig, default 180; 0 = keep forever). - admin API routes/audit.py (/api/admin/audit): recent-window list (actor emails resolved, System for background) + clear (leaves one tamper-evidence row). Frontend: - new admin-only "Audit log" nav page (ScrollText) using the shared DataTable (first admin page to reuse it) — sort/filter/paginate/persist, action select filter, actor text filter, before→after detail, Clear-all with confirm. - AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type. - trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group + scheduler job labels; Audit config group (retention days). --- backend/alembic/versions/0054_audit_log.py | 51 +++++++++++ backend/app/audit.py | 83 +++++++++++++++++ backend/app/config.py | 3 + backend/app/main.py | 2 + backend/app/models.py | 24 +++++ backend/app/routes/admin.py | 49 +++++++++- backend/app/routes/audit.py | 71 +++++++++++++++ backend/app/routes/config.py | 26 +++++- backend/app/routes/scheduler.py | 17 +++- backend/app/routes/sync.py | 11 ++- backend/app/scheduler.py | 47 +++++++++- backend/app/sysconfig.py | 3 + frontend/src/App.tsx | 3 + frontend/src/components/AuditLog.tsx | 90 ++++++++++++++++++ frontend/src/components/ConfigPanel.tsx | 2 +- frontend/src/components/NavSidebar.tsx | 2 + frontend/src/components/auditColumns.tsx | 91 +++++++++++++++++++ frontend/src/i18n/locales/de/audit.json | 18 ++++ .../src/i18n/locales/de/auditActions.json | 21 +++++ frontend/src/i18n/locales/de/config.json | 3 +- frontend/src/i18n/locales/de/header.json | 3 +- frontend/src/i18n/locales/de/scheduler.json | 6 +- frontend/src/i18n/locales/en/audit.json | 18 ++++ .../src/i18n/locales/en/auditActions.json | 21 +++++ frontend/src/i18n/locales/en/config.json | 3 +- frontend/src/i18n/locales/en/header.json | 3 +- frontend/src/i18n/locales/en/scheduler.json | 6 +- frontend/src/i18n/locales/hu/audit.json | 18 ++++ .../src/i18n/locales/hu/auditActions.json | 21 +++++ frontend/src/i18n/locales/hu/config.json | 3 +- frontend/src/i18n/locales/hu/header.json | 3 +- frontend/src/i18n/locales/hu/scheduler.json | 6 +- frontend/src/lib/api.ts | 19 ++++ frontend/src/lib/storage.ts | 1 + frontend/src/lib/urlState.ts | 1 + 35 files changed, 720 insertions(+), 29 deletions(-) create mode 100644 backend/alembic/versions/0054_audit_log.py create mode 100644 backend/app/audit.py create mode 100644 backend/app/routes/audit.py create mode 100644 frontend/src/components/AuditLog.tsx create mode 100644 frontend/src/components/auditColumns.tsx create mode 100644 frontend/src/i18n/locales/de/audit.json create mode 100644 frontend/src/i18n/locales/de/auditActions.json create mode 100644 frontend/src/i18n/locales/en/audit.json create mode 100644 frontend/src/i18n/locales/en/auditActions.json create mode 100644 frontend/src/i18n/locales/hu/audit.json create mode 100644 frontend/src/i18n/locales/hu/auditActions.json diff --git a/backend/alembic/versions/0054_audit_log.py b/backend/alembic/versions/0054_audit_log.py new file mode 100644 index 0000000..5f83938 --- /dev/null +++ b/backend/alembic/versions/0054_audit_log.py @@ -0,0 +1,51 @@ +"""audit_log: append-only admin/scheduler audit trail (who/what/when/before->after) + +Revision ID: 0054_audit_log +Revises: 0053_user_session_epoch +Create Date: 2026-07-12 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0054_audit_log" +down_revision: Union[str, None] = "0053_user_session_epoch" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "audit_log", + sa.Column("id", sa.Integer(), primary_key=True), + # SET NULL on user delete: keep the trail (it just becomes system-attributed). + sa.Column( + "actor_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("action", sa.String(length=48), nullable=False), + sa.Column("target_type", sa.String(length=32), nullable=True), + sa.Column("target_id", sa.String(length=128), nullable=True), + sa.Column("summary", sa.String(length=255), nullable=True), + sa.Column("before", sa.JSON(), nullable=True), + sa.Column("after", sa.JSON(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index("ix_audit_log_actor_id", "audit_log", ["actor_id"]) + op.create_index("ix_audit_log_action", "audit_log", ["action"]) + op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_log_created_at", table_name="audit_log") + op.drop_index("ix_audit_log_action", table_name="audit_log") + op.drop_index("ix_audit_log_actor_id", table_name="audit_log") + op.drop_table("audit_log") diff --git a/backend/app/audit.py b/backend/app/audit.py new file mode 100644 index 0000000..485bc81 --- /dev/null +++ b/backend/app/audit.py @@ -0,0 +1,83 @@ +"""Admin/scheduler audit trail: an append-only record of who changed what and when. + +Design (locked): log at ACTION / job-run granularity, NEVER per-row — a scheduler run that +touches thousands of videos is ONE audit row summarising the outcome. Automatic job runs are +only logged when they actually changed something (or errored); all admin actions and all manual +runs are always logged. Secrets are never stored (config changes log the key name only). + +`record()` only db.add()s the row — the caller commits it in the SAME transaction as the +mutation it describes, so the trail can't drift from reality (and a rolled-back mutation drops +its audit row too). +""" +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete +from sqlalchemy.orm import Session + +from app.models import AuditLog + + +class AuditAction: + """Canonical `_` audit keys — the single source of truth. User-facing labels + are resolved on the client via i18n `auditActions.` (mirrors quota.QuotaAction).""" + + # User / roles / access + USER_ROLE_CHANGE = "user_role_change" + USER_SUSPEND = "user_suspend" + USER_UNSUSPEND = "user_unsuspend" + USER_DELETE = "user_delete" + INVITE_APPROVE = "invite_approve" + INVITE_DENY = "invite_deny" + INVITE_ADD = "invite_add" + DEMO_ADD = "demo_add" + DEMO_REMOVE = "demo_remove" + DEMO_RESET = "demo_reset" + DISCOVERY_PURGE = "discovery_purge" + # Config + CONFIG_SET = "config_set" + CONFIG_RESET = "config_reset" + # Scheduler / sync + SCHEDULER_INTERVAL = "scheduler_interval" + MAINTENANCE_TUNE = "maintenance_tune" + SYNC_PAUSE = "sync_pause" + SYNC_RESUME = "sync_resume" + # Scheduler job outcome / run-summary (actor_id set = a manual "run now"; NULL = automatic) + JOB_RUN = "job_run" + # The log itself was cleared (a single row survives the clear as tamper-evidence) + AUDIT_CLEAR = "audit_clear" + + +def record( + db: Session, + actor_id: int | None, + action: str, + *, + target_type: str | None = None, + target_id: str | int | None = None, + summary: str | None = None, + before: dict | None = None, + after: dict | None = None, +) -> None: + """Append an audit row (NOT committed — the caller commits it with the mutation).""" + db.add( + AuditLog( + actor_id=actor_id, + action=action, + target_type=target_type, + target_id=None if target_id is None else str(target_id), + summary=(summary or None) and summary[:255], + before=before, + after=after, + ) + ) + + +def gc(db: Session, retention_days: int) -> int: + """Delete audit rows older than `retention_days`. Returns the number removed. A + retention_days <= 0 disables pruning (keep forever).""" + if retention_days <= 0: + return 0 + cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) + result = db.execute(delete(AuditLog).where(AuditLog.created_at < cutoff)) + db.commit() + return result.rowcount or 0 diff --git a/backend/app/config.py b/backend/app/config.py index a4d6c10..78bb9a6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -185,6 +185,9 @@ class Settings(BaseSettings): download_retention_days: int = 30 download_gc_grace_days: int = 2 download_gc_minutes: int = 360 + # Admin audit log: how long to keep rows (days; 0 = keep forever) + how often the GC runs. + audit_retention_days: int = 180 + audit_gc_minutes: int = 1440 # On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat". download_layout: str = "plex" # Default SponsorBlock (skip sponsor segments) for new custom profiles. diff --git a/backend/app/main.py b/backend/app/main.py index 772888a..41f8a19 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -29,6 +29,7 @@ from app.config import settings from app.db import SessionLocal from app.routes import ( admin, + audit as audit_routes, channels, config as config_routes, downloads, @@ -146,6 +147,7 @@ app.include_router(public_routes.router) app.include_router(admin.router) app.include_router(config_routes.router) app.include_router(scheduler_routes.router) +app.include_router(audit_routes.router) app.include_router(quota.router) app.include_router(version.router) app.include_router(setup_routes.router) diff --git a/backend/app/models.py b/backend/app/models.py index 9b31e67..6e40d5b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -495,6 +495,30 @@ class QuotaEvent(Base): ) +class AuditLog(Base): + """Append-only admin/scheduler audit trail: who did what, when, and (where it applies) + the before->after values. `actor_id` is the acting admin; NULL means the scheduler / + background did it (SET NULL on user delete keeps the trail). Logged at ACTION / job-run + granularity — never per-row — so a scheduler run that touches thousands of videos is ONE + row summarising the outcome. `before`/`after` are small JSON snapshots (secrets excluded).""" + + __tablename__ = "audit_log" + + id: Mapped[int] = mapped_column(primary_key=True) + actor_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), index=True + ) + action: Mapped[str] = mapped_column(String(48), index=True) + target_type: Mapped[str | None] = mapped_column(String(32)) + target_id: Mapped[str | None] = mapped_column(String(128)) + summary: Mapped[str | None] = mapped_column(String(255)) + before: Mapped[dict | None] = mapped_column(JSON) + after: Mapped[dict | None] = mapped_column(JSON) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + + class AppState(Base): """Single-row global app state (admin-controlled).""" diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 3edd262..c507c6c 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -6,7 +6,9 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from sqlalchemy import delete, func, select from sqlalchemy.orm import Session +from app import audit from app import email as email_mod +from app.audit import AuditAction from app.auth import ( admin_user, count_admins, @@ -74,6 +76,14 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite: inv.status = "approved" if approved else "denied" inv.decided_at = datetime.now(timezone.utc) inv.decided_by = admin.email + audit.record( + db, + admin.id, + AuditAction.INVITE_APPROVE if approved else AuditAction.INVITE_DENY, + target_type="invite", + target_id=inv.id, + summary=inv.email, + ) db.commit() return inv @@ -117,6 +127,7 @@ def add_invite( inv.status = "approved" inv.decided_at = datetime.now(timezone.utc) inv.decided_by = admin.email + audit.record(db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email) db.commit() _activate_user_by_email(db, email) return _serialize(inv) @@ -174,6 +185,12 @@ def set_user_role( ): raise HTTPException(status_code=400, detail="Can't remove the last admin.") changed = target.role != role + if changed: + audit.record( + db, admin.id, AuditAction.USER_ROLE_CHANGE, + target_type="user", target_id=target.id, summary=target.email, + before={"role": target.role}, after={"role": role}, + ) target.role = role db.commit() if changed: @@ -210,6 +227,12 @@ def set_user_suspended( ): raise HTTPException(status_code=400, detail="Can't suspend the last active admin.") was_suspended = target.is_suspended + if was_suspended != suspended: + audit.record( + db, admin.id, + AuditAction.USER_SUSPEND if suspended else AuditAction.USER_UNSUSPEND, + target_type="user", target_id=target.id, summary=target.email, + ) target.is_suspended = suspended db.commit() if was_suspended and not suspended: @@ -240,6 +263,11 @@ def admin_delete_user( ) if target.role == "admin" and not target.is_suspended and count_admins(db, active_only=True) <= 1: raise HTTPException(status_code=400, detail="Can't delete the last admin.") + # Record BEFORE the purge — the target row (and its email) is gone afterwards. + audit.record( + db, admin.id, AuditAction.USER_DELETE, + target_type="user", target_id=target.id, summary=target.email, + ) purge_user(db, target, background) return {"deleted": user_id} @@ -289,6 +317,7 @@ def add_demo_whitelist( added_by=admin.email, ) db.add(row) + audit.record(db, admin.id, AuditAction.DEMO_ADD, target_type="demo", target_id=email, summary=email) db.commit() return _serialize_demo(row) @@ -296,11 +325,12 @@ def add_demo_whitelist( @router.delete("/demo/whitelist/{entry_id}") def remove_demo_whitelist( entry_id: int, - _: User = Depends(admin_user), + admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: row = db.get(DemoWhitelist, entry_id) if row is not None: + audit.record(db, admin.id, AuditAction.DEMO_REMOVE, target_type="demo", target_id=row.email, summary=row.email) db.delete(row) db.commit() return {"deleted": entry_id} @@ -375,21 +405,30 @@ def reset_demo_state(db: Session, demo: User) -> int: @router.post("/demo/reset") def reset_demo( - _: User = Depends(admin_user), db: Session = Depends(get_db) + admin: User = Depends(admin_user), db: Session = Depends(get_db) ) -> dict: """Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the same automatically (admin-tunable interval); this is the admin's on-demand button.""" demo = get_or_create_demo_user(db) - return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)} + seeded = reset_demo_state(db, demo) + audit.record(db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded") + db.commit() + return {"reset": True, "playlists_seeded": seeded} @router.post("/purge-discovery") def purge_discovery( - _: User = Depends(admin_user), db: Session = Depends(get_db) + admin: User = Depends(admin_user), db: Session = Depends(get_db) ) -> dict: """Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result videos nobody watched/saved/subscribed to, plus explored-but-unsubscribed channels. The scheduled discovery-cleanup job does the same on its interval; this is the on-demand button.""" from app.sync.explore import purge_unkept_explored, purge_unkept_search - return {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)} + result = {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)} + audit.record( + db, admin.id, AuditAction.DISCOVERY_PURGE, + summary=", ".join(f"{k}: {v}" for k, v in result.items()) or None, after=result, + ) + db.commit() + return result diff --git a/backend/app/routes/audit.py b/backend/app/routes/audit.py new file mode 100644 index 0000000..206a32c --- /dev/null +++ b/backend/app/routes/audit.py @@ -0,0 +1,71 @@ +"""Admin audit log API: read the append-only who/what/when trail, and clear it. + +Admin-only. The client renders these rows in a DataTable (client-side sort/filter/paginate), so +this returns the most recent window of rows (capped) plus the true total, rather than paging +server-side — the log stays small in practice (state-changing runs + admin actions only).""" +from fastapi import APIRouter, Depends +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session + +from app import audit +from app.audit import AuditAction +from app.db import get_db +from app.models import AuditLog, User +from app.routes.admin import admin_user + +router = APIRouter(prefix="/api/admin/audit", tags=["admin"]) + +# Cap the client-side window. The audit log is small by design; if it ever outgrows this we'd +# switch the DataTable to server-side paging. +MAX_ROWS = 2000 + + +def _serialize(row: AuditLog, actors: dict[int, str]) -> dict: + return { + "id": row.id, + "created_at": row.created_at.isoformat() if row.created_at else None, + # actor_id NULL = the scheduler/background did it (client shows "System"); a set id that + # can't be resolved would mean a deleted user, but SET NULL makes that NULL — so present. + "actor": actors.get(row.actor_id) if row.actor_id is not None else None, + "action": row.action, + "target_type": row.target_type, + "target_id": row.target_id, + "summary": row.summary, + "before": row.before, + "after": row.after, + } + + +@router.get("") +def list_audit( + limit: int = 500, + _: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + limit = max(1, min(limit, MAX_ROWS)) + total = db.scalar(select(func.count()).select_from(AuditLog)) or 0 + rows = ( + db.execute(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit)) + .scalars() + .all() + ) + actor_ids = {r.actor_id for r in rows if r.actor_id is not None} + actors: dict[int, str] = {} + if actor_ids: + actors = dict( + db.execute(select(User.id, User.email).where(User.id.in_(actor_ids))).all() + ) + return { + "items": [_serialize(r, actors) for r in rows], + "total": total, + "returned": len(rows), + } + + +@router.delete("") +def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: + """Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence).""" + n = db.execute(delete(AuditLog)).rowcount or 0 + audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries") + db.commit() + return {"cleared": n} diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py index 3d05345..38b5c09 100644 --- a/backend/app/routes/config.py +++ b/backend/app/routes/config.py @@ -3,8 +3,10 @@ app.sysconfig. Secret values (e.g. SMTP password) are write-only — never retur from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session +from app import audit from app import email as email_mod from app import sysconfig +from app.audit import AuditAction from app.db import get_db from app.models import User from app.routes.admin import admin_user @@ -21,7 +23,7 @@ def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> def set_config( key: str, payload: dict, - _: User = Depends(admin_user), + admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: s = sysconfig.spec(key) @@ -34,22 +36,40 @@ def set_config( status_code=400, detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.", ) + old = None if s.secret else sysconfig.get(db, key) try: sysconfig.set_value(db, key, payload["value"]) except (TypeError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc) or "Invalid value") + # Never log a secret's value — only that it was changed, by whom. + new = None if s.secret else sysconfig.get(db, key) + audit.record( + db, admin.id, AuditAction.CONFIG_SET, target_type="config", target_id=key, + summary=f"{key} = (secret updated)" if s.secret else f"{key} = {new}", + before=None if s.secret else {"value": old}, + after=None if s.secret else {"value": new}, + ) + db.commit() return sysconfig.describe(db) @router.delete("/{key}") def reset_config( key: str, - _: User = Depends(admin_user), + admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: - if sysconfig.spec(key) is None: + s = sysconfig.spec(key) + if s is None: raise HTTPException(status_code=404, detail="Unknown config key") + old = None if s.secret else sysconfig.get(db, key) sysconfig.reset(db, key) + audit.record( + db, admin.id, AuditAction.CONFIG_RESET, target_type="config", target_id=key, + summary=f"{key} → default", + before=None if s.secret else {"value": old}, + ) + db.commit() return sysconfig.describe(db) diff --git a/backend/app/routes/scheduler.py b/backend/app/routes/scheduler.py index e88b1db..eedbcd9 100644 --- a/backend/app/routes/scheduler.py +++ b/backend/app/routes/scheduler.py @@ -5,7 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import and_, func, or_, select from sqlalchemy.orm import Session -from app import quota, state, sysconfig +from app import audit, quota, state, sysconfig +from app.audit import AuditAction from app.config import settings from app.db import get_db from app.models import Channel, Subscription, User, Video @@ -28,7 +29,7 @@ router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"]) def set_job_interval( job_id: str, payload: dict, - _: User = Depends(admin_user), + admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Change how often a scheduler job runs (minutes). Persisted as an override and applied @@ -45,6 +46,11 @@ def set_job_interval( detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes", ) applied = apply_interval(db, job_id, minutes) + audit.record( + db, admin.id, AuditAction.SCHEDULER_INTERVAL, target_type="job", target_id=job_id, + summary=f"{job_id} → {applied} min/run", after={"interval_minutes": applied}, + ) + db.commit() return {"id": job_id, "interval_minutes": applied} @@ -74,7 +80,7 @@ def run_all_now(user: User = Depends(admin_user)) -> dict: @router.patch("/maintenance") def set_maintenance_settings( payload: dict, - _: User = Depends(admin_user), + admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per @@ -91,7 +97,10 @@ def set_maintenance_settings( f"{state.MAINTENANCE_BATCH_MAX}" ), ) - return {"revalidate_batch": state.set_maintenance_batch(db, value)} + new = state.set_maintenance_batch(db, value) + audit.record(db, admin.id, AuditAction.MAINTENANCE_TUNE, summary=f"revalidate batch = {new}", after={"revalidate_batch": new}) + db.commit() + return {"revalidate_batch": new} @router.get("") diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index 0ee9f4e..9cfa522 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import and_, case, func, select, update from sqlalchemy.orm import Session -from app import quota, state +from app import audit, quota, state +from app.audit import AuditAction from app.auth import admin_user, current_user, has_read_scope, require_human from app.db import get_db from app.models import Channel, Subscription, User, Video @@ -188,12 +189,16 @@ def deep_all( @router.post("/pause") -def pause_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: +def pause_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: state.set_sync_paused(db, True) + audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused") + db.commit() return {"paused": True} @router.post("/resume") -def resume_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: +def resume_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: state.set_sync_paused(db, False) + audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed") + db.commit() return {"paused": False} diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index e5e97b2..62d1599 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -9,7 +9,8 @@ from typing import Callable from apscheduler.schedulers.background import BackgroundScheduler from sqlalchemy import select -from app import progress, quota +from app import audit, progress, quota +from app.audit import AuditAction from app.config import settings from app.db import SessionLocal from app.downloads.gc import run_download_gc @@ -66,6 +67,7 @@ JOB_INTERVALS: dict[str, int] = { "plex_sync": settings.plex_sync_interval_min, "plex_watch_sync": settings.plex_watch_sync_interval_min, "plex_watch_reconcile": settings.plex_watch_reconcile_interval_min, + "audit_gc": settings.audit_gc_minutes, } # Sane bounds for an admin-set interval (minutes). @@ -127,6 +129,35 @@ def _notify_done(db, actor_id: int, job_id: str, status: str, summary: str | Non logger.exception("failed to write completion notification for job %s", job_id) +def _run_changed(result) -> bool: + """Did an automatic job run actually do something worth an audit row? (No-op polls don't.)""" + if result is None: + return False + if isinstance(result, dict): + return any(bool(v) for v in result.values()) + if isinstance(result, (int, float)): + return result != 0 + return bool(result) + + +def _audit_run(db, actor_id, job_id, status, summary, result) -> None: + """Write ONE run-summary audit row per job run (never per-item). Manual runs (an admin + clicked "run now" → actor_id set) and errors are always logged; automatic runs only when + they changed something, so routine no-op polls don't spam the trail. Best-effort.""" + if actor_id is None and status == "ok" and not _run_changed(result): + return + try: + audit.record( + db, actor_id, AuditAction.JOB_RUN, target_type="job", target_id=job_id, + summary=f"{job_id}: {status}" + (f" — {summary}" if summary else ""), + after={"status": status, "summary": summary}, + ) + db.commit() + except Exception: + db.rollback() + logger.exception("failed to write audit row for job %s", job_id) + + def _job(name: str, fn) -> None: db = SessionLocal() actor_id = _manual_actor.get() # set only for a manual "run now" trigger @@ -142,6 +173,7 @@ def _job(name: str, fn) -> None: last_result="paused", progress=None) if actor_id is not None: _notify_done(db, actor_id, name, "skipped", "sync paused") + _audit_run(db, actor_id, name, "skipped", "sync paused", None) return with progress.bind(sink): result = fn(db) @@ -151,6 +183,7 @@ def _job(name: str, fn) -> None: last_result=summary, progress=None) if actor_id is not None: _notify_done(db, actor_id, name, "ok", summary) + _audit_run(db, actor_id, name, "ok", summary, result) except Exception as exc: db.rollback() logger.exception("job %s failed", name) @@ -159,6 +192,7 @@ def _job(name: str, fn) -> None: last_error=err, progress=None) if actor_id is not None: _notify_done(db, actor_id, name, "error", err) + _audit_run(db, actor_id, name, "error", err, None) finally: db.close() @@ -307,6 +341,16 @@ def _plex_watch_reconcile_job() -> None: _job("plex_watch_reconcile", run_plex_watch_reconcile) +def _audit_gc_job() -> None: + # Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep + # forever). Pure DB, no quota. + def work(db): + from app import sysconfig + return {"reaped": audit.gc(db, sysconfig.get_int(db, "audit_retention_days"))} + + _job("audit_gc", work) + + # job_id -> wrapper. The single source of truth for which jobs exist and how to run one, # shared by start_scheduler (recurring registration) and trigger_job (manual "run now"). JOB_FUNCS: dict[str, Callable[[], None]] = { @@ -324,6 +368,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = { "plex_sync": _plex_sync_job, "plex_watch_sync": _plex_watch_sync_job, "plex_watch_reconcile": _plex_watch_reconcile_job, + "audit_gc": _audit_gc_job, } diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index 8264fb5..0c8d66c 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -77,6 +77,9 @@ SPECS: tuple[ConfigSpec, ...] = ( ConfigSpec("download_layout", "str", "downloads", "download_layout"), ConfigSpec("download_worker_concurrency", "int", "downloads", "download_worker_concurrency", min=1, max=16), ConfigSpec("sponsorblock_default", "bool", "downloads", "sponsorblock_default"), + # --- Audit log --- + # Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever). + ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650), # --- Plex integration (optional; local-file playback + Plex metadata) --- ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"), ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a498575..4f0892a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -73,6 +73,7 @@ const Stats = lazy(() => import("./components/Stats")); const Scheduler = lazy(() => import("./components/Scheduler")); const ConfigPanel = lazy(() => import("./components/ConfigPanel")); const AdminUsers = lazy(() => import("./components/AdminUsers")); +const AuditLog = lazy(() => import("./components/AuditLog")); const SettingsPanel = lazy(() => import("./components/SettingsPanel")); const NotificationsPanel = lazy(() => import("./components/NotificationsPanel")); const Messages = lazy(() => import("./components/Messages")); @@ -828,6 +829,8 @@ export default function App() { ) : page === "users" && meQuery.data!.role === "admin" ? ( + ) : page === "audit" && meQuery.data!.role === "admin" ? ( + ) : page === "playlists" ? ( ) : page === "notifications" ? ( diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx new file mode 100644 index 0000000..12fb281 --- /dev/null +++ b/frontend/src/components/AuditLog.tsx @@ -0,0 +1,90 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Trash2 } from "lucide-react"; +import { api } from "../lib/api"; +import { notify } from "../lib/notifications"; +import { LS } from "../lib/storage"; +import { Section } from "./ui/form"; +import { useConfirm } from "./ConfirmProvider"; +import DataTable from "./DataTable"; +import { auditColumns } from "./auditColumns"; + +// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run +// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via +// DataTable; the API returns the most recent window (capped) — the log stays small by design. +export default function AuditLog() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + const q = useQuery({ queryKey: ["admin-audit"], queryFn: () => api.adminAudit() }); + const data = q.data; + const rows = useMemo(() => data?.items ?? [], [data]); + + const actionOptions = useMemo(() => { + const present = Array.from(new Set(rows.map((r) => r.action))).sort(); + return present.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) })); + }, [rows, t]); + const columns = useMemo(() => auditColumns(t, actionOptions), [t, actionOptions]); + + const clear = useMutation({ + mutationFn: () => api.clearAudit(), + onSuccess: (r) => { + qc.invalidateQueries({ queryKey: ["admin-audit"] }); + notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) }); + }, + onError: () => notify({ level: "error", message: t("audit.clearFailed") }), + }); + const onClear = async () => { + if ( + await confirm({ + title: t("audit.clearTitle"), + message: t("audit.clearConfirm"), + confirmLabel: t("audit.clear"), + danger: true, + }) + ) + clear.mutate(); + }; + + const truncated = !!data && data.total > data.returned; + + return ( +
+
+

{t("audit.intro")}

+ {q.isLoading ? ( +
{t("common.loading")}
+ ) : rows.length === 0 ? ( +

{t("audit.empty")}

+ ) : ( + <> + String(r.id)} + persistKey={LS.auditTable} + controlsPosition="top" + controlsLeading={ + + } + emptyText={t("audit.empty")} + /> + {truncated && ( +

+ {t("audit.truncated", { returned: data!.returned, total: data!.total })} +

+ )} + + )} +
+
+ ); +} diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 730dd0d..6f80394 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -15,7 +15,7 @@ import { LS } from "../lib/storage"; // applied together via one Save/Discard bar (consistent with the Settings page); nothing hits // the server until Save. Group order is fixed where known so logically related settings (e.g. // Email/SMTP) stay together. -const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"]; +const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "audit", "plex"]; export default function ConfigPanel() { const { t } = useTranslation(); diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index c3dab6d..6a2267f 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -15,6 +15,7 @@ import { ListVideo, LogOut, MessageSquare, + ScrollText, Settings, Shield, SlidersHorizontal, @@ -193,6 +194,7 @@ export default function NavSidebar({ { page: "scheduler", icon: Activity, label: t("header.account.scheduler") }, { page: "config", icon: SlidersHorizontal, label: t("header.account.config") }, { page: "users", icon: Users, label: t("header.account.users") }, + { page: "audit", icon: ScrollText, label: t("header.account.audit") }, ] : []; diff --git a/frontend/src/components/auditColumns.tsx b/frontend/src/components/auditColumns.tsx new file mode 100644 index 0000000..57a2dbb --- /dev/null +++ b/frontend/src/components/auditColumns.tsx @@ -0,0 +1,91 @@ +import type { TFunction } from "i18next"; +import { relativeTime, formatDate } from "../lib/format"; +import i18n from "../i18n"; +import type { AuditRow } from "../lib/api"; +import type { Column } from "./DataTable"; + +function actionLabel(t: TFunction, action: string): string { + // Canonical key → localized label; falls back to the raw key for any unmapped action. + return t(`auditActions.${action}`, action); +} + +function fmt(v: unknown): string { + return v == null || v === "" ? "—" : String(v); +} + +/** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */ +function changeText(row: AuditRow): string | null { + const { before, after } = row; + if (before && after) { + return Object.keys(after) + .map((k) => `${k}: ${fmt(before[k])} → ${fmt(after[k])}`) + .join(", "); + } + if (after) { + return Object.entries(after) + .map(([k, v]) => `${k}: ${fmt(v)}`) + .join(", "); + } + return null; +} + +/** Columns for the admin audit-log DataTable. `actionOptions` are the distinct actions present in + * the data (built by the page) so the Action column's select filter only offers real values. */ +export function auditColumns( + t: TFunction, + actionOptions: { value: string; label: string }[], +): Column[] { + return [ + { + key: "when", + header: t("audit.cols.when"), + nowrap: true, + sortable: true, + sortValue: (r) => r.created_at ?? "", + cardPrimary: true, + render: (r) => ( + + {r.created_at ? relativeTime(r.created_at) : "—"} + + ), + }, + { + key: "actor", + header: t("audit.cols.actor"), + sortable: true, + sortValue: (r) => r.actor ?? "", + filter: { kind: "text", get: (r) => r.actor ?? t("audit.system") }, + render: (r) => + r.actor ? ( + {r.actor} + ) : ( + {t("audit.system")} + ), + }, + { + key: "action", + header: t("audit.cols.action"), + nowrap: true, + sortable: true, + sortValue: (r) => actionLabel(t, r.action), + filter: { kind: "select", options: actionOptions, test: (r, v) => r.action === v }, + render: (r) => {actionLabel(t, r.action)}, + }, + { + key: "details", + header: t("audit.cols.details"), + render: (r) => { + const change = changeText(r); + return ( +
+ {r.summary &&
{r.summary}
} + {change &&
{change}
} +
+ ); + }, + }, + ]; +} diff --git a/frontend/src/i18n/locales/de/audit.json b/frontend/src/i18n/locales/de/audit.json new file mode 100644 index 0000000..cabffc3 --- /dev/null +++ b/frontend/src/i18n/locales/de/audit.json @@ -0,0 +1,18 @@ +{ + "title": "Audit-Protokoll", + "intro": "Ein reines Anhänge-Protokoll der Admin-Aktionen und Scheduler-Laufzusammenfassungen — wer was wann getan hat. Auf Aktionsebene protokolliert, nie pro Element.", + "empty": "Noch keine Protokolleinträge.", + "system": "System", + "clear": "Protokoll leeren", + "clearTitle": "Audit-Protokoll leeren?", + "clearConfirm": "Alle Protokolleinträge dauerhaft löschen? Das kann nicht rückgängig gemacht werden. (Ein Eintrag, der festhält, dass du es geleert hast, bleibt erhalten.)", + "cleared": "{{count}} Protokolleinträge gelöscht", + "clearFailed": "Das Protokoll konnte nicht geleert werden.", + "truncated": "Die {{returned}} neuesten von {{total}} Einträgen werden angezeigt.", + "cols": { + "when": "Wann", + "actor": "Wer", + "action": "Aktion", + "details": "Details" + } +} diff --git a/frontend/src/i18n/locales/de/auditActions.json b/frontend/src/i18n/locales/de/auditActions.json new file mode 100644 index 0000000..4705cfe --- /dev/null +++ b/frontend/src/i18n/locales/de/auditActions.json @@ -0,0 +1,21 @@ +{ + "user_role_change": "Rolle geändert", + "user_suspend": "Benutzer gesperrt", + "user_unsuspend": "Benutzer reaktiviert", + "user_delete": "Benutzer gelöscht", + "invite_approve": "Zugriffsanfrage genehmigt", + "invite_deny": "Zugriffsanfrage abgelehnt", + "invite_add": "E-Mail auf Whitelist", + "demo_add": "Demo-E-Mail hinzugefügt", + "demo_remove": "Demo-E-Mail entfernt", + "demo_reset": "Demo zurückgesetzt", + "discovery_purge": "Entdeckung bereinigt", + "config_set": "Einstellung geändert", + "config_reset": "Einstellung zurückgesetzt", + "scheduler_interval": "Job-Intervall geändert", + "maintenance_tune": "Wartung angepasst", + "sync_pause": "Sync pausiert", + "sync_resume": "Sync fortgesetzt", + "job_run": "Scheduler-Job ausgeführt", + "audit_clear": "Audit-Protokoll geleert" +} diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json index ee35d7f..42a0eee 100644 --- a/frontend/src/i18n/locales/de/config.json +++ b/frontend/src/i18n/locales/de/config.json @@ -12,7 +12,8 @@ "batch": "Stapelgrößen", "explore": "Kanal erkunden", "downloads": "Downloads", - "plex": "Plex" + "plex": "Plex", + "audit": "Audit-Protokoll" }, "fields": { "smtp_host": { diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json index 1e233fe..7e22b54 100644 --- a/frontend/src/i18n/locales/de/header.json +++ b/frontend/src/i18n/locales/de/header.json @@ -29,7 +29,8 @@ "about": "Über", "signOut": "Abmelden", "addAccount": "Weiteres Konto hinzufügen", - "switchTo": "Zu {{name}} wechseln" + "switchTo": "Zu {{name}} wechseln", + "audit": "Audit-Protokoll" }, "sync": { "yours": "deine", diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json index bfe3520..f7618d3 100644 --- a/frontend/src/i18n/locales/de/scheduler.json +++ b/frontend/src/i18n/locales/de/scheduler.json @@ -71,7 +71,8 @@ "download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben.", "plex_sync": "Spiegelt die aktivierten Plex-Bibliotheken in den App-Katalog — die Metadaten hinter Plex-Stöbern, Suche, Filtern und Info-Seiten (Genres, Besetzung, Wertungen, Artwork). Fällt er aus, erscheinen neu hinzugefügte oder geänderte Plex-Titel erst beim nächsten Sync.", "plex_watch_sync": "Holt kürzliche Plex-Wiedergabeänderungen (beendete Episoden, Fortsetzungspositionen) nach Siftlode und schiebt lokale Wiedergabeänderungen nach, die Plex nicht erreicht haben. Der günstige Zwei-Wege-Abgleich zwischen den vollen Reconciles. Fällt er aus, erscheinen in der Plex-App gesetzte Gesehen-/Fortsetzungsstände hier später.", - "plex_watch_reconcile": "Ein vollständiger Wiedergabe-Abgleich, der jede Bibliothek neu scannt — erfasst, was der inkrementelle Lauf nicht kann, vor allem eine auf Plex-Seite zurückgenommene Gesehen-Markierung. Schwerer, läuft daher etwa täglich. Fällt er aus, werden in Plex vorgenommene Un-Watches nicht zurückgespiegelt." + "plex_watch_reconcile": "Ein vollständiger Wiedergabe-Abgleich, der jede Bibliothek neu scannt — erfasst, was der inkrementelle Lauf nicht kann, vor allem eine auf Plex-Seite zurückgenommene Gesehen-Markierung. Schwerer, läuft daher etwa täglich. Fällt er aus, werden in Plex vorgenommene Un-Watches nicht zurückgespiegelt.", + "audit_gc": "Entfernt Audit-Protokolleinträge, die älter als das eingestellte Aufbewahrungsfenster sind (Konfiguration → Audit-Protokoll). Wenn er stoppt, wächst das Protokoll unbegrenzt — harmlos, aber unbeschränkt." }, "jobs": { "rss_poll": "RSS-Abfrage (neue Uploads)", @@ -87,7 +88,8 @@ "download_gc": "Download-Bereinigung", "plex_sync": "Plex-Bibliothek-Sync", "plex_watch_sync": "Plex-Wiedergabe-Sync (inkrementell)", - "plex_watch_reconcile": "Plex-Wiedergabe-Abgleich (vollständig)" + "plex_watch_reconcile": "Plex-Wiedergabe-Abgleich (vollständig)", + "audit_gc": "Audit-Protokoll-Bereinigung" }, "queue": { "recentPending": "Zu synchronisierende Kanäle", diff --git a/frontend/src/i18n/locales/en/audit.json b/frontend/src/i18n/locales/en/audit.json new file mode 100644 index 0000000..cad77e8 --- /dev/null +++ b/frontend/src/i18n/locales/en/audit.json @@ -0,0 +1,18 @@ +{ + "title": "Audit log", + "intro": "An append-only record of admin actions and scheduler run summaries — who did what, and when. Logged at action granularity, never per item.", + "empty": "No audit entries yet.", + "system": "System", + "clear": "Clear log", + "clearTitle": "Clear the audit log?", + "clearConfirm": "Permanently delete all audit entries? This can't be undone. (One entry recording that you cleared it is kept.)", + "cleared": "Cleared {{count}} audit entries", + "clearFailed": "Couldn't clear the audit log.", + "truncated": "Showing the {{returned}} most recent of {{total}} entries.", + "cols": { + "when": "When", + "actor": "Who", + "action": "Action", + "details": "Details" + } +} diff --git a/frontend/src/i18n/locales/en/auditActions.json b/frontend/src/i18n/locales/en/auditActions.json new file mode 100644 index 0000000..a9e8e86 --- /dev/null +++ b/frontend/src/i18n/locales/en/auditActions.json @@ -0,0 +1,21 @@ +{ + "user_role_change": "Role changed", + "user_suspend": "User suspended", + "user_unsuspend": "User reinstated", + "user_delete": "User deleted", + "invite_approve": "Access request approved", + "invite_deny": "Access request denied", + "invite_add": "Email whitelisted", + "demo_add": "Demo email added", + "demo_remove": "Demo email removed", + "demo_reset": "Demo reset", + "discovery_purge": "Discovery purged", + "config_set": "Setting changed", + "config_reset": "Setting reset", + "scheduler_interval": "Job interval changed", + "maintenance_tune": "Maintenance tuned", + "sync_pause": "Sync paused", + "sync_resume": "Sync resumed", + "job_run": "Scheduler job run", + "audit_clear": "Audit log cleared" +} diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index ac00434..25ade89 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -12,7 +12,8 @@ "batch": "Batch sizes", "explore": "Channel explore", "downloads": "Downloads", - "plex": "Plex" + "plex": "Plex", + "audit": "Audit log" }, "fields": { "smtp_host": { diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index fa2eae8..d5f29f5 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -29,7 +29,8 @@ "about": "About", "signOut": "Sign out", "addAccount": "Add another account", - "switchTo": "Switch to {{name}}" + "switchTo": "Switch to {{name}}", + "audit": "Audit log" }, "sync": { "yours": "yours", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index f6e2f99..abaa779 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -71,7 +71,8 @@ "download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.", "plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.", "plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.", - "plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back." + "plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.", + "audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded." }, "jobs": { "rss_poll": "RSS poll (new uploads)", @@ -87,7 +88,8 @@ "download_gc": "Download cleanup", "plex_sync": "Plex library sync", "plex_watch_sync": "Plex watch sync (incremental)", - "plex_watch_reconcile": "Plex watch reconcile (full)" + "plex_watch_reconcile": "Plex watch reconcile (full)", + "audit_gc": "Audit-log cleanup" }, "queue": { "recentPending": "Channels to sync", diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json new file mode 100644 index 0000000..f62dc16 --- /dev/null +++ b/frontend/src/i18n/locales/hu/audit.json @@ -0,0 +1,18 @@ +{ + "title": "Naplózás", + "intro": "Az admin-műveletek és az ütemező futás-összegzéseinek csak-hozzáfűző naplója — ki, mit, mikor. Művelet-szinten naplózva, soha nem elemenként.", + "empty": "Még nincs naplóbejegyzés.", + "system": "Rendszer", + "clear": "Napló ürítése", + "clearTitle": "Üríted a naplót?", + "clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)", + "cleared": "{{count}} naplóbejegyzés törölve", + "clearFailed": "A naplót nem sikerült üríteni.", + "truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.", + "cols": { + "when": "Mikor", + "actor": "Ki", + "action": "Művelet", + "details": "Részletek" + } +} diff --git a/frontend/src/i18n/locales/hu/auditActions.json b/frontend/src/i18n/locales/hu/auditActions.json new file mode 100644 index 0000000..9038c32 --- /dev/null +++ b/frontend/src/i18n/locales/hu/auditActions.json @@ -0,0 +1,21 @@ +{ + "user_role_change": "Szerepkör módosítva", + "user_suspend": "Felhasználó felfüggesztve", + "user_unsuspend": "Felhasználó visszaállítva", + "user_delete": "Felhasználó törölve", + "invite_approve": "Hozzáférési kérelem jóváhagyva", + "invite_deny": "Hozzáférési kérelem elutasítva", + "invite_add": "Email fehérlistázva", + "demo_add": "Demo email hozzáadva", + "demo_remove": "Demo email eltávolítva", + "demo_reset": "Demo visszaállítva", + "discovery_purge": "Felfedezés ürítve", + "config_set": "Beállítás módosítva", + "config_reset": "Beállítás visszaállítva", + "scheduler_interval": "Job-intervallum módosítva", + "maintenance_tune": "Karbantartás hangolva", + "sync_pause": "Szinkron szüneteltetve", + "sync_resume": "Szinkron folytatva", + "job_run": "Ütemező job lefutott", + "audit_clear": "Napló ürítve" +} diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index f4e165a..a9ad80e 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -12,7 +12,8 @@ "batch": "Kötegméretek", "explore": "Csatorna-felfedezés", "downloads": "Letöltések", - "plex": "Plex" + "plex": "Plex", + "audit": "Naplózás" }, "fields": { "smtp_host": { diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 974897c..8aa29c4 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -29,7 +29,8 @@ "about": "Névjegy", "signOut": "Kijelentkezés", "addAccount": "Másik fiók hozzáadása", - "switchTo": "Váltás erre: {{name}}" + "switchTo": "Váltás erre: {{name}}", + "audit": "Naplózás" }, "sync": { "yours": "tiéd", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 026cfea..4ab4d88 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -71,7 +71,8 @@ "download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.", "plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg.", "plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes reconcile-ok között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.", - "plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza." + "plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza.", + "audit_gc": "Törli a beállított megőrzési időnél (Beállítások → Naplózás) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul." }, "jobs": { "rss_poll": "RSS-lekérdezés (új feltöltések)", @@ -87,7 +88,8 @@ "download_gc": "Letöltések takarítása", "plex_sync": "Plex-könyvtár szinkron", "plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)", - "plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)" + "plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)", + "audit_gc": "Napló-karbantartás" }, "queue": { "recentPending": "Szinkronra váró csatorna", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 470c954..6443733 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -926,6 +926,23 @@ export interface AdminUserRow { created_at: string | null; } +export interface AuditRow { + id: number; + created_at: string | null; + actor: string | null; // email of the acting admin; null = System (scheduler/background) + action: string; // canonical key, localized on the client via auditActions. + target_type: string | null; + target_id: string | null; + summary: string | null; + before: Record | null; + after: Record | null; +} +export interface AuditListResult { + items: AuditRow[]; + total: number; // total rows in the log (may exceed `items` if capped) + returned: number; +} + // --- download center --- export interface DownloadSpec { mode: "av" | "v" | "a"; // audio+video | video-only | audio-only @@ -1386,6 +1403,8 @@ export const api = { plexImageUrl: (id: string, variant?: "thumb" | "art"): string => `/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`, // --- admin: users & roles --- + adminAudit: (limit = 500): Promise => req(`/api/admin/audit?limit=${limit}`), + clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }), adminUsers: (): Promise => req("/api/admin/users"), setUserRole: (id: number, role: "user" | "admin"): Promise => req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }), diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 52d4cca..4d4899d 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -20,6 +20,7 @@ export const LS = { channelDiscoveryTable: "siftlode.channelDiscoveryTable", settingsTab: "siftlode.settingsTab", configTab: "siftlode.configTab", + auditTable: "siftlode.auditTable", statsTab: "siftlode.statsTab", adminUsersTab: "siftlode.adminUsersTab", navCollapsed: "siftlode.navCollapsed", diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 5653dd8..c9100da 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -98,6 +98,7 @@ const PAGES = [ "scheduler", "config", "users", + "audit", "notifications", "messages", "downloads",