mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* docs: add infrastructure roadmap plan Records the verified recommendations from an infrastructure review in three tracks: raising the single-instance ceiling, cheap seams for a possible multi-instance future, and ops hygiene. Includes explicit anti-recommendations and sequencing. Security-sensitive detail is intentionally excluded per docs/security.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): real health checks and saturation metrics /api/v1/metrics now exposes signals that were already computed in memory but never surfaced: reconnect replay tier hits, event-persister counters, SQLite writer-pool wait stats, aggregate per-client backpressure counters (including previously invisible low-priority drops), and permission-cache hit/miss. /health now returns a real verdict: hub dispatch-loop liveness, a bounded database ping, and a free-disk check, returning 503 with a subsystem reason when degraded. Checks are cached so the unauthenticated endpoint cannot amplify load. The hub's panic breaker now exits the process so a supervisor can restart it, instead of leaving broadcast delivery silently dead while clients still appear online. OTel instruments that were declared but never recorded are now wired (ws_active_connections, ws_broadcast_latency_seconds, ws_messages_total, ws_events_dropped_total, voice gauges) or removed (db_query_duration_seconds). Also corrects the docs/api.md description of broadcast_drops, which counts hub-queue overflow, not client send-queue overflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): implement scheduled backups, retention, and backup verification The backup_schedule and backup_retention settings have existed in the admin panel and API since the initial schema but were never read by any code. The 15-minute maintenance loop now enforces them: a scheduled backup is taken when the newest backup on disk is older than the schedule interval (manual backups reset the clock), and retention prunes backups older than the configured days while always keeping the newest one. Backups are now verified with PRAGMA integrity_check immediately after VACUUM INTO (a failed backup is removed rather than listed as restorable) and again before a restore may overwrite the live database. A failed VACUUM INTO also cleans up its partial output file — but never a pre-existing one. The backup directory is configurable via a new backup.dir key (default data/backups) so operators can point backups at another disk or an off-host mount, mirroring the SetDatabasePath plumb. Restore-handler tests now use real SQLite fixtures (the integrity gate correctly refuses text files) with the mid-copy failure injected through a test-only copy hook. Also adds audited gosec suppressions to the Windows disk-free syscall added in the previous commit, which the Windows lint leg flagged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): capacity and failure-mode guardrails - server.max_ws_connections: optional cap on concurrent WebSocket clients, checked before the upgrade with a 503 + Retry-After; rejections are counted and exposed as ws_conn_rejects in /api/v1/metrics. - Single-process database lock: an OS-level advisory lock (flock / exclusive handle) beside the SQLite file makes a second server process fail fast with a clear message instead of silently fighting the first over process-local state. A bounded retry covers the self-update/restore restart handoff, and the lock mechanism failing (e.g. network filesystems) only warns. - Disk-space awareness: boot-time warnings for the data and backup volumes, plus a disk_free_mb metrics field, via a small cross-platform diskutil package (already used by /health). - Upload storage failures: storage.Save now marks server-side filesystem failures with a sentinel (storage.ErrIO); handlers return 507 for those instead of blaming the client with a 400, and the emoji route stops echoing raw storage errors (which embed absolute paths) into responses. - Unknown config keys now warn at startup — a typo like admin_alowed_cidrs previously kept the default silently while the operator believed the setting changed. Never fatal: newer servers tolerate older configs. - Admin settings honesty: the three stored-but-inert settings (server_icon, max_upload_bytes, voice_quality) are shown read-only with a note pointing at the real config.yaml keys, instead of pretending to apply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * perf(db): write-path efficiency and capacity knobs - channel_focus/mark_read now skip the read-state UPSERT when the stored row already matches (same last_message_id, no mentions) — refocus events fire at up to 10/s/user and every no-op write still occupied the single SQLite writer connection. The extra existence check runs on the reader pool, which doesn't serialize. Same shape as the session-touch throttle. - DeleteExpiredSessions is now sargable: migration 031 normalizes legacy expiry formats to the RFC3339-Z layout the server writes and indexes expires_at, replacing the strftime full-table scan that ran on the writer every 15 minutes. - Boot-time ANALYZE runs only when a migration actually applied; unchanged schemas get the cheap PRAGMA optimize instead (which also covers crash-restarts that never reached the shutdown optimize). - The read/write SQL router gets a table-driven test with explicit expected values (INSERT ... RETURNING must hit the writer despite being :one). - New knobs, all defaulting to current behavior: database.max_readers, security.auth_rate_limit_multiplier (for shared-NAT communities), event_persistence.replay_ring_size and replay_cold_limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * fix(server): shutdown lifecycle ordering - The event pruner and maintenance loop are now joined (bounded) before the database closes: bgCtx cancellation used to run AFTER database.Close via LIFO defers, contradicting its own comment, and neither goroutine was ever waited on — a mid-tick scheduled backup or prune could still hold the writer while the pool tore down. StartEventPruner returns a done channel with the same join contract EventPersister.Stop already had. - srv.Shutdown now runs before hub.GracefulStop, so in-flight HTTP handlers' broadcasts still reach a live hub and the event persister instead of vanishing from the replay/event store across a restart. Shutdown does not wait on hijacked WebSocket connections, so the swap adds no delay. - GracefulStopContext threads the 30s shutdown budget into the hub: the 5s client-notice window (matching the countdown clients are shown) ends early when the budget expires, and is skipped entirely when nobody is connected — early-return startup paths and idle servers no longer sleep 5s for an audience of zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * build(deploy): systemd unit, compose hardening, boot-smoked releases, CI polish - deploy/owncord.service: hardened systemd unit template with the two verified caveats encoded (install dir stays writable for self-update under ProtectSystem=strict; CAP_NET_BIND_SERVICE for ACME's :80), plus a 'Linux (systemd)' deployment docs section — the Linux service story was previously 'Docker or nothing'. - New 'Reverse Proxy Topology' docs section with a working nginx snippet and the correct signaling-vs-media distinction: /livekit/* is already proxied by the server, only WebRTC media ports must be directly reachable. - docker-compose: log rotation, commented resource limits, and a healthcheck backed by a new 'chatserver healthcheck' subcommand (the distroless image has no shell) that probes /health without config side effects. - release.yml: a concurrency group (queue, never cancel), and boot-smoke gates — the freshly built server binaries and the Docker image are cold booted and probed healthy BEFORE anything is signed or pushed. The release feed drives signed self-updates, so a binary that compiles but dies on boot previously would have shipped itself to every auto-updating instance. - ci.yml: client-check/client-tests move to ubuntu with the reasoning recorded (no win32 code paths, LF enforced repo-wide); admin-e2e gets a written graduation criterion instead of an open-ended non-blocking status. - docs: Tailscale guide notes the CGNAT range vs the default admin CIDRs; architecture overview records presence/voice state as the fifth single-instance blocker and the macOS client scope decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * perf(server): measured load tooling, narrowed invalidation, presence coalescing, storage and CIDR seams - Fix scripts/k6/ws-load.js against the real wire protocol: envelope-wrapped frames, correct message types (typing_start, presence_update), the correct /api/v1/ws path, and thresholds that fail a run where nobody authenticated or went ready — the script had drifted to pre-envelope framing and reported 100% green while every auth failed on the first frame. A new workflow_dispatch-only load-baseline workflow boots a real server, seeds users through the setup/invite APIs, runs the script, and uploads the k6 summary plus a metrics snapshot for before/after comparison. - Role-scoped channel-override changes now evict only the affected role's members from the permission cache (fail-safe: unreadable member list still flushes everything). InvalidateAll here repopulated every connected user — two reads each — synchronously inside the admin request via RefreshChannelVisibility, a stampede that scaled with total population rather than the role's size. Same pattern the per-user override endpoints already used. - Connect/disconnect presence broadcasts now pass through a 300ms latest-wins coalescer (QueuePresence): each un-coalesced presence change is a sequenced global broadcast (an O(clients) fan-out under seqMu), so a reconnect storm fired O(users) of them from the connect critical path. A flap inside the window collapses to its final state; the wire format, seq ordering, and replay behaviour are unchanged, and the delivery path (BroadcastPresence) is untouched. - Storage seam: api handlers now consume a FileStore interface (consumer-side, same pattern as service.Store) with Open returning a seekable storage.File — writing down the contract (range-request seeks included) an alternative backend would have to meet, without building one. - The metrics surfaces and the LiveKit webhook/health endpoints get their own allowlist keys (metrics_allowed_cidrs, livekit_webhook_allowed_cidrs, both defaulting to admin_allowed_cidrs), so a central Prometheus scraper or an externally-hosted LiveKit no longer requires widening the admin panel's perimeter. Startup now also warns when admin_allowed_cidrs is customized while trusted_proxies is empty — behind a proxy or container network the check would otherwise compare the proxy's private address, not the client's. - The container healthcheck probe now PINS the server's own certificate from disk (VerifyConnection, exact-match) instead of skipping TLS verification, addressing the CodeQL finding on the previous commit; WebPKI verification is used when no local cert exists (ACME). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * fix(server): address self-review findings on the hardening branch Seven fixes from a high-effort review of the full branch diff: - healthcheck CLI now works under tls.mode acme: it overrides ServerName with the configured domain for WebPKI verification instead of pinning a cert that doesn't exist (or is stale) in that mode. Previously an ACME deployment's container healthcheck failed forever. - /health pings the READER pool (new db.PingRead): the writer ping queued behind a scheduled backup's VACUUM INTO and reported the server degraded for the whole backup — which an autoheal watchdog would turn into a nightly mid-backup restart. - /health runs its cached checks under context.WithoutCancel so a probe that disconnects mid-request cannot poison the shared cache with a false degraded verdict for the next 5 seconds. - The token CLI uses a new db.OpenShared that skips the single-process lock: minting a token against a running server is safe under WAL and was a documented workflow the lock had broken. - The per-user TOTP failure cap is no longer scaled by security.auth_rate_limit_multiplier — that knob exists for per-IP limits; scaling the only cross-IP brute-force defence multiplied an attacker's distributed guess budget. Mirrors the unscaled per-user login threshold. - A direct presence_update now drops the user's queued entry in the connect/disconnect coalescer, so a stale connect-time presence can no longer flush 300ms later over the user's fresher chosen status. - The scheduled-backup filename collision loop breaks on any stat error and bounds its suffix probing, instead of spinning the maintenance goroutine forever on a persistent EACCES. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * test(admin): real SQLite fixture for the merged Close-failure restore test TestHandleRestoreBackup_RestartsWhenCloseFails arrived from main (#1375) with a plain-text backup fixture; this branch's restore handler verifies backups with integrity_check before touching the live database, so the text fixture was (correctly) refused with 400 before the Close-failure branch under test was reached. Use a real backup via BackupToSafe, matching the other restore tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj --------- Co-authored-by: Claude <noreply@anthropic.com>
1997 lines
141 KiB
HTML
1997 lines
141 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>OwnCord — Admin Panel</title>
|
||
<style>
|
||
:root {
|
||
--bg-tertiary:#1e1f22;--bg-secondary:#2b2d31;--bg-primary:#313338;
|
||
--bg-input:#1e1f22;--bg-hover:#35373c;--bg-active:#404249;
|
||
--bg-overlay:rgba(0,0,0,.7);--bg-card:#2b2d31;--bg-table-hover:rgba(88,101,242,.06);
|
||
--accent:#5865f2;--accent-hover:#4752c4;--accent-active:#3c45a5;--accent-glow:rgba(88,101,242,.25);
|
||
--text-normal:#dbdee1;--text-muted:#949ba4;--text-faint:#80848e;--text-micro:#6d6f78;--text-link:#00a8fc;
|
||
--green:#23a55a;--yellow:#f0b232;--red:#f23f43;--border:#3f4147;--border-strong:#4e5058;
|
||
--role-owner:#e74c3c;--role-admin:#f39c12;--role-mod:#2ecc71;--role-member:#949ba4;
|
||
--font-body:"Segoe UI Variable Text","Segoe UI",system-ui,sans-serif;
|
||
--font-mono:"Cascadia Code","Consolas",monospace;
|
||
--radius-sm:4px;--radius-md:8px;--sidebar-w:240px;
|
||
}
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
html,body{height:100%;overflow:hidden}
|
||
body{font-family:var(--font-body);font-size:14px;color:var(--text-normal);background:var(--bg-tertiary);-webkit-font-smoothing:antialiased}
|
||
button{font-family:inherit;border:none;cursor:pointer;outline:none}
|
||
input,select,textarea{font-family:inherit;border:none;outline:none}
|
||
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}
|
||
::-webkit-scrollbar-thumb{background:var(--bg-tertiary);border-radius:3px}
|
||
::-webkit-scrollbar-thumb:hover{background:var(--bg-hover)}
|
||
|
||
.admin{display:flex;height:100vh}
|
||
.sidebar{width:var(--sidebar-w);background:var(--bg-secondary);display:flex;flex-direction:column;flex-shrink:0;overflow-y:auto}
|
||
.sidebar-header{padding:20px 16px 16px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||
.sidebar-brand{display:flex;align-items:center;gap:10px}
|
||
.sidebar-logo{width:36px;height:36px;border-radius:10px;background:var(--accent);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||
.sidebar-logo svg{width:22px;height:22px}
|
||
.sidebar-title{font-size:15px;font-weight:700;color:white}
|
||
.sidebar-subtitle{font-size:11px;color:var(--text-faint);letter-spacing:.03em}
|
||
.sidebar-nav{flex:1;padding:8px}
|
||
.sidebar-label{font-size:11px;font-weight:700;color:var(--text-faint);letter-spacing:.05em;text-transform:uppercase;padding:12px 12px 4px}
|
||
.sidebar-sep{height:1px;background:var(--border);margin:4px 12px}
|
||
.nav-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:var(--radius-sm);font-size:14px;color:var(--text-muted);background:transparent;width:100%;text-align:left;transition:all .15s;position:relative}
|
||
.nav-item:hover{background:var(--bg-hover);color:var(--text-normal)}
|
||
.nav-item.active{background:var(--bg-active);color:white}
|
||
.nav-item svg{width:18px;height:18px;flex-shrink:0;opacity:.7}
|
||
.nav-item.active svg{opacity:1}
|
||
.nav-item.danger{color:var(--red)}.nav-item.danger:hover{background:rgba(242,63,67,.1)}
|
||
.nav-item .unsaved-dot{position:absolute;left:6px;top:50%;transform:translateY(-50%);width:6px;height:6px;border-radius:50%;background:var(--yellow)}
|
||
.sidebar-footer{padding:12px 16px;border-top:1px solid var(--border);flex-shrink:0;font-size:11px;color:var(--text-micro);text-align:center}
|
||
|
||
.content{flex:1;overflow-y:auto;padding:32px 40px;background:var(--bg-primary)}
|
||
.page-title{font-size:22px;font-weight:700;color:white;margin-bottom:4px}
|
||
.page-desc{font-size:13px;color:var(--text-faint);margin-bottom:24px}
|
||
|
||
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;margin-bottom:24px}
|
||
.stat-card{background:var(--bg-card);border-radius:var(--radius-md);padding:18px 20px;border:1px solid var(--border);transition:border-color .2s}
|
||
.stat-card:hover{border-color:var(--border-strong)}
|
||
.stat-card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
|
||
.stat-card-label{font-size:11px;font-weight:700;color:var(--text-faint);letter-spacing:.04em;text-transform:uppercase}
|
||
.stat-card-icon{width:32px;height:32px;border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||
.stat-card-icon svg{width:16px;height:16px}
|
||
.stat-card-value{font-size:28px;font-weight:700;color:white;font-family:var(--font-mono);line-height:1.1}
|
||
.stat-card-sub{font-size:12px;color:var(--text-faint);margin-top:4px;font-family:var(--font-mono)}
|
||
|
||
.section-card{background:var(--bg-card);border-radius:var(--radius-md);border:1px solid var(--border);margin-bottom:20px;overflow:hidden}
|
||
.section-card-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||
.section-card-header h3{font-size:14px;font-weight:700;color:white}
|
||
.section-card-body{padding:16px 20px}
|
||
.section-card-body.no-pad{padding:0}
|
||
|
||
.tbl{width:100%;border-collapse:collapse}
|
||
.tbl th{font-size:11px;font-weight:700;color:var(--text-faint);letter-spacing:.04em;text-transform:uppercase;text-align:left;padding:10px 16px;border-bottom:2px solid var(--border)}
|
||
.tbl td{padding:10px 16px;font-size:13px;border-bottom:1px solid var(--border);vertical-align:middle}
|
||
.tbl tr:hover td{background:var(--bg-table-hover)}
|
||
.tbl tr:last-child td{border-bottom:none}
|
||
|
||
.avatar{width:28px;height:28px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;color:white;flex-shrink:0}
|
||
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px;flex-shrink:0}
|
||
.dot.online{background:var(--green)}.dot.offline{background:var(--text-micro)}.dot.banned{background:var(--red)}
|
||
.role-badge{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;padding:2px 8px;border-radius:10px;background:rgba(255,255,255,.06)}
|
||
.role-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||
.act-btn{width:28px;height:28px;border-radius:var(--radius-sm);background:transparent;color:var(--text-faint);display:inline-flex;align-items:center;justify-content:center;transition:all .15s}
|
||
.act-btn:hover{background:var(--bg-active);color:var(--text-normal)}
|
||
.act-btn.danger:hover{background:var(--red);color:white}
|
||
.act-btn svg{width:14px;height:14px}
|
||
.act-group{display:flex;gap:2px}
|
||
|
||
.badge{display:inline-block;font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px}
|
||
.badge-green{background:rgba(35,165,90,.15);color:var(--green)}
|
||
.badge-red{background:rgba(242,63,67,.15);color:var(--red)}
|
||
.badge-yellow{background:rgba(240,178,50,.15);color:var(--yellow)}
|
||
.badge-muted{background:rgba(128,132,142,.15);color:var(--text-faint)}
|
||
.badge-accent{background:var(--accent-glow);color:var(--accent)}
|
||
|
||
.filter-bar{display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap}
|
||
.filter-search{flex:1;min-width:200px;padding:8px 12px;background:var(--bg-input);color:var(--text-normal);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;transition:border-color .2s}
|
||
.filter-search::placeholder{color:var(--text-micro)}.filter-search:focus{border-color:var(--accent)}
|
||
.filter-select{padding:8px 28px 8px 10px;background:var(--bg-input);color:var(--text-normal);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;appearance:none;cursor:pointer}
|
||
.filter-select:focus{border-color:var(--accent)}
|
||
|
||
.btn{padding:8px 16px;border-radius:var(--radius-sm);font-size:13px;font-weight:600;transition:all .15s;display:inline-flex;align-items:center;gap:6px}
|
||
.btn-accent{background:var(--accent);color:white}.btn-accent:hover{background:var(--accent-hover)}
|
||
.btn-danger{background:var(--red);color:white}.btn-danger:hover{background:#d83135}
|
||
.btn-ghost{background:var(--bg-hover);color:var(--text-muted)}.btn-ghost:hover{background:var(--bg-active);color:var(--text-normal)}
|
||
.btn-outline{background:transparent;color:var(--text-muted);border:1px solid var(--border)}.btn-outline:hover{border-color:var(--border-strong);color:var(--text-normal)}
|
||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||
.btn .spinner{width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:white;border-radius:50%;animation:spin .6s linear infinite}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
|
||
.form-group{margin-bottom:16px}
|
||
.form-label{display:block;font-size:11px;font-weight:700;color:var(--text-muted);letter-spacing:.02em;text-transform:uppercase;margin-bottom:6px}
|
||
.form-label .req{color:var(--red);margin-left:2px}
|
||
.form-input{width:100%;padding:10px 12px;background:var(--bg-input);color:var(--text-normal);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:14px;transition:border-color .2s}
|
||
.form-input::placeholder{color:var(--text-micro)}.form-input:focus{border-color:var(--accent)}
|
||
.form-textarea{resize:vertical;min-height:80px}
|
||
.role-swatch{width:14px;height:14px;border-radius:50%;flex-shrink:0;border:1px solid var(--border-strong)}
|
||
.perm-group{margin-bottom:14px}
|
||
.perm-group-title{font-size:11px;font-weight:700;color:var(--text-faint);letter-spacing:.04em;text-transform:uppercase;margin-bottom:6px}
|
||
.perm-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 12px}
|
||
.perm-item{display:flex;align-items:flex-start;gap:7px;font-size:13px;color:var(--text-muted);cursor:pointer}
|
||
.perm-item input{margin-top:2px;flex-shrink:0}
|
||
.perm-item.locked{opacity:.45;cursor:not-allowed}
|
||
@media(max-width:600px){.perm-grid{grid-template-columns:1fr}}
|
||
.toggle{width:40px;height:22px;border-radius:11px;background:var(--border-strong);cursor:pointer;position:relative;transition:background .2s;flex-shrink:0;padding:0;appearance:none;-webkit-appearance:none;border:none}
|
||
.toggle.on{background:var(--green)}
|
||
.toggle::after{content:'';position:absolute;width:16px;height:16px;border-radius:50%;background:white;top:3px;left:3px;transition:transform .2s}
|
||
.toggle.on::after{transform:translateX(18px)}
|
||
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border)}
|
||
.setting-row:last-child{border-bottom:none}
|
||
.setting-info{flex:1;min-width:0}.setting-name{font-size:14px;color:var(--text-normal);margin-bottom:2px}
|
||
.setting-desc{font-size:12px;color:var(--text-faint)}.setting-ctrl{flex-shrink:0;margin-left:16px}
|
||
|
||
.modal-overlay{position:fixed;inset:0;background:var(--bg-overlay);display:none;align-items:center;justify-content:center;z-index:100}
|
||
.modal-overlay.visible{display:flex}
|
||
.modal{background:var(--bg-primary);border-radius:var(--radius-md);width:480px;max-height:80vh;overflow-y:auto;box-shadow:0 8px 48px rgba(0,0,0,.5);animation:modalIn .25s cubic-bezier(.16,1,.3,1)}
|
||
@keyframes modalIn{from{opacity:0;transform:translateY(20px) scale(.96)}to{opacity:1;transform:translateY(0) scale(1)}}
|
||
.modal-header{padding:20px 24px 0;display:flex;align-items:center;justify-content:space-between}
|
||
.modal-header h3{font-size:18px;font-weight:700;color:white}
|
||
.modal-close{background:transparent;color:var(--text-faint);font-size:20px;padding:4px;border-radius:var(--radius-sm);transition:color .15s}
|
||
.modal-close:hover{color:var(--text-normal)}
|
||
.modal-body{padding:20px 24px}
|
||
.modal-footer{padding:16px 24px;background:var(--bg-secondary);border-radius:0 0 var(--radius-md) var(--radius-md);display:flex;justify-content:flex-end;gap:8px}
|
||
|
||
.pagination{display:flex;align-items:center;justify-content:space-between;padding:12px 0;margin-top:4px}
|
||
.pagination-info{font-size:12px;color:var(--text-faint)}
|
||
.pagination-btns{display:flex;gap:4px}
|
||
.page-btn{width:32px;height:32px;border-radius:var(--radius-sm);background:transparent;color:var(--text-muted);font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s}
|
||
.page-btn:hover{background:var(--bg-hover);color:var(--text-normal)}
|
||
.page-btn.active{background:var(--accent);color:white}
|
||
.page-btn:disabled{opacity:.3;cursor:not-allowed}
|
||
|
||
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:var(--radius-md);font-size:13px;font-weight:600;display:none;align-items:center;gap:8px;z-index:200;box-shadow:0 4px 24px rgba(0,0,0,.4);animation:toastIn .3s cubic-bezier(.16,1,.3,1)}
|
||
.toast.visible{display:flex}.toast.success{background:var(--green);color:white}.toast.error{background:var(--red);color:white}.toast.info{background:var(--accent);color:white}
|
||
@keyframes toastIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
|
||
|
||
.activity-item{display:flex;gap:10px;padding:10px 0;border-bottom:1px solid var(--border)}
|
||
.activity-item:last-child{border-bottom:none}
|
||
.activity-icon{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||
.activity-icon svg{width:14px;height:14px}
|
||
.activity-text{font-size:13px;color:var(--text-muted);line-height:1.4}
|
||
.activity-text strong{color:var(--text-normal)}
|
||
.activity-time{font-size:11px;color:var(--text-micro);margin-top:2px}
|
||
|
||
.update-card{display:flex;align-items:flex-start;gap:16px;padding:20px;background:var(--bg-card);border-radius:var(--radius-md);border:1px solid var(--border)}
|
||
.update-icon{width:48px;height:48px;border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||
.update-icon svg{width:24px;height:24px}
|
||
.update-info{flex:1}.update-ver{font-size:18px;font-weight:700;color:white}
|
||
.update-notes{font-size:13px;color:var(--text-faint);margin-top:6px;line-height:1.5}
|
||
|
||
.code-copy{display:flex;align-items:center;gap:8px;background:var(--bg-tertiary);padding:8px 12px;border-radius:var(--radius-sm);margin-top:8px}
|
||
.code-copy code{flex:1;font-family:var(--font-mono);font-size:13px;color:var(--text-link);word-break:break-all}
|
||
.code-copy .btn{padding:4px 10px;font-size:11px}
|
||
|
||
/* Auth overlays */
|
||
.auth-overlay{position:fixed;inset:0;background:var(--bg-tertiary);display:none;align-items:center;justify-content:center;z-index:50}
|
||
.auth-overlay.visible{display:flex}
|
||
.auth-box{background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-md);padding:32px;width:380px}
|
||
.auth-box h2{font-size:20px;font-weight:700;color:white;margin-bottom:20px}
|
||
.auth-error{color:var(--red);font-size:13px;margin-top:10px;min-height:20px}
|
||
.auth-box.wizard{width:560px;max-width:94vw;max-height:92vh;overflow-y:auto}
|
||
.wiz-steps{display:flex;gap:6px;margin-bottom:20px}
|
||
.wiz-dot{height:4px;flex:1;border-radius:2px;background:var(--border);transition:background .2s}
|
||
.wiz-dot.active{background:var(--accent)}
|
||
.wiz-dot.done{background:var(--accent);opacity:.45}
|
||
.wiz-sub{color:var(--text-muted);font-size:14px;margin-bottom:20px;line-height:1.5}
|
||
.wiz-hint{color:var(--text-faint);font-size:12px;margin-top:6px;line-height:1.4}
|
||
.wiz-nav{display:flex;justify-content:space-between;gap:8px;margin-top:24px}
|
||
.wiz-review-row{display:flex;justify-content:space-between;gap:16px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px}
|
||
.wiz-review-row .k{color:var(--text-muted);white-space:nowrap}
|
||
.wiz-review-row .v{color:var(--text-normal);font-weight:600;text-align:right;word-break:break-word}
|
||
.wiz-callout{background:rgba(240,178,50,.1);border:1px solid rgba(240,178,50,.35);color:var(--yellow);font-size:13px;padding:10px 12px;border-radius:var(--radius-sm);margin-top:16px;line-height:1.45}
|
||
.wiz-skip{display:block;width:100%;text-align:center;margin-top:14px;color:var(--text-faint);font-size:12px;cursor:pointer;text-decoration:underline;background:none}
|
||
.wiz-skip:hover{color:var(--text-muted)}
|
||
.wiz-toggle-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}
|
||
.wiz-toggle-row .lbl{font-size:14px;color:var(--text-normal)}
|
||
|
||
/* Log viewer */
|
||
.log-toolbar{display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap}
|
||
.level-toggle{padding:4px 10px;border-radius:10px;font-size:11px;font-weight:700;background:var(--bg-hover);color:var(--text-micro);transition:all .15s;text-transform:uppercase;letter-spacing:.03em}
|
||
.level-toggle:hover{color:var(--text-muted)}
|
||
.level-toggle.active-debug{background:rgba(128,132,142,.2);color:var(--text-muted)}
|
||
.level-toggle.active-info{background:rgba(35,165,90,.2);color:var(--green)}
|
||
.level-toggle.active-warn{background:rgba(240,178,50,.2);color:var(--yellow)}
|
||
.level-toggle.active-error{background:rgba(242,63,67,.2);color:var(--red)}
|
||
.log-output{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);font-family:var(--font-mono);font-size:12px;line-height:1.6;padding:8px 12px;overflow-y:auto;height:calc(100vh - 240px);white-space:pre-wrap;word-break:break-all}
|
||
.log-line{padding:1px 0}
|
||
.log-line .log-ts{color:var(--text-micro);margin-right:8px}
|
||
.log-line .log-lvl{display:inline-block;width:44px;font-weight:700;margin-right:6px}
|
||
.log-line .log-src{color:var(--text-faint);margin-right:8px;font-size:11px}
|
||
.log-line.l-debug .log-lvl{color:var(--text-micro)}
|
||
.log-line.l-info .log-lvl{color:var(--green)}
|
||
.log-line.l-warn .log-lvl{color:var(--yellow)}
|
||
.log-line.l-error .log-lvl{color:var(--red)}
|
||
.log-line.l-error{color:#f5a0a2}
|
||
.log-status{display:flex;align-items:center;gap:8px;margin-top:8px;font-size:11px;color:var(--text-micro)}
|
||
.log-status .dot-live{width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite}
|
||
.log-status .dot-off{width:6px;height:6px;border-radius:50%;background:var(--red)}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||
|
||
.hidden{display:none!important}
|
||
|
||
@media(max-width:900px){.sidebar{width:200px}.content{padding:24px 20px}.stat-grid{grid-template-columns:repeat(auto-fill,minmax(160px,1fr))}}
|
||
@media(max-width:600px){.admin{flex-direction:column}.sidebar{width:100%;height:auto;max-height:56px;overflow:hidden}.content{padding:16px 12px}.stat-grid{grid-template-columns:1fr 1fr}.filter-bar{flex-direction:column}.modal{width:95vw;max-height:90vh}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Auth: Setup (first-run wizard; content rendered by renderWizard) -->
|
||
<div class="auth-overlay" id="setupOverlay">
|
||
<div class="auth-box wizard" id="wizardBox"></div>
|
||
</div>
|
||
|
||
<!-- Auth: Setup Success -->
|
||
<div class="auth-overlay" id="setupSuccessOverlay">
|
||
<div class="auth-box">
|
||
<h2>Setup Complete!</h2>
|
||
<p style="color:var(--text-muted);font-size:14px;margin-bottom:16px">Your owner account has been created. Here's your invite code:</p>
|
||
<div class="code-copy"><code id="inviteCode"></code><button class="btn btn-ghost" onclick="copyInvite()">Copy</button></div>
|
||
<p style="color:var(--yellow);font-size:13px;margin:16px 0">Save this code! Share it with people you want to invite.</p>
|
||
<div id="setupWarnings"></div>
|
||
<div id="setupRestart" style="display:none">
|
||
<p style="color:var(--text-muted);font-size:14px;margin-bottom:8px"><span class="spinner" style="display:inline-block;width:13px;height:13px;border:2px solid var(--border-strong);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite;vertical-align:-2px;margin-right:6px"></span>Applying your settings — the server is restarting…</p>
|
||
<p style="font-size:14px;margin-bottom:8px">When it's back, open: <a id="restartLink" href="#" style="color:var(--text-link);word-break:break-all"></a></p>
|
||
<p class="wiz-hint" id="restartHint">This can take a few seconds. If the page doesn't redirect on its own, click the link above. You may need to sign in again, and with a self-signed certificate your browser may show a one-time security warning — choose Advanced → Continue.</p>
|
||
</div>
|
||
<button class="btn btn-accent" id="setupContinueBtn" style="width:100%">Continue to Admin Panel</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Auth: Login -->
|
||
<div class="auth-overlay" id="loginOverlay">
|
||
<div class="auth-box">
|
||
<h2>OwnCord Admin</h2>
|
||
<div class="form-group"><label class="form-label">Username</label><input class="form-input" id="loginUser" autocomplete="username" placeholder="admin"></div>
|
||
<div class="form-group"><label class="form-label">Password</label><input class="form-input" id="loginPass" type="password" autocomplete="current-password" placeholder="••••••••"></div>
|
||
<button class="btn btn-accent" id="loginBtn" style="width:100%">Sign In</button>
|
||
<div class="auth-error" id="loginErr"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Admin Shell -->
|
||
<div class="admin hidden" id="adminShell">
|
||
<div class="sidebar">
|
||
<div class="sidebar-header"><div class="sidebar-brand">
|
||
<div class="sidebar-logo"><svg viewBox="0 0 24 24" fill="white"><path d="M12 2L4 5.5v5c0 5.25 3.4 10.15 8 11.5 4.6-1.35 8-6.25 8-11.5v-5L12 2zm0 3l1.5 3h3.2l-2.6 1.9 1 3.1L12 11.1 8.9 13l1-3.1L7.3 8h3.2L12 5z"/></svg></div>
|
||
<div><div class="sidebar-title">OwnCord</div><div class="sidebar-subtitle">Admin Panel</div></div>
|
||
</div></div>
|
||
<nav class="sidebar-nav" id="sidebarNav" role="navigation" aria-label="Admin sections"></nav>
|
||
<div class="sidebar-footer" id="sidebarFooter">OwnCord</div>
|
||
</div>
|
||
<div class="content" id="content"></div>
|
||
</div>
|
||
|
||
<!-- Modal -->
|
||
<div class="modal-overlay" id="modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||
<div class="modal" id="modalInner"></div>
|
||
</div>
|
||
|
||
<!-- Toast -->
|
||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||
|
||
<script>
|
||
/* ═══ Icons ═══ */
|
||
const I={
|
||
dashboard:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>',
|
||
users:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
|
||
channels:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>',
|
||
settings:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>',
|
||
backup:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>',
|
||
updates:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg>',
|
||
audit:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
|
||
logout:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>',
|
||
edit:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
|
||
trash:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
|
||
ban:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>',
|
||
disconnect:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
||
check:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
|
||
plus:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
|
||
refresh:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>',
|
||
download:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
|
||
voice:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>',
|
||
megaphone:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||
logs:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
|
||
lock:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
|
||
plugins:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v6"/><path d="M18 3v6"/><path d="M4 9h16v4a8 8 0 0 1-16 0z"/><path d="M12 21v-4"/></svg>',
|
||
upload:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
|
||
shield:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
|
||
arrowUp:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
arrowDown:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
|
||
smile:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>',
|
||
};
|
||
|
||
/* ═══ State ═══ */
|
||
const PAGE_SIZE=50;
|
||
const state={section:'dashboard',token:localStorage.getItem('admin_token')||'',
|
||
me:null,
|
||
usersPage:1,auditPage:1,auditSearch:'',auditActionFilter:'all',auditCache:[],settingsChanged:false,backupRunning:false,updateApplying:false,
|
||
cachedStats:null,cachedUpdate:null,channelCache:{},roleList:[],pluginRuntime:'unknown',pluginBusy:false,
|
||
logEntries:[],logLevels:{DEBUG:true,INFO:true,WARN:true,ERROR:true},
|
||
logSearch:'',logAutoScroll:true,logPaused:false,logEventSource:null,logReconnectTimer:null,logConnectSeq:0,logMaxLines:2000};
|
||
|
||
/* ═══ API ═══ */
|
||
/* A 401 means the admin session is gone. Handle it here rather than letting
|
||
every call site toast "invalid or expired session" forever while the panel
|
||
stays on screen with no way back to the login form. */
|
||
function handleSessionExpired(){
|
||
state.logConnectSeq++;
|
||
if(state.logEventSource){state.logEventSource.close();state.logEventSource=null}
|
||
if(state.logReconnectTimer){clearTimeout(state.logReconnectTimer);state.logReconnectTimer=null}
|
||
state.token='';state.me=null;localStorage.removeItem('admin_token');
|
||
const err=document.getElementById('loginErr');if(err)err.textContent='Your session expired — sign in again.';
|
||
showOverlay('loginOverlay');
|
||
}
|
||
|
||
async function api(method,path,body){
|
||
const opts={method,headers:{'Authorization':'Bearer '+state.token,'Content-Type':'application/json'}};
|
||
if(body!==undefined)opts.body=JSON.stringify(body);
|
||
const res=await fetch('/admin/api'+path,opts);
|
||
if(res.status===401){handleSessionExpired();throw new Error('Your session expired — sign in again.')}
|
||
if(res.status===204)return null;
|
||
const data=await res.json();
|
||
if(!res.ok)throw new Error(data.message||res.statusText);
|
||
return data;
|
||
}
|
||
|
||
/* ═══ Permissions ═══ */
|
||
/* The panel perimeter admits any role holding one moderation bit, so what a
|
||
principal may actually do varies. GET /admin/api/me reports the caller's
|
||
role mask; tabs and row actions hide what it cannot use. Hiding is an
|
||
affordance only — every route re-checks the bit server-side. */
|
||
const PERM={MANAGE_CHANNELS:0x20000,KICK_MEMBERS:0x40000,BAN_MEMBERS:0x80000,
|
||
MUTE_MEMBERS:0x100000,MANAGE_ROLES:0x1000000,MANAGE_SERVER:0x2000000,
|
||
VIEW_AUDIT_LOG:0x8000000,ADMINISTRATOR:0x40000000};
|
||
function can(bit){
|
||
const p=(state.me&&state.me.permissions)||0;
|
||
if((p&PERM.ADMINISTRATOR)!==0)return true;
|
||
return (p&bit)===bit;
|
||
}
|
||
/* Owner-only routes (tokens, backups, updates) gate on role position, not on
|
||
a bit, so the mask alone cannot answer this. */
|
||
function isOwner(){return !!(state.me&&state.me.is_owner)}
|
||
|
||
/* ═══ Utilities ═══ */
|
||
function esc(s){if(s===null||s===undefined)return'';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||
/* Escape for embedding inside a single-quoted JS string in an inline onclick
|
||
attribute: JS-escape backslashes and single quotes first, then HTML-escape.
|
||
Without this a name containing ' breaks out of the string literal (XSS). */
|
||
function jsq(s){return esc(String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'"))}
|
||
function fmtBytes(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';if(b<1073741824)return(b/1048576).toFixed(1)+' MB';return(b/1073741824).toFixed(2)+' GB'}
|
||
function actionBadge(a){if(!a)return'badge-muted';if(a.includes('ban')||a.includes('kick')||a.includes('delete'))return'badge-red';if(a.includes('create'))return'badge-green';if(a.includes('update'))return'badge-yellow';return'badge-accent'}
|
||
function actionColor(a){if(!a)return'var(--accent)';if(a.includes('ban')||a.includes('kick')||a.includes('delete'))return'var(--red)';if(a.includes('create'))return'var(--green)';if(a.includes('update'))return'var(--yellow)';return'var(--accent)'}
|
||
/* Roles are createable now, so the four seeded ids are a fallback, not the set.
|
||
Anything role-shaped prefers the live list (state.roleList, filled by the
|
||
Roles section and by openEditUser) and only then the seeded map — otherwise a
|
||
custom role renders as "Member" in its own colour. */
|
||
function roleFromCache(rid){return (state.roleList||[]).find(r=>r.id===rid)||null}
|
||
function roleColor(rid){
|
||
const r=roleFromCache(rid);
|
||
if(r&&r.color)return r.color;
|
||
return{1:'var(--role-owner)',2:'var(--role-admin)',3:'var(--role-mod)'}[rid]||'var(--role-member)';
|
||
}
|
||
/* name is the server-supplied role_name where the caller has one (the users
|
||
list ships it); it wins over any cache because it is always current. */
|
||
function roleName(rid,name){
|
||
if(name)return name;
|
||
const r=roleFromCache(rid);
|
||
if(r)return r.name;
|
||
return{1:'Owner',2:'Admin',3:'Moderator',4:'Member'}[rid]||'Member';
|
||
}
|
||
|
||
function showToast(msg,type='success'){
|
||
const t=document.getElementById('toast');
|
||
t.className='toast visible '+type;
|
||
t.innerHTML=(type==='success'?I.check:type==='error'?I.ban:I.check)+'<span>'+esc(msg)+'</span>';
|
||
clearTimeout(window._tt);window._tt=setTimeout(()=>t.classList.remove('visible'),3000);
|
||
}
|
||
|
||
function openModal(html){
|
||
const o=document.getElementById('modal');
|
||
document.getElementById('modalInner').innerHTML=html;
|
||
o.classList.add('visible');o.setAttribute('aria-hidden','false');
|
||
}
|
||
function closeModal(){
|
||
const o=document.getElementById('modal');
|
||
o.classList.remove('visible');o.setAttribute('aria-hidden','true');
|
||
}
|
||
|
||
/* ═══ Auth ═══ */
|
||
function hideAll(){['setupOverlay','setupSuccessOverlay','loginOverlay'].forEach(id=>document.getElementById(id).classList.remove('visible'));document.getElementById('adminShell').classList.add('hidden')}
|
||
function showOverlay(id){hideAll();document.getElementById(id).classList.add('visible')}
|
||
function showApp(){hideAll();document.getElementById('adminShell').classList.remove('hidden')}
|
||
|
||
async function checkAuth(){
|
||
try{const r=await fetch('/admin/api/setup/status');const d=await r.json();if(d.needs_setup){wizInit(d.defaults);showOverlay('setupOverlay');return}}catch(e){console.error('setup check:',e)}
|
||
if(!state.token){showOverlay('loginOverlay');return}
|
||
try{await enterApp()}catch(e){showOverlay('loginOverlay')}
|
||
}
|
||
|
||
/* Loads the caller's permissions before the first render — the nav is built
|
||
from them, so rendering earlier would flash tabs the principal cannot open.
|
||
Throws on an unusable session so callers fall back to the login overlay. */
|
||
/* A #section fragment deep-links the panel: the desktop client's "Audit Log"
|
||
entry opens /admin#audit, so the operator lands on the log rather than on the
|
||
dashboard with a tab still to find. Applied before the permission fallback
|
||
below, so a fragment naming a section the principal may not open falls back
|
||
to the dashboard exactly like a stale stored section does. */
|
||
function sectionFromHash(){
|
||
const id=(location.hash||'').replace(/^#/,'');
|
||
return NAV.some(n=>n.id===id)?id:'';
|
||
}
|
||
|
||
async function enterApp(){
|
||
state.me=await api('GET','/me');
|
||
const deepLink=sectionFromHash();
|
||
if(deepLink)state.section=deepLink;
|
||
if(!sectionAllowed(state.section))state.section='dashboard';
|
||
showApp();renderNav();renderContent();
|
||
}
|
||
|
||
/* ═══ First-Run Setup Wizard ═══ */
|
||
/* Multi-step overlay shown while needs_setup is true. Collects the owner
|
||
account plus the basics (name, port, security, uploads, voice, access) and
|
||
submits everything as one POST /admin/api/setup — the server writes both
|
||
the settings table and config.yaml, and restarts itself if startup-only
|
||
values changed. "Skip" falls back to the legacy account-only payload. */
|
||
const wiz={step:0,skip:false,defaults:null,data:{},busy:false};
|
||
const WIZ_STEP_COUNT=6;
|
||
|
||
function wizInit(defaults){
|
||
wiz.defaults=defaults||null;wiz.step=0;wiz.skip=false;wiz.busy=false;
|
||
const d=defaults||{};
|
||
wiz.data={username:'',password:'',confirm:'',
|
||
server_name:d.server_name||'OwnCord Server',
|
||
motd:(d.motd===undefined||d.motd===null)?'Welcome!':d.motd,
|
||
registration_open:!!d.registration_open,
|
||
port:d.port||8443,
|
||
tls_mode:d.tls_mode||'self_signed',
|
||
tls_domain:d.tls_domain||'',
|
||
upload_max_size_mb:d.upload_max_size_mb||100,
|
||
voice_quality:d.voice_quality||'medium',
|
||
voice_auto_download:d.voice_auto_download!==undefined?!!d.voice_auto_download:true};
|
||
renderWizard();
|
||
}
|
||
|
||
function wizDots(){
|
||
let h='<div class="wiz-steps">';
|
||
for(let i=0;i<WIZ_STEP_COUNT;i++)h+='<div class="wiz-dot '+(i===wiz.step?'active':i<wiz.step?'done':'')+'"></div>';
|
||
return h+'</div>';
|
||
}
|
||
|
||
function wizField(id,label,input){return '<div class="form-group"><label class="form-label" for="'+id+'">'+label+'</label>'+input+'</div>'}
|
||
|
||
function renderWizard(){
|
||
const box=document.getElementById('wizardBox');
|
||
const d=wiz.data;
|
||
let h=wizDots();
|
||
const err='<div class="auth-error" id="wizErr"></div>';
|
||
const nav=nextLabel=>'<div class="wiz-nav"><button class="btn btn-ghost" onclick="wizBack()">Back</button><button class="btn btn-accent" id="wizNextBtn" onclick="wizNext()">'+nextLabel+'</button></div>';
|
||
switch(wiz.step){
|
||
case 0:
|
||
h+='<h2>Welcome to OwnCord</h2>'
|
||
+'<p class="wiz-sub">Your own private chat server is almost ready. This one-minute setup creates your admin account and configures the basics — no config files to edit, everything is saved for you.</p>'
|
||
+'<button class="btn btn-accent" style="width:100%" onclick="wizNext()">Get Started</button>'
|
||
+'<button class="wiz-skip" onclick="wizSkip()">Skip the questions — use recommended defaults</button>';
|
||
break;
|
||
case 1:
|
||
h+='<h2>Create your admin account</h2>'
|
||
+'<p class="wiz-sub">This is the owner account for managing the server. Pick a strong password — this account can do everything.</p>'
|
||
+wizField('wizUser','Username','<input class="form-input" id="wizUser" autocomplete="username" placeholder="Choose a username" value="'+esc(d.username)+'">')
|
||
+wizField('wizPass','Password','<input class="form-input" id="wizPass" type="password" autocomplete="new-password" placeholder="Min 8 characters">')
|
||
+wizField('wizConfirm','Confirm Password','<input class="form-input" id="wizConfirm" type="password" autocomplete="new-password" placeholder="Re-enter password">')
|
||
+err+nav(wiz.skip?'Create Owner Account':'Next');
|
||
break;
|
||
case 2:
|
||
h+='<h2>Server basics</h2>'
|
||
+'<p class="wiz-sub">How your server introduces itself, and how people connect to it.</p>'
|
||
+wizField('wizName','Server Name','<input class="form-input" id="wizName" maxlength="100" value="'+esc(d.server_name)+'">')
|
||
+wizField('wizPort','Port','<input class="form-input" id="wizPort" type="number" min="1" max="65535" value="'+esc(d.port)+'"><div class="wiz-hint">The network port people connect to. Keep the default unless it clashes with something else on this machine.</div>')
|
||
+wizField('wizTLS','Security','<select class="filter-select" id="wizTLS" style="width:100%" onchange="wizTLSChanged()">'
|
||
+'<option value="self_signed"'+(d.tls_mode==='self_signed'?' selected':'')+'>Self-signed HTTPS — recommended</option>'
|
||
+'<option value="acme"'+(d.tls_mode==='acme'?' selected':'')+'>Let's Encrypt certificate — needs a public domain</option>'
|
||
+'<option value="manual"'+(d.tls_mode==='manual'?' selected':'')+'>Manual certificates — advanced</option>'
|
||
+'<option value="off"'+(d.tls_mode==='off'?' selected':'')+'>No encryption — not recommended</option>'
|
||
+'</select><div class="wiz-hint" id="wizTLSHint"></div>')
|
||
+'<div class="form-group" id="wizDomainGroup" style="display:none"><label class="form-label" for="wizDomain">Domain</label><input class="form-input" id="wizDomain" placeholder="chat.example.com" value="'+esc(d.tls_domain)+'"><div class="wiz-hint">Must already point at this machine, with ports 80 and 443 reachable from the internet. If that isn't set up yet, pick self-signed for now — you can switch later.</div></div>'
|
||
+err+nav('Next');
|
||
break;
|
||
case 3:
|
||
h+='<h2>Uploads & voice</h2>'
|
||
+'<p class="wiz-sub">Limits for file sharing and voice chat quality.</p>'
|
||
+wizField('wizUpload','Max upload size (MB)','<input class="form-input" id="wizUpload" type="number" min="1" max="10240" value="'+esc(d.upload_max_size_mb)+'"><div class="wiz-hint">The largest file anyone can share. 100 MB suits most servers.</div>')
|
||
+'<div class="wiz-toggle-row"><div><div class="lbl">Voice chat</div><div class="wiz-hint" style="margin-top:2px">Downloads the voice engine (LiveKit, ~40 MB, one time) from the official LiveKit project and manages it for you. Turn off only if you run your own LiveKit server.</div></div><button class="toggle'+(d.voice_auto_download?' on':'')+'" id="wizVoiceDl" onclick="this.classList.toggle(\'on\')"></button></div>'
|
||
+wizField('wizVoice','Voice quality','<select class="filter-select" id="wizVoice" style="width:100%">'
|
||
+'<option value="low"'+(d.voice_quality==='low'?' selected':'')+'>Low — least bandwidth, phone-call quality</option>'
|
||
+'<option value="medium"'+(d.voice_quality==='medium'?' selected':'')+'>Medium — recommended balance</option>'
|
||
+'<option value="high"'+(d.voice_quality==='high'?' selected':'')+'>High — best quality, most bandwidth</option>'
|
||
+'</select>')
|
||
+err+nav('Next');
|
||
break;
|
||
case 4:
|
||
h+='<h2>Who can join?</h2>'
|
||
+'<p class="wiz-sub">You'll get an invite code either way — these control what happens after that.</p>'
|
||
+'<div class="wiz-toggle-row"><div><div class="lbl">Open registration</div><div class="wiz-hint" style="margin-top:2px">Allow new people to create accounts using invite codes. Turn off to lock the server to existing members.</div></div><button class="toggle'+(d.registration_open?' on':'')+'" id="wizReg" onclick="this.classList.toggle(\'on\')"></button></div>'
|
||
+wizField('wizMotd','Welcome message','<input class="form-input" id="wizMotd" maxlength="500" value="'+esc(d.motd)+'" placeholder="Welcome!"><div class="wiz-hint">Shown to members when they connect.</div>')
|
||
+err+nav('Next');
|
||
break;
|
||
case 5:{
|
||
const secLabel={self_signed:'Self-signed HTTPS',acme:'Let's Encrypt ('+esc(d.tls_domain)+')',manual:'Manual certificates',off:'No encryption'}[d.tls_mode]||esc(d.tls_mode);
|
||
const rows=[['Username',esc(d.username)],['Server name',esc(d.server_name)],['Port',esc(d.port)],['Security',secLabel],['Max upload',esc(d.upload_max_size_mb)+' MB'],['Voice chat',d.voice_auto_download?'Automatic (LiveKit downloaded for you)':'Self-managed / off'],['Voice quality',esc(d.voice_quality)],['Open registration',d.registration_open?'Yes':'No'],['Welcome message',esc(d.motd)||'—']];
|
||
h+='<h2>Review & finish</h2><p class="wiz-sub">Everything look right? You can change any of this later in the admin panel.</p>';
|
||
rows.forEach(r=>{h+='<div class="wiz-review-row"><span class="k">'+r[0]+'</span><span class="v">'+r[1]+'</span></div>'});
|
||
if(wizNeedsRestart())h+='<div class="wiz-callout">The server will restart once to apply your connection settings, then point you to the right address.</div>';
|
||
h+=err+nav('Finish Setup');
|
||
break;}
|
||
}
|
||
box.innerHTML=h;
|
||
if(wiz.step===2)wizTLSChanged();
|
||
box.querySelectorAll('input').forEach(el=>el.addEventListener('keydown',e=>{if(e.key==='Enter')wizNext()}));
|
||
const first=box.querySelector('input');if(first)first.focus();
|
||
}
|
||
|
||
function wizTLSChanged(){
|
||
const sel=document.getElementById('wizTLS');if(!sel)return;
|
||
const hints={
|
||
self_signed:'Works out of the box on your network. Browsers show a one-time security warning you can safely accept.',
|
||
acme:'A free, trusted certificate from Let’s Encrypt. Only choose this if you own a domain that points at this machine.',
|
||
manual:'Bring your own certificate files (data/cert.pem and data/key.pem).',
|
||
off:'Traffic is unencrypted. Only for testing, or behind a reverse proxy that handles HTTPS.'};
|
||
document.getElementById('wizTLSHint').textContent=hints[sel.value]||'';
|
||
document.getElementById('wizDomainGroup').style.display=sel.value==='acme'?'block':'none';
|
||
}
|
||
|
||
function wizNeedsRestart(){
|
||
const f=wiz.defaults;if(!f)return false;const d=wiz.data;
|
||
return Number(d.port)!==f.port||d.tls_mode!==f.tls_mode||Number(d.upload_max_size_mb)!==f.upload_max_size_mb||d.voice_quality!==f.voice_quality||d.voice_auto_download!==!!f.voice_auto_download||(d.tls_mode==='acme'&&d.tls_domain!==(f.tls_domain||''));
|
||
}
|
||
|
||
function wizCollect(){
|
||
const g=id=>{const el=document.getElementById(id);return el?el.value:undefined};
|
||
const d=wiz.data;
|
||
switch(wiz.step){
|
||
case 1:d.username=(g('wizUser')||'').trim();d.password=g('wizPass')||'';d.confirm=g('wizConfirm')||'';break;
|
||
case 2:d.server_name=(g('wizName')||'').trim();d.port=g('wizPort');d.tls_mode=g('wizTLS')||d.tls_mode;d.tls_domain=(g('wizDomain')||'').trim();break;
|
||
case 3:{d.upload_max_size_mb=g('wizUpload');d.voice_quality=g('wizVoice')||d.voice_quality;const vd=document.getElementById('wizVoiceDl');if(vd)d.voice_auto_download=vd.classList.contains('on');break}
|
||
case 4:{const t=document.getElementById('wizReg');if(t)d.registration_open=t.classList.contains('on');d.motd=(g('wizMotd')||'').trim();break}
|
||
}
|
||
}
|
||
|
||
function wizBack(){
|
||
if(wiz.busy)return;
|
||
wizCollect();
|
||
if(wiz.step===1)wiz.skip=false;
|
||
wiz.step=Math.max(0,wiz.step-1);
|
||
renderWizard();
|
||
}
|
||
|
||
function wizSkip(){wiz.skip=true;wiz.step=1;renderWizard()}
|
||
|
||
function wizNext(){
|
||
if(wiz.busy)return;
|
||
wizCollect();
|
||
const d=wiz.data;
|
||
const fail=msg=>{const e=document.getElementById('wizErr');if(e)e.textContent=msg};
|
||
switch(wiz.step){
|
||
case 1:
|
||
if(!d.username||!d.password)return fail('Username and password are required.');
|
||
if(d.password.length<8)return fail('Password must be at least 8 characters.');
|
||
if(d.password!==d.confirm)return fail('Passwords do not match.');
|
||
if(wiz.skip)return wizFinish();
|
||
break;
|
||
case 2:{
|
||
if(!d.server_name)return fail('Server name is required.');
|
||
const p=Number(d.port);
|
||
if(!Number.isInteger(p)||p<1||p>65535)return fail('Port must be a number between 1 and 65535.');
|
||
if(d.tls_mode==='acme'&&!d.tls_domain)return fail('A domain is required for Let’s Encrypt.');
|
||
break;}
|
||
case 3:{
|
||
const u=Number(d.upload_max_size_mb);
|
||
if(!Number.isInteger(u)||u<1||u>10240)return fail('Max upload size must be between 1 and 10240 MB.');
|
||
break;}
|
||
case 5:return wizFinish();
|
||
}
|
||
wiz.step++;renderWizard();
|
||
}
|
||
|
||
async function wizFinish(){
|
||
if(wiz.busy)return;wiz.busy=true;
|
||
const btn=document.getElementById('wizNextBtn');if(btn){btn.disabled=true;btn.innerHTML='<div class="spinner"></div> Setting up…'}
|
||
const d=wiz.data;
|
||
const body={username:d.username,password:d.password};
|
||
if(!wiz.skip){
|
||
body.wizard={server_name:d.server_name,motd:d.motd,registration_open:!!d.registration_open,
|
||
port:Number(d.port),tls_mode:d.tls_mode,upload_max_size_mb:Number(d.upload_max_size_mb),
|
||
voice_quality:d.voice_quality,voice_auto_download:!!d.voice_auto_download};
|
||
if(d.tls_mode==='acme')body.wizard.tls_domain=d.tls_domain;
|
||
}
|
||
try{
|
||
const r=await fetch('/admin/api/setup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||
const resp=await r.json();if(!r.ok)throw new Error(resp.message||'Setup failed');
|
||
state.token=resp.token;localStorage.setItem('admin_token',state.token);
|
||
document.getElementById('inviteCode').textContent=resp.invite_code;
|
||
const warn=document.getElementById('setupWarnings');warn.innerHTML='';
|
||
(resp.warnings||[]).forEach(wm=>{const div=document.createElement('div');div.className='wiz-callout';div.style.marginBottom='12px';div.textContent=wm;warn.appendChild(div)});
|
||
showOverlay('setupSuccessOverlay');
|
||
if(resp.restart_required&&resp.restart_url)beginRestartWait(resp.restart_url);
|
||
}catch(e){
|
||
const err=document.getElementById('wizErr');if(err)err.textContent=e.message;
|
||
const b=document.getElementById('wizNextBtn');if(b){b.disabled=false;b.textContent=wiz.step===1?'Create Owner Account':'Finish Setup'}
|
||
}finally{wiz.busy=false}
|
||
}
|
||
|
||
/* Poll until the restarted server answers, then follow it. no-cors: an opaque
|
||
response resolving means "up" even across a port change; rejection means
|
||
still down. A self-signed cert the browser hasn't accepted yet keeps the
|
||
poll failing — the visible link is the primary path, this redirect is
|
||
best-effort sugar. */
|
||
function beginRestartWait(url){
|
||
document.getElementById('setupContinueBtn').style.display='none';
|
||
document.getElementById('setupRestart').style.display='block';
|
||
const link=document.getElementById('restartLink');link.href=url;link.textContent=url;
|
||
let elapsed=0;
|
||
setTimeout(function poll(){
|
||
fetch(url+'/api/setup/status',{mode:'no-cors',cache:'no-store'})
|
||
.then(()=>{window.location=url})
|
||
.catch(()=>{elapsed+=2000;if(elapsed<60000)setTimeout(poll,2000)});
|
||
},4000);
|
||
}
|
||
|
||
document.getElementById('setupContinueBtn').onclick=()=>{enterApp().catch(()=>showOverlay('loginOverlay'))};
|
||
function copyInvite(){navigator.clipboard.writeText(document.getElementById('inviteCode').textContent).then(()=>showToast('Copied!','info')).catch(()=>showToast('Copy failed','error'))}
|
||
|
||
document.getElementById('loginBtn').onclick=async()=>{
|
||
const btn=document.getElementById('loginBtn');
|
||
const u=document.getElementById('loginUser').value.trim(),p=document.getElementById('loginPass').value,err=document.getElementById('loginErr');
|
||
err.textContent='';
|
||
if(!u||!p){err.textContent='Username and password are required.';return}
|
||
// Each submit counts against the login lockout counter — don't spend two.
|
||
if(btn.disabled)return;
|
||
btn.disabled=true;
|
||
try{const r=await fetch('/api/v1/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});const d=await r.json();if(!r.ok)throw new Error(d.message||'Login failed');
|
||
state.token=d.token;localStorage.setItem('admin_token',state.token);await enterApp();
|
||
}catch(e){err.textContent=e.message}
|
||
finally{btn.disabled=false}
|
||
};
|
||
|
||
/* Wizard inputs bind Enter dynamically in renderWizard(). */
|
||
['loginUser','loginPass'].forEach(id=>document.getElementById(id).addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('loginBtn').click()}));
|
||
|
||
/* ═══ Nav ═══ */
|
||
/* `allowed` mirrors the server-side gate on each section's routes; omitted
|
||
means perimeter-level (any principal the panel let in). */
|
||
const NAV=[
|
||
{section:'Management'},
|
||
{id:'dashboard',label:'Dashboard',icon:I.dashboard},
|
||
{id:'users',label:'Users',icon:I.users},
|
||
{id:'channels',label:'Channels',icon:I.channels,allowed:()=>can(PERM.MANAGE_CHANNELS)},
|
||
{id:'roles',label:'Roles',icon:I.shield,allowed:()=>can(PERM.MANAGE_ROLES)},
|
||
{id:'emoji',label:'Emoji',icon:I.smile,allowed:()=>can(PERM.MANAGE_SERVER)},
|
||
{sep:true},
|
||
{section:'Configuration'},
|
||
{id:'audit',label:'Audit Log',icon:I.audit,allowed:()=>can(PERM.VIEW_AUDIT_LOG)},
|
||
{id:'tokens',label:'API Tokens',icon:I.lock,allowed:isOwner},
|
||
{id:'plugins',label:'Plugins',icon:I.plugins,allowed:()=>can(PERM.ADMINISTRATOR)},
|
||
{id:'logs',label:'Server Logs',icon:I.logs,allowed:()=>can(PERM.ADMINISTRATOR)},
|
||
{id:'settings',label:'Settings',icon:I.settings,unsaved:()=>state.settingsChanged,allowed:()=>can(PERM.MANAGE_SERVER)},
|
||
{id:'backups',label:'Backups',icon:I.backup,allowed:isOwner},
|
||
{id:'updates',label:'Updates',icon:I.updates,allowed:isOwner},
|
||
{sep:true},
|
||
{id:'logout',label:'Sign Out',icon:I.logout,danger:true},
|
||
];
|
||
|
||
/* True when the principal may open the section. Unknown ids are refused so a
|
||
stale localStorage/section value can't route into a hidden page. */
|
||
function sectionAllowed(id){
|
||
const n=NAV.find(x=>x.id===id);
|
||
if(!n)return false;
|
||
return !n.allowed||n.allowed();
|
||
}
|
||
|
||
/* Drops section labels with no visible item under them and separators that
|
||
would end up leading, trailing, or doubled once entries are filtered out. */
|
||
function visibleNav(){
|
||
const kept=NAV.filter(n=>n.section||n.sep||!n.allowed||n.allowed());
|
||
const out=[];
|
||
for(let i=0;i<kept.length;i++){
|
||
const n=kept[i];
|
||
if(n.section){
|
||
const next=kept[i+1];
|
||
if(!next||next.section||next.sep)continue;
|
||
}
|
||
if(n.sep){
|
||
const prev=out[out.length-1];
|
||
if(!prev||prev.sep)continue;
|
||
}
|
||
out.push(n);
|
||
}
|
||
while(out.length&&out[out.length-1].sep)out.pop();
|
||
return out;
|
||
}
|
||
|
||
function renderNav(){
|
||
document.getElementById('sidebarNav').innerHTML=visibleNav().map(n=>{
|
||
if(n.section)return'<div class="sidebar-label">'+n.section+'</div>';
|
||
if(n.sep)return'<div class="sidebar-sep"></div>';
|
||
const active=state.section===n.id?'active':'';
|
||
const cls=n.danger?'danger':'';
|
||
const unsaved=n.unsaved&&n.unsaved()?'<span class="unsaved-dot"></span>':'';
|
||
if(n.id==='logout')return'<button class="nav-item '+cls+'" onclick="doLogout()">'+n.icon+'<span>'+n.label+'</span></button>';
|
||
return'<button class="nav-item '+active+' '+cls+'" role="tab" onclick="navigateTo(\''+n.id+'\')">'+unsaved+n.icon+'<span>'+n.label+'</span></button>';
|
||
}).join('');
|
||
}
|
||
|
||
function navigateTo(id){
|
||
if(!sectionAllowed(id)){showToast('You do not have permission to open that section','error');return}
|
||
try{
|
||
if(state.section==='logs'&&id!=='logs'){state.logConnectSeq++;if(state.logEventSource){state.logEventSource.close();state.logEventSource=null}if(state.logReconnectTimer){clearTimeout(state.logReconnectTimer);state.logReconnectTimer=null}}
|
||
state.section=id;renderNav();renderContent();
|
||
}catch(err){
|
||
console.error('[Admin] Tab navigation failed for "'+id+'":', err);
|
||
var c=document.getElementById('content');
|
||
if(c)c.innerHTML='<div class="page-title">Error</div><p style="color:var(--red)">Failed to navigate to '+esc(id)+': '+esc(err&&err.message||String(err))+'</p><button class="btn btn-accent" onclick="navigateTo(\'dashboard\')">Back to Dashboard</button>';
|
||
}
|
||
}
|
||
|
||
function doLogout(){state.logConnectSeq++;if(state.logEventSource){state.logEventSource.close();state.logEventSource=null}if(state.logReconnectTimer){clearTimeout(state.logReconnectTimer);state.logReconnectTimer=null}state.token='';state.me=null;localStorage.removeItem('admin_token');showOverlay('loginOverlay')}
|
||
|
||
/* ═══ Content Router ═══ */
|
||
function renderContent(){
|
||
const c=document.getElementById('content');if(!c)return;c.scrollTop=0;
|
||
const r={dashboard:renderDashboard,users:renderUsers,channels:renderChannels,roles:renderRoles,emoji:renderEmoji,audit:renderAudit,tokens:renderTokens,plugins:renderPlugins,logs:renderLogs,settings:renderSettings,backups:renderBackups,updates:renderUpdates};
|
||
c.innerHTML='<div class="page-title">Loading...</div>';
|
||
const fn=r[state.section];
|
||
if(typeof fn!=='function'){console.error('[Admin] No render function for section: '+state.section);c.innerHTML='<div class="page-title">Error</div><p style="color:var(--red)">Unknown section: '+esc(state.section)+'</p><button class="btn btn-accent" onclick="navigateTo(\'dashboard\')">Back to Dashboard</button>';return}
|
||
try{
|
||
const result=fn();
|
||
if(result instanceof Promise){
|
||
const renderSection=state.section;
|
||
result.then(function(html){if(state.section===renderSection)c.innerHTML=html}).catch(function(e){
|
||
console.error('[Admin] Render error in "'+renderSection+'":', e);
|
||
if(state.section===renderSection)c.innerHTML='<div class="page-title">Error</div><p style="color:var(--red)">'+esc(e&&e.message||String(e))+'</p><button class="btn btn-accent" onclick="renderContent()">Retry</button>';
|
||
});
|
||
}else{c.innerHTML=result}
|
||
}catch(e){
|
||
console.error('[Admin] Sync render error in "'+state.section+'":', e);
|
||
c.innerHTML='<div class="page-title">Error</div><p style="color:var(--red)">'+esc(e&&e.message||String(e))+'</p><button class="btn btn-accent" onclick="renderContent()">Retry</button>';
|
||
}
|
||
}
|
||
|
||
/* ═══ Dashboard ═══ */
|
||
async function renderDashboard(){
|
||
try{state.cachedStats=await api('GET','/stats')}catch(e){return'<div class="page-title">Dashboard</div><p style="color:var(--red)">Failed to load stats: '+esc(e.message)+'</p>'}
|
||
/* Update checks are owner-only; skip the call for everyone else instead of
|
||
spending a guaranteed 403 on every dashboard load. */
|
||
if(isOwner()){try{state.cachedUpdate=await api('GET','/updates')}catch(e){/* the banner is optional; the Updates page reports the failure */}}
|
||
const s=state.cachedStats;const u=state.cachedUpdate;
|
||
let html='<div class="page-title">Dashboard</div><div class="page-desc">Server overview and statistics</div>';
|
||
if(u&&u.update_available)html+='<div class="update-card" style="border-color:var(--accent);margin-bottom:20px"><div class="update-icon" style="background:var(--accent-glow);color:var(--accent)">'+I.updates+'</div><div class="update-info"><div class="update-ver">Update Available: '+esc(u.latest)+'</div><div class="update-notes">Current: '+esc(u.current)+' — <button class="btn btn-accent" style="margin-left:8px" onclick="navigateTo(\'updates\')">View Update</button></div></div></div>';
|
||
html+='<div class="stat-grid">';
|
||
html+='<div class="stat-card"><div class="stat-card-header"><span class="stat-card-label">Total Users</span><div class="stat-card-icon" style="background:rgba(35,165,90,.15);color:var(--green)">'+I.users+'</div></div><div class="stat-card-value">'+(s.user_count||0)+'</div><div class="stat-card-sub">registered</div></div>';
|
||
html+='<div class="stat-card"><div class="stat-card-header"><span class="stat-card-label">Messages</span><div class="stat-card-icon" style="background:var(--accent-glow);color:var(--accent)">'+I.megaphone+'</div></div><div class="stat-card-value">'+(s.message_count||0).toLocaleString()+'</div><div class="stat-card-sub">total</div></div>';
|
||
html+='<div class="stat-card"><div class="stat-card-header"><span class="stat-card-label">Channels</span><div class="stat-card-icon" style="background:rgba(240,178,50,.15);color:var(--yellow)">'+I.channels+'</div></div><div class="stat-card-value">'+(s.channel_count||0)+'</div><div class="stat-card-sub">active</div></div>';
|
||
html+='<div class="stat-card"><div class="stat-card-header"><span class="stat-card-label">Database</span><div class="stat-card-icon" style="background:rgba(88,101,242,.15);color:var(--accent)">'+I.backup+'</div></div><div class="stat-card-value">'+fmtBytes(s.db_size_bytes||0)+'</div><div class="stat-card-sub">SQLite</div></div>';
|
||
html+='</div>';
|
||
// Recent audit — VIEW_AUDIT_LOG only.
|
||
if(can(PERM.VIEW_AUDIT_LOG))try{
|
||
const entries=await api('GET','/audit-log?limit=5&offset=0');
|
||
if(entries&&entries.length){
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>Recent Activity</h3><button class="btn btn-ghost" onclick="navigateTo(\'audit\')">View All</button></div><div class="section-card-body">';
|
||
entries.forEach(a=>{html+='<div class="activity-item"><div class="activity-icon" style="background:'+actionColor(a.action)+'22;color:'+actionColor(a.action)+'">'+I.audit+'</div><div><div class="activity-text"><strong>'+esc(a.actor_name||a.actor_id)+'</strong> '+esc(a.action)+' <strong>'+esc(a.target_type)+(a.target_id?' #'+a.target_id:'')+'</strong></div><div class="activity-time">'+esc(a.created_at)+(a.detail?' — '+esc(a.detail):'')+'</div></div></div>'});
|
||
html+='</div></div>';
|
||
}
|
||
}catch(e){}
|
||
return html;
|
||
}
|
||
|
||
/* ═══ Users ═══ */
|
||
async function renderUsers(){
|
||
const offset=(state.usersPage-1)*PAGE_SIZE;
|
||
let users;
|
||
try{users=await api('GET','/users?limit='+PAGE_SIZE+'&offset='+offset)}catch(e){return'<div class="page-title">Users</div><p style="color:var(--red)">'+esc(e.message)+'</p>'}
|
||
const totalPages=Math.max(1,Math.ceil(users.length/PAGE_SIZE));
|
||
let html='<div class="page-title">Users</div><div class="page-desc">Manage server members</div>';
|
||
html+='<div class="section-card"><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>User</th><th>Role</th><th>Status</th><th>Banned</th><th style="text-align:right">Actions</th></tr></thead><tbody>';
|
||
if(!users.length)html+='<tr><td colspan="5" style="text-align:center;color:var(--text-faint);padding:24px">No users found</td></tr>';
|
||
users.forEach(u=>{
|
||
const uid=u.id||u.ID;const uname=u.Username||u.username||'';const rid=u.role_id||u.RoleID||4;
|
||
const status=u.Status||u.status||'offline';const banned=u.Banned||u.banned||false;
|
||
const statusDot=banned?'banned':status;const statusLabel=banned?'Banned':status==='online'?'Online':'Offline';
|
||
const initial=uname?uname[0].toUpperCase():'?';
|
||
html+='<tr><td><div style="display:flex;align-items:center;gap:8px"><div class="avatar" style="background:var(--accent)">'+initial+'</div><strong>'+esc(uname)+'</strong></div></td>';
|
||
html+='<td><span class="role-badge"><span class="role-dot" style="background:'+esc(roleColor(rid))+'"></span>'+esc(roleName(rid,u.role_name||u.RoleName))+'</span></td>';
|
||
html+='<td><span class="dot '+statusDot+'"></span>'+statusLabel+'</td>';
|
||
// The ban reason is collected on ban and stored server-side; showing it
|
||
// here is the only place an admin can read back why someone was banned.
|
||
const banReason=u.ban_reason||u.BanReason||'';
|
||
const bannedCell=banned
|
||
?'<span class="badge badge-red" title="'+esc(banReason||'No reason given')+'">Yes</span>'
|
||
+(banReason?'<div style="font-size:11px;color:var(--text-faint);margin-top:2px">'+esc(banReason)+'</div>':'')
|
||
:'<span class="badge badge-muted">No</span>';
|
||
html+='<td>'+bannedCell+'</td>';
|
||
html+='<td><div class="act-group" style="justify-content:flex-end">';
|
||
if(can(PERM.MANAGE_ROLES))html+='<button class="act-btn" title="Edit role" onclick="openEditUser('+uid+',\''+jsq(uname)+'\','+rid+')">'+I.edit+'</button>';
|
||
if(can(PERM.KICK_MEMBERS))html+='<button class="act-btn" title="Force Logout" onclick="forceLogout('+uid+')">'+I.disconnect+'</button>';
|
||
if(can(PERM.BAN_MEMBERS)){
|
||
if(banned)html+='<button class="act-btn" title="Unban" onclick="unbanUser('+uid+')">'+I.check+'</button>';
|
||
else html+='<button class="act-btn danger" title="Ban" onclick="openBanUser('+uid+',\''+jsq(uname)+'\')">'+I.ban+'</button>';
|
||
}
|
||
html+='</div></td></tr>';
|
||
});
|
||
html+='</tbody></table></div></div>';
|
||
html+='<div class="pagination"><div class="pagination-info">Page '+state.usersPage+'</div><div class="pagination-btns">';
|
||
html+='<button class="page-btn" '+(state.usersPage<=1?'disabled':'')+' onclick="state.usersPage--;renderContent()"><</button>';
|
||
html+='<button class="page-btn active">'+state.usersPage+'</button>';
|
||
html+='<button class="page-btn" '+(users.length<PAGE_SIZE?'disabled':'')+' onclick="state.usersPage++;renderContent()">></button>';
|
||
html+='</div></div>';
|
||
return html;
|
||
}
|
||
|
||
/* Seeded roles with their hierarchy positions — the fallback used only when the
|
||
live list cannot be read. Role CRUD means the real set is whatever /roles
|
||
returns, so assigning a custom role must not depend on this literal. */
|
||
const ROLE_CHOICES=[{id:1,name:'Owner',position:100},{id:2,name:'Admin',position:80},{id:3,name:'Moderator',position:60},{id:4,name:'Member',position:40}];
|
||
|
||
/* The picker needs every assignable role, not the four seeded ones. The button
|
||
that opens this is gated on MANAGE_ROLES, which is exactly what GET /roles
|
||
requires, so the fetch is authorized whenever the modal is reachable; a
|
||
failure degrades to the seeded list rather than blocking the edit. */
|
||
async function openEditUser(uid,uname,currentRole){
|
||
const myPos=(state.me&&state.me.role_position)||0;
|
||
let roles;
|
||
try{roles=await api('GET','/roles');state.roleList=roles||[]}
|
||
catch(e){roles=ROLE_CHOICES}
|
||
/* The server refuses to assign a role positioned at or above the actor's
|
||
own, so anything higher is dropped rather than offered as a guaranteed
|
||
403. The current role is always listed so the select can show it. */
|
||
const opts=roles.filter(r=>r.position<myPos||r.id===currentRole)
|
||
.map(r=>'<option value="'+r.id+'" '+(currentRole===r.id?'selected':'')+'>'+esc(r.name)+'</option>').join('');
|
||
openModal('<div class="modal-header"><h3>Edit User</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><div style="display:flex;align-items:center;gap:12px;margin-bottom:20px"><div class="avatar" style="background:var(--accent);width:48px;height:48px;font-size:20px">'+uname[0].toUpperCase()+'</div><div style="font-size:16px;font-weight:700;color:white">'+esc(uname)+'</div></div><div class="form-group"><label class="form-label">Role</label><select class="form-input" id="editRoleSelect" style="appearance:auto">'+opts+'</select></div></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="saveUserRole('+uid+')">Save</button></div>');
|
||
}
|
||
|
||
async function saveUserRole(uid){
|
||
const sel=document.getElementById('editRoleSelect');if(!sel)return;
|
||
try{await api('PATCH','/users/'+uid,{role_id:parseInt(sel.value)});closeModal();showToast('Role updated');renderContent()}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
function openBanUser(uid,uname){
|
||
openModal('<div class="modal-header"><h3>Ban User</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted);margin-bottom:16px">Ban <strong style="color:white">'+esc(uname)+'</strong> from the server?</p><div class="form-group"><label class="form-label">Reason</label><textarea class="form-input form-textarea" id="banReason" placeholder="Reason for ban..."></textarea></div></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmBan('+uid+')">Ban User</button></div>');
|
||
}
|
||
|
||
async function confirmBan(uid){
|
||
const reason=document.getElementById('banReason')?.value||'';
|
||
try{await api('PATCH','/users/'+uid,{banned:true,ban_reason:reason});closeModal();showToast('User banned');renderContent()}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
async function unbanUser(uid){
|
||
try{await api('PATCH','/users/'+uid,{banned:false});showToast('User unbanned');renderContent()}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
async function forceLogout(uid){
|
||
openModal('<div class="modal-header"><h3>Force Logout</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">Terminate all sessions for this user? They can sign back in immediately — this is not a removal.</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmForceLogout('+uid+')">Force Logout</button></div>');
|
||
}
|
||
|
||
async function confirmForceLogout(uid){
|
||
try{await api('DELETE','/users/'+uid+'/sessions');closeModal();showToast('Forced logout: all sessions terminated');renderContent()}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
/* ═══ Channels ═══ */
|
||
async function renderChannels(){
|
||
let channels;
|
||
try{channels=await api('GET','/channels')}catch(e){return'<div class="page-title">Channels</div><p style="color:var(--red)">'+esc(e.message)+'</p>'}
|
||
const chIcon=t=>t==='voice'?I.voice:t==='announcement'?I.megaphone:I.channels;
|
||
/* Categories are free text — a channel of any type may live under any one of
|
||
them. Collect the ones already in use so the create/edit forms can offer
|
||
them as a datalist instead of hardcoding names nobody has to use. */
|
||
const catSet={};
|
||
let html='<div class="page-title">Channels</div><div class="page-desc">'+channels.length+' channels</div>';
|
||
html+='<div class="filter-bar"><button class="btn btn-accent" onclick="openChannelModal(null)">'+I.plus+' Create Channel</button></div>';
|
||
html+='<div class="section-card"><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>Channel</th><th>Type</th><th>Category</th><th>Archived</th><th style="text-align:right">Actions</th></tr></thead><tbody>';
|
||
if(!channels.length)html+='<tr><td colspan="5" style="text-align:center;color:var(--text-faint);padding:24px">No channels</td></tr>';
|
||
channels.forEach(ch=>{
|
||
const id=ch.id||ch.ID;const name=ch.name||ch.Name||'';const type=ch.type||ch.Type||'text';
|
||
const cat=ch.category||ch.Category||'';const archived=ch.archived||ch.Archived||false;
|
||
html+='<tr><td><div style="display:flex;align-items:center;gap:8px"><span style="color:var(--text-faint)">'+chIcon(type)+'</span><strong>'+esc(name)+'</strong></div></td>';
|
||
html+='<td><span class="badge '+(type==='voice'?'badge-yellow':type==='announcement'?'badge-accent':'badge-muted')+'">'+esc(type)+'</span></td>';
|
||
html+='<td style="font-size:12px;color:var(--text-faint)">'+esc(cat)+'</td>';
|
||
html+='<td>'+(archived?'<span class="badge badge-muted">Yes</span>':'<span class="badge badge-green">No</span>')+'</td>';
|
||
const lockBtn=type==='dm'?'':'<button class="act-btn" title="Access (private channel)" onclick="openChannelPermsModal('+id+',\''+jsq(name)+'\')">'+I.lock+'</button>';
|
||
state.channelCache[id]=ch;
|
||
if(cat)catSet[cat]=true;
|
||
html+='<td><div class="act-group" style="justify-content:flex-end"><button class="act-btn" title="Edit" onclick="openChannelEditModal('+id+')">'+I.edit+'</button>'+lockBtn+'<button class="act-btn danger" title="Delete" onclick="openDeleteChannel('+id+',\''+jsq(name)+'\')">'+I.trash+'</button></div></td></tr>';
|
||
});
|
||
html+='</tbody></table></div></div>';
|
||
state.channelCategories=Object.keys(catSet).sort();
|
||
return html;
|
||
}
|
||
|
||
/* <datalist> of the categories currently in use. Purely a suggestion list —
|
||
typing a brand-new name is the supported way to create a category. */
|
||
function categoryDatalist(listId){
|
||
const cats=state.channelCategories||[];
|
||
let html='<datalist id="'+listId+'">';
|
||
cats.forEach(c=>{html+='<option value="'+esc(c)+'"></option>'});
|
||
return html+'</datalist>';
|
||
}
|
||
|
||
function openChannelModal(){
|
||
openModal('<div class="modal-header"><h3>Create Channel</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><div class="form-group"><label class="form-label">Name <span class="req">*</span></label><input class="form-input" id="chName" placeholder="general"></div><div class="form-group"><label class="form-label">Type</label><select class="form-input" id="chType" style="appearance:auto"><option value="text">Text</option><option value="voice">Voice</option><option value="announcement">Announcement</option></select></div><div class="form-group"><label class="form-label">Category</label><input class="form-input" id="chCat" list="chCatList" placeholder="Text Channels" autocomplete="off">'+categoryDatalist('chCatList')+'<div style="font-size:11px;color:var(--text-faint);margin-top:4px">Any name works, for voice and text channels alike. Leave blank for no category.</div></div><div class="form-group"><label class="form-label">Topic</label><input class="form-input" id="chTopic"></div><div class="form-group"><label class="form-label">Position</label><input class="form-input" id="chPos" type="number" value="0" min="0"></div></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="createChannel()">Create</button></div>');
|
||
}
|
||
|
||
async function createChannel(){
|
||
const body={name:document.getElementById('chName').value.trim(),type:document.getElementById('chType').value,category:document.getElementById('chCat').value.trim(),topic:document.getElementById('chTopic').value.trim(),position:parseInt(document.getElementById('chPos').value)||0};
|
||
if(!body.name){showToast('Name is required','error');return}
|
||
try{await api('POST','/channels',body);closeModal();showToast('Channel created');renderContent()}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
/* PATCH /channels/{id} accepts name, topic, category, slow_mode, position,
|
||
archived, nsfw and the two voice capacity limits — the modal used to offer
|
||
only the name, so the Archived column in the table was read-only state with
|
||
no control behind it.
|
||
|
||
NSFW is a flag and nothing more: the server stores, broadcasts and audits it
|
||
but applies no content behaviour to a flagged channel. Clients decide what to
|
||
do with it (the desktop client shows a per-session age gate).
|
||
|
||
The voice limits are only rendered for a voice channel. They are stored on
|
||
any type, but on a text channel they are values nothing will ever read, and
|
||
offering them there would imply an enforcement that does not exist. */
|
||
function openChannelEditModal(id){
|
||
const ch=state.channelCache[id]||{};
|
||
const name=ch.name||ch.Name||'';
|
||
const topic=ch.topic||ch.Topic||'';
|
||
const cat=ch.category||ch.Category||'';
|
||
const slow=ch.slow_mode||ch.SlowMode||0;
|
||
const pos=ch.position||ch.Position||0;
|
||
const archived=ch.archived||ch.Archived||false;
|
||
const nsfw=ch.nsfw||ch.NSFW||false;
|
||
const type=ch.type||ch.Type||'text';
|
||
const maxUsers=ch.voice_max_users||ch.VoiceMaxUsers||0;
|
||
const maxVideo=ch.voice_max_video||ch.VoiceMaxVideo||0;
|
||
const voiceRows=type!=='voice'?'':
|
||
'<div class="form-group"><label class="form-label">User limit (0 = unlimited)</label><input class="form-input" id="chEditMaxUsers" type="number" min="0" max="99" value="'+esc(maxUsers)+'"></div>'
|
||
+'<div class="form-group"><label class="form-label">Video limit (0 = unlimited)</label><input class="form-input" id="chEditMaxVideo" type="number" min="0" max="99" value="'+esc(maxVideo)+'"></div>';
|
||
openModal('<div class="modal-header"><h3>Edit Channel</h3><button class="modal-close" onclick="closeModal()">×</button></div>'
|
||
+'<div class="modal-body">'
|
||
+'<div class="form-group"><label class="form-label">Name</label><input class="form-input" id="chEditName" value="'+esc(name)+'"></div>'
|
||
+'<div class="form-group"><label class="form-label">Topic</label><input class="form-input" id="chEditTopic" value="'+esc(topic)+'"></div>'
|
||
+'<div class="form-group"><label class="form-label">Category</label><input class="form-input" id="chEditCat" list="chEditCatList" value="'+esc(cat)+'" autocomplete="off">'+categoryDatalist('chEditCatList')+'<div style="font-size:11px;color:var(--text-faint);margin-top:4px">Move the channel to another category, or blank it to leave it uncategorized.</div></div>'
|
||
+'<div class="form-group"><label class="form-label">Slow mode (seconds, 0 = off)</label><input class="form-input" id="chEditSlow" type="number" min="0" value="'+esc(slow)+'"></div>'
|
||
+'<div class="form-group"><label class="form-label">Position</label><input class="form-input" id="chEditPos" type="number" min="0" value="'+esc(pos)+'"></div>'
|
||
+voiceRows
|
||
+'<div class="setting-row"><div class="setting-info"><div class="setting-name">Archived</div><div class="setting-desc">Hide the channel without deleting its messages</div></div><div class="setting-ctrl"><button class="toggle '+(archived?'on':'')+'" id="chEditArchived" onclick="this.classList.toggle(\'on\')"></button></div></div>'
|
||
+'<div class="setting-row"><div class="setting-info"><div class="setting-name">Age-restricted (NSFW)</div><div class="setting-desc">Clients show a one-time warning and mark the channel. The server does not filter or restrict anything.</div></div><div class="setting-ctrl"><button class="toggle '+(nsfw?'on':'')+'" id="chEditNsfw" onclick="this.classList.toggle(\'on\')"></button></div></div>'
|
||
+'</div>'
|
||
+'<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="saveChannelEdit('+id+')">Save</button></div>');
|
||
}
|
||
|
||
async function saveChannelEdit(id){
|
||
const name=document.getElementById('chEditName').value.trim();
|
||
if(!name){showToast('Name is required','error');return}
|
||
const body={
|
||
name,
|
||
topic:document.getElementById('chEditTopic').value.trim(),
|
||
category:document.getElementById('chEditCat').value.trim(),
|
||
slow_mode:parseInt(document.getElementById('chEditSlow').value,10)||0,
|
||
position:parseInt(document.getElementById('chEditPos').value,10)||0,
|
||
archived:document.getElementById('chEditArchived').classList.contains('on'),
|
||
nsfw:document.getElementById('chEditNsfw').classList.contains('on'),
|
||
};
|
||
/* Only present for a voice channel. Omitting them entirely (rather than
|
||
sending 0) is what keeps a text-channel edit from clobbering limits a
|
||
channel might carry from an earlier life as a voice channel — the handler
|
||
starts from the stored values for every field the body leaves out. */
|
||
const maxUsersEl=document.getElementById('chEditMaxUsers');
|
||
const maxVideoEl=document.getElementById('chEditMaxVideo');
|
||
if(maxUsersEl){body.voice_max_users=parseInt(maxUsersEl.value,10)||0}
|
||
if(maxVideoEl){body.voice_max_video=parseInt(maxVideoEl.value,10)||0}
|
||
try{await api('PATCH','/channels/'+id,body);closeModal();showToast('Channel updated');renderContent()}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
function openDeleteChannel(id,name){
|
||
openModal('<div class="modal-header"><h3>Delete Channel</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">Permanently delete <strong style="color:white">#'+esc(name)+'</strong> and all its messages?</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmDeleteChannel('+id+')">Delete</button></div>');
|
||
}
|
||
|
||
async function confirmDeleteChannel(id){
|
||
try{await api('DELETE','/channels/'+id);closeModal();showToast('Channel deleted');renderContent()}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
/* ═══ Channel permissions (override matrix) ═══ */
|
||
/* Two editors over the same two endpoints, because they answer two different
|
||
questions. The quick "Can access" list is the 90% case — hide this channel
|
||
from a role — and still writes exactly the mask it always did. The matrix
|
||
below it is the honest one: pick a role OR a single member, then set each
|
||
relevant bit to allow / inherit / deny, which is what the API has always
|
||
accepted and what the resolution order (base -> role override -> user
|
||
override) actually resolves. */
|
||
const DENY_PRIVATE=0x202; /* READ_MESSAGES | CONNECT_VOICE */
|
||
const ADMIN_BIT=0x40000000;
|
||
|
||
/* The bits worth overriding PER CHANNEL. Server-wide bits (Manage Roles, Ban
|
||
Members, …) are deliberately absent: they answer to the server, not to one
|
||
channel, so offering them here would write masks nothing ever reads. */
|
||
const OVERRIDE_BITS=[
|
||
[0x2,'Read Messages'],
|
||
[0x1,'Send Messages'],
|
||
[0x20,'Attach Files'],
|
||
[0x40,'Add Reactions'],
|
||
[0x10000,'Manage Messages'],
|
||
[0x200000,'Mention @everyone'],
|
||
[0x200,'Connect'],
|
||
[0x400,'Speak'],
|
||
[0x800,'Video'],
|
||
[0x1000,'Share Screen'],
|
||
];
|
||
|
||
/* Tri-state per bit: 'allow' sets the bit in the allow mask, 'deny' sets it in
|
||
the deny mask, 'inherit' sets it in neither. An override row whose two masks
|
||
are both zero is deleted rather than stored — an all-inherit row is the same
|
||
thing as no row, and keeping it would leave phantom entries in the listing. */
|
||
function overrideStateOf(allow,deny,bit){
|
||
if((allow&bit)===bit)return 'allow';
|
||
if((deny&bit)===bit)return 'deny';
|
||
return 'inherit';
|
||
}
|
||
|
||
async function openChannelPermsModal(id,name){
|
||
let data,users;
|
||
try{
|
||
data=await api('GET','/channels/'+id+'/permissions');
|
||
users=await api('GET','/users?limit=500&offset=0');
|
||
}catch(e){showToast(e.message,'error');return}
|
||
state.permChannel={id:id,name:name,roles:data.roles||[],users:data.users||[],allUsers:users||[]};
|
||
renderChannelPermsModal();
|
||
}
|
||
|
||
function renderChannelPermsModal(){
|
||
const pc=state.permChannel;if(!pc)return;
|
||
let quick='';
|
||
pc.roles.forEach(role=>{
|
||
const isAdmin=(role.permissions&ADMIN_BIT)!==0;
|
||
const canAccess=isAdmin||((role.deny&0x2)===0);
|
||
quick+='<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--bg-active)">'
|
||
+'<span style="color:'+roleColor(role.role_id)+';font-weight:600">'+esc(role.role_name)+'</span>'
|
||
+(isAdmin
|
||
?'<span style="font-size:12px;color:var(--text-faint)">always has access</span>'
|
||
:'<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-muted);cursor:pointer"><input type="checkbox" id="permRole'+role.role_id+'" '+(canAccess?'checked':'')+'> Can access</label>')
|
||
+'</div>';
|
||
});
|
||
|
||
let opts='<option value="">— pick a role or member —</option><optgroup label="Roles">';
|
||
pc.roles.forEach(r=>{opts+='<option value="r:'+r.role_id+'">'+esc(r.role_name)+'</option>'});
|
||
opts+='</optgroup><optgroup label="Members">';
|
||
/* The member list is paginated, so a member who already has an override could
|
||
fall outside the page and become uneditable. Union the two lists — the
|
||
override rows carry the username the picker needs. */
|
||
const picked=[];const seen={};
|
||
pc.users.forEach(o=>{seen[o.user_id]=true;picked.push({id:o.user_id,username:o.username,has:true})});
|
||
pc.allUsers.forEach(u=>{if(!seen[u.id])picked.push({id:u.id,username:u.username,has:false})});
|
||
picked.sort((a,b)=>String(a.username).localeCompare(String(b.username)));
|
||
picked.forEach(u=>{
|
||
opts+='<option value="u:'+u.id+'">'+esc(u.username)+(u.has?' (override)':'')+'</option>';
|
||
});
|
||
opts+='</optgroup>';
|
||
|
||
openModal('<div class="modal-header"><h3>Channel Permissions — #'+esc(pc.name)+'</h3><button class="modal-close" onclick="closeModal()">×</button></div>'
|
||
+'<div class="modal-body">'
|
||
+'<p style="color:var(--text-muted);font-size:13px;margin-bottom:12px">Uncheck a role to hide this channel from it (private channel). Changes apply to connected users immediately; users already in the voice channel are not disconnected.</p>'
|
||
+quick
|
||
+'<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--bg-active)">'
|
||
+'<div class="form-group"><label class="form-label">Override matrix</label>'
|
||
+'<select class="form-input" id="permTarget" style="appearance:auto" onchange="renderPermMatrix()">'+opts+'</select></div>'
|
||
+'<p style="color:var(--text-faint);font-size:12px;margin:0 0 10px">Resolution order: base role permissions → role override → member override. A member deny beats a role allow; Administrator bypasses everything.</p>'
|
||
+'<div id="permMatrix"></div>'
|
||
+'</div></div>'
|
||
+'<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="saveChannelPerms()">Save</button></div>');
|
||
renderPermMatrix();
|
||
}
|
||
|
||
/* Reads the current masks for the selected target and paints one tri-state row
|
||
per bit. A member with no override row starts all-inherit. */
|
||
function renderPermMatrix(){
|
||
const pc=state.permChannel;if(!pc)return;
|
||
const box=document.getElementById('permMatrix');if(!box)return;
|
||
const sel=document.getElementById('permTarget');
|
||
const val=sel?sel.value:'';
|
||
if(!val){box.innerHTML='<p style="color:var(--text-faint);font-size:12px">Pick a role or member above to edit its per-channel bits.</p>';return}
|
||
const kind=val.charAt(0),tid=parseInt(val.slice(2),10);
|
||
let allow=0,deny=0,adminNote='';
|
||
if(kind==='r'){
|
||
const role=pc.roles.find(r=>r.role_id===tid);
|
||
if(role){allow=role.allow;deny=role.deny;if((role.permissions&ADMIN_BIT)!==0)adminNote='This role holds Administrator — every override below is bypassed.'}
|
||
}else{
|
||
const o=pc.users.find(u=>u.user_id===tid);
|
||
if(o){allow=o.allow;deny=o.deny}
|
||
}
|
||
let html='';
|
||
if(adminNote)html+='<p style="color:var(--yellow);font-size:12px;margin:0 0 8px">'+esc(adminNote)+'</p>';
|
||
html+='<table class="tbl"><thead><tr><th>Permission</th><th style="text-align:center">Allow</th><th style="text-align:center">Inherit</th><th style="text-align:center">Deny</th></tr></thead><tbody>';
|
||
OVERRIDE_BITS.forEach(b=>{
|
||
const bit=b[0],label=b[1],st=overrideStateOf(allow,deny,bit);
|
||
html+='<tr><td>'+esc(label)+'</td>';
|
||
['allow','inherit','deny'].forEach(k=>{
|
||
html+='<td style="text-align:center"><input type="radio" name="ovr'+bit+'" data-ovrbit="'+bit+'" value="'+k+'"'+(st===k?' checked':'')+'></td>';
|
||
});
|
||
html+='</tr>';
|
||
});
|
||
html+='</tbody></table>';
|
||
html+='<div style="margin-top:10px"><button class="btn btn-ghost" onclick="clearPermOverride()">Clear override</button></div>';
|
||
box.innerHTML=html;
|
||
}
|
||
|
||
/* Collects the tri-state rows back into the two masks the API takes. */
|
||
function collectOverrideMasks(){
|
||
let allow=0,deny=0;
|
||
document.querySelectorAll('#permMatrix input[data-ovrbit]:checked').forEach(el=>{
|
||
const bit=parseInt(el.getAttribute('data-ovrbit'),10);
|
||
if(el.value==='allow')allow|=bit;
|
||
else if(el.value==='deny')deny|=bit;
|
||
});
|
||
return {allow:allow,deny:deny};
|
||
}
|
||
|
||
function permTargetPath(){
|
||
const pc=state.permChannel;
|
||
const sel=document.getElementById('permTarget');
|
||
const val=sel?sel.value:'';
|
||
if(!pc||!val)return null;
|
||
const kind=val.charAt(0),tid=parseInt(val.slice(2),10);
|
||
return '/channels/'+pc.id+(kind==='r'?'/permissions/':'/user-permissions/')+tid;
|
||
}
|
||
|
||
async function clearPermOverride(){
|
||
const path=permTargetPath();
|
||
if(!path){showToast('Pick a role or member first','error');return}
|
||
try{
|
||
await api('DELETE',path);
|
||
closeModal();showToast('Override cleared');renderContent();
|
||
}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
async function saveChannelPerms(){
|
||
const pc=state.permChannel;if(!pc)return;
|
||
try{
|
||
/* Quick toggles first: same masks this panel has always written. */
|
||
for(const role of pc.roles){
|
||
if((role.permissions&ADMIN_BIT)!==0)continue;
|
||
const box=document.getElementById('permRole'+role.role_id);
|
||
if(!box)continue;
|
||
const wasHidden=(role.deny&0x2)!==0;
|
||
if(!box.checked)await api('PUT','/channels/'+pc.id+'/permissions/'+role.role_id,{allow:0,deny:DENY_PRIVATE});
|
||
else if(wasHidden)await api('DELETE','/channels/'+pc.id+'/permissions/'+role.role_id);
|
||
}
|
||
/* Then the matrix, if a target is selected. An all-inherit row is a delete:
|
||
storing (0,0) would leave a row that resolves to nothing. */
|
||
const path=permTargetPath();
|
||
if(path){
|
||
const masks=collectOverrideMasks();
|
||
if(masks.allow===0&&masks.deny===0)await api('DELETE',path);
|
||
else await api('PUT',path,masks);
|
||
}
|
||
closeModal();showToast('Channel permissions updated');renderContent();
|
||
}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
/* ═══ Roles ═══ */
|
||
/* Roles are real CRUD now, not four seeded rows. Everything here is gated on
|
||
MANAGE_ROLES, and the server additionally enforces the hierarchy: you may
|
||
only touch roles strictly BELOW your own position, and may never grant a bit
|
||
your own role lacks. The UI mirrors both rules so a doomed request is not
|
||
offered — but the server is the authority, and a 403 surfaces as a toast. */
|
||
|
||
/* Permission checkboxes, grouped exactly as docs/schema.md's "Permission
|
||
groups" section groups the bitfield. Keep the two in step: the doc is the
|
||
reference an operator reads next to this grid, and every one of the 19
|
||
defined bits must appear in exactly one group or it becomes ungrantable
|
||
here. */
|
||
const PERM_GROUPS=[
|
||
{title:'General',bits:[
|
||
[0x20000,'Manage Channels','Create, edit and delete channels and their overrides'],
|
||
[0x1000000,'Manage Roles','Create, edit, delete and assign roles below your own'],
|
||
[0x4000000,'Manage Invites','Create and revoke invite codes'],
|
||
[0x2000000,'Manage Server','Read and change server settings'],
|
||
[0x8000000,'View Audit Log','Read the action history'],
|
||
[0x40000000,'Administrator','Bypasses every permission check'],
|
||
]},
|
||
{title:'Text',bits:[
|
||
[0x2,'Read Messages','View messages in text channels'],
|
||
[0x1,'Send Messages','Post messages in text channels'],
|
||
[0x20,'Attach Files','Upload file attachments'],
|
||
[0x40,'Add Reactions','React to messages with emoji'],
|
||
[0x200000,'Mention @everyone','Give @everyone/@here real mention semantics'],
|
||
[0x10000,'Manage Messages','Delete others’ messages, pin and purge'],
|
||
]},
|
||
{title:'Voice',bits:[
|
||
[0x200,'Connect','Join voice channels'],
|
||
[0x400,'Speak','Transmit audio in voice channels'],
|
||
[0x800,'Video','Enable the camera in voice channels'],
|
||
[0x1000,'Share Screen','Share the screen in voice channels'],
|
||
]},
|
||
{title:'Moderation',bits:[
|
||
[0x40000,'Kick Members','Force-logout a lower-ranked member'],
|
||
[0x80000,'Ban Members','Ban and unban lower-ranked members'],
|
||
[0x100000,'Mute Members','Server mute, deafen, move and disconnect in voice'],
|
||
]},
|
||
];
|
||
|
||
/* My own position, from GET /me — the hierarchy boundary every row respects. */
|
||
function myPosition(){return (state.me&&state.me.role_position)||0}
|
||
/* True when the signed-in principal may manage this role at all. */
|
||
function canManageRole(role){return role.position<myPosition()}
|
||
/* True when this bit may be granted: ADMINISTRATOR grants anything, otherwise
|
||
only bits the caller's own role holds. */
|
||
function canGrantBit(bit){
|
||
const p=(state.me&&state.me.permissions)||0;
|
||
if((p&PERM.ADMINISTRATOR)!==0)return true;
|
||
return (p&bit)===bit;
|
||
}
|
||
|
||
async function renderRoles(){
|
||
let roles;
|
||
try{roles=await api('GET','/roles')}catch(e){return'<div class="page-title">Roles</div><p style="color:var(--red)">'+esc(e.message)+'</p>'}
|
||
state.roleList=roles||[];
|
||
let html='<div class="page-title">Roles</div><div class="page-desc">'+state.roleList.length+' roles, highest rank first. You can only manage roles below your own.</div>';
|
||
html+='<div class="filter-bar"><button class="btn btn-accent" onclick="openRoleModal(null)">'+I.plus+' Create Role</button></div>';
|
||
html+='<div class="section-card"><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>Role</th><th>Members</th><th>Position</th><th style="text-align:right">Actions</th></tr></thead><tbody>';
|
||
if(!state.roleList.length)html+='<tr><td colspan="4" style="text-align:center;color:var(--text-faint);padding:24px">No roles</td></tr>';
|
||
/* Only the manageable slice can be reordered — the reorder endpoint takes
|
||
exactly the roles below the caller, so the arrows move within that slice. */
|
||
const movable=state.roleList.filter(canManageRole);
|
||
state.roleList.forEach(role=>{
|
||
const mine=canManageRole(role);
|
||
const mIdx=movable.findIndex(r=>r.id===role.id);
|
||
const swatch='<span class="role-swatch" style="background:'+(role.color?esc(role.color):'var(--text-micro)')+'"></span>';
|
||
html+='<tr><td><div style="display:flex;align-items:center;gap:8px">'+swatch+'<strong style="color:'+(role.color?esc(role.color):'var(--text-normal)')+'">'+esc(role.name)+'</strong>';
|
||
if(role.is_default)html+='<span class="badge badge-muted">default</span>';
|
||
if(!mine)html+='<span class="badge badge-muted">above you</span>';
|
||
html+='</div></td>';
|
||
html+='<td style="color:var(--text-muted)">'+(role.member_count||0)+'</td>';
|
||
html+='<td style="font-size:12px;color:var(--text-faint)">'+role.position+'</td>';
|
||
html+='<td><div class="act-group" style="justify-content:flex-end">';
|
||
if(mine){
|
||
const upDisabled=mIdx<=0?'disabled style="opacity:.3"':'';
|
||
const downDisabled=(mIdx<0||mIdx>=movable.length-1)?'disabled style="opacity:.3"':'';
|
||
html+='<button class="act-btn" title="Move up" '+upDisabled+' onclick="moveRole('+role.id+',-1)">'+I.arrowUp+'</button>';
|
||
html+='<button class="act-btn" title="Move down" '+downDisabled+' onclick="moveRole('+role.id+',1)">'+I.arrowDown+'</button>';
|
||
html+='<button class="act-btn" title="Edit" onclick="openRoleModal('+role.id+')">'+I.edit+'</button>';
|
||
if(role.is_default)html+='<button class="act-btn" title="The default role cannot be deleted" disabled style="opacity:.3">'+I.trash+'</button>';
|
||
else html+='<button class="act-btn danger" title="Delete" onclick="openDeleteRole('+role.id+')">'+I.trash+'</button>';
|
||
}else{
|
||
html+='<span style="font-size:12px;color:var(--text-micro)">read-only</span>';
|
||
}
|
||
html+='</div></td></tr>';
|
||
});
|
||
html+='</tbody></table></div></div>';
|
||
return html;
|
||
}
|
||
|
||
/* Swap a role with its neighbour and send the whole manageable order. The
|
||
endpoint normalizes positions, so the client never computes them. */
|
||
async function moveRole(id,delta){
|
||
const movable=state.roleList.filter(canManageRole);
|
||
const i=movable.findIndex(r=>r.id===id);
|
||
const j=i+delta;
|
||
if(i<0||j<0||j>=movable.length)return;
|
||
const ids=movable.map(r=>r.id);
|
||
ids[i]=movable[j].id;ids[j]=movable[i].id;
|
||
try{await api('PATCH','/roles/reorder',{role_ids:ids});showToast('Roles reordered');renderContent()}
|
||
catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
/* Shared create/edit modal. id === null creates. */
|
||
function openRoleModal(id){
|
||
const role=id===null?null:state.roleList.find(r=>r.id===id);
|
||
if(id!==null&&!role){showToast('Role not found','error');return}
|
||
const name=role?role.name:'';
|
||
const color=(role&&role.color)?role.color:'';
|
||
const perms=role?role.permissions:0;
|
||
/* A new role defaults to just below the caller, which is what the server
|
||
does for an omitted position — shown so the number is never a surprise. */
|
||
const position=role?role.position:Math.max(0,myPosition()-1);
|
||
|
||
let grid='';
|
||
PERM_GROUPS.forEach(g=>{
|
||
grid+='<div class="perm-group"><div class="perm-group-title">'+esc(g.title)+'</div><div class="perm-grid">';
|
||
g.bits.forEach(b=>{
|
||
const bit=b[0],label=b[1],desc=b[2];
|
||
const granted=(perms&bit)===bit;
|
||
/* A bit the caller does not hold can only be left as it is: checked and
|
||
locked when the role already has it (removing is a de-escalation the
|
||
server allows, but the panel keeps the rule to one sentence), unchecked
|
||
and locked otherwise. */
|
||
const locked=!canGrantBit(bit);
|
||
const title=locked?'Your own role does not have this permission':desc;
|
||
grid+='<label class="perm-item'+(locked?' locked':'')+'" title="'+esc(title)+'">'
|
||
+'<input type="checkbox" data-permbit="'+bit+'" '+(granted?'checked':'')+' '+(locked?'disabled':'')+'>'
|
||
+'<span>'+esc(label)+'</span></label>';
|
||
});
|
||
grid+='</div></div>';
|
||
});
|
||
|
||
openModal('<div class="modal-header"><h3>'+(role?'Edit Role':'Create Role')+'</h3><button class="modal-close" onclick="closeModal()">×</button></div>'
|
||
+'<div class="modal-body">'
|
||
+'<div class="form-group"><label class="form-label">Name <span class="req">*</span></label><input class="form-input" id="roleName" maxlength="32" value="'+esc(name)+'" placeholder="Moderator"></div>'
|
||
+'<div class="form-group"><label class="form-label">Color</label><div style="display:flex;align-items:center;gap:10px">'
|
||
+'<input type="color" id="roleColor" value="'+esc(color||'#5865F2')+'" style="width:44px;height:34px;padding:2px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm)">'
|
||
+'<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-muted)"><input type="checkbox" id="roleNoColor" '+(color?'':'checked')+'> No color</label>'
|
||
+'</div></div>'
|
||
+'<div class="form-group"><label class="form-label">Position (must be below your own rank of '+myPosition()+')</label><input class="form-input" id="rolePos" type="number" min="0" max="'+Math.max(0,myPosition()-1)+'" value="'+position+'"></div>'
|
||
+'<div class="form-group"><label class="form-label">Permissions</label>'+grid+'</div>'
|
||
+'</div>'
|
||
+'<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="saveRole('+(role?role.id:'null')+')">'+(role?'Save':'Create')+'</button></div>');
|
||
}
|
||
|
||
/* Collect the checked bits. Disabled boxes still report their state, so a bit
|
||
the caller cannot grant is preserved rather than silently stripped. */
|
||
function collectRolePerms(){
|
||
let mask=0;
|
||
document.querySelectorAll('#modalInner input[data-permbit]').forEach(box=>{
|
||
if(box.checked)mask|=parseInt(box.getAttribute('data-permbit'),10);
|
||
});
|
||
return mask;
|
||
}
|
||
|
||
async function saveRole(id){
|
||
const name=document.getElementById('roleName').value.trim();
|
||
if(!name){showToast('Name is required','error');return}
|
||
const noColor=document.getElementById('roleNoColor').checked;
|
||
const body={
|
||
name:name,
|
||
color:noColor?'':document.getElementById('roleColor').value,
|
||
permissions:collectRolePerms(),
|
||
position:parseInt(document.getElementById('rolePos').value,10)||0,
|
||
};
|
||
try{
|
||
if(id===null)await api('POST','/roles',body);
|
||
else await api('PATCH','/roles/'+id,body);
|
||
closeModal();showToast(id===null?'Role created':'Role updated');renderContent();
|
||
}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
function openDeleteRole(id){
|
||
const role=state.roleList.find(r=>r.id===id);
|
||
if(!role){showToast('Role not found','error');return}
|
||
const fallback=state.roleList.find(r=>r.is_default);
|
||
const fallbackName=fallback?fallback.name:'the default role';
|
||
const count=role.member_count||0;
|
||
const members=count===0
|
||
?'No members hold this role.'
|
||
:'<strong style="color:white">'+count+' member'+(count===1?'':'s')+'</strong> will be moved to <strong style="color:white">'+esc(fallbackName)+'</strong>.';
|
||
openModal('<div class="modal-header"><h3>Delete Role</h3><button class="modal-close" onclick="closeModal()">×</button></div>'
|
||
+'<div class="modal-body"><p style="color:var(--text-muted)">Delete <strong style="color:'+(role.color?esc(role.color):'white')+'">'+esc(role.name)+'</strong>?</p>'
|
||
+'<p style="color:var(--text-muted);margin-top:8px">'+members+'</p>'
|
||
+'<p style="color:var(--text-faint);font-size:12px;margin-top:8px">Its channel permission overrides are removed too. This cannot be undone.</p></div>'
|
||
+'<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmDeleteRole('+id+')">Delete</button></div>');
|
||
}
|
||
|
||
async function confirmDeleteRole(id){
|
||
try{await api('DELETE','/roles/'+id);closeModal();showToast('Role deleted');renderContent()}
|
||
catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
/* ═══ Audit Log ═══ */
|
||
async function renderAudit(){
|
||
const offset=(state.auditPage-1)*PAGE_SIZE;
|
||
let entries;
|
||
try{entries=await api('GET','/audit-log?limit='+PAGE_SIZE+'&offset='+offset)}catch(e){return'<div class="page-title">Audit Log</div><p style="color:var(--red)">'+esc(e.message)+'</p>'}
|
||
state.auditCache=entries||[];
|
||
|
||
// Collect unique action types for the filter dropdown.
|
||
const actionTypes=[...new Set(state.auditCache.map(e=>e.action).filter(Boolean))].sort();
|
||
|
||
// Client-side filter on fetched page.
|
||
const filtered=state.auditCache.filter(e=>{
|
||
if(state.auditActionFilter!=='all'&&e.action!==state.auditActionFilter)return false;
|
||
if(state.auditSearch){const s=state.auditSearch.toLowerCase();
|
||
if(!(e.actor_name||String(e.actor_id)||'').toLowerCase().includes(s)&&!(e.action||'').toLowerCase().includes(s)&&!(e.target_type||'').toLowerCase().includes(s)&&!(e.detail||'').toLowerCase().includes(s))return false}
|
||
return true;
|
||
});
|
||
|
||
let html='<div class="page-title">Audit Log</div><div class="page-desc">Action history — '+state.auditCache.length+' entries on this page</div>';
|
||
|
||
// Filter bar
|
||
html+='<div class="filter-bar">';
|
||
html+='<input class="filter-search" placeholder="Search audit log..." value="'+esc(state.auditSearch)+'" oninput="state.auditSearch=this.value;refilterAudit()">';
|
||
html+='<select class="filter-select" onchange="state.auditActionFilter=this.value;refilterAudit()">';
|
||
html+='<option value="all" '+(state.auditActionFilter==='all'?'selected':'')+'>All Actions</option>';
|
||
actionTypes.forEach(t=>{html+='<option value="'+esc(t)+'" '+(state.auditActionFilter===t?'selected':'')+'>'+esc(t)+'</option>'});
|
||
html+='</select>';
|
||
html+='<button class="btn btn-ghost" onclick="copyAuditLog()" title="Copy filtered entries">Copy All</button>';
|
||
html+='<button class="btn btn-ghost" onclick="exportAuditCSV()" title="Export as CSV">Export CSV</button>';
|
||
html+='</div>';
|
||
|
||
// Table
|
||
html+='<div class="section-card"><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th><th>Detail</th></tr></thead><tbody id="auditTbody">';
|
||
if(!filtered.length)html+='<tr><td colspan="5" style="text-align:center;color:var(--text-faint);padding:24px">No matching entries</td></tr>';
|
||
else filtered.forEach(e=>{html+=renderAuditRow(e)});
|
||
html+='</tbody></table></div></div>';
|
||
|
||
// Pagination
|
||
html+='<div class="pagination"><div class="pagination-info">Page '+state.auditPage+(state.auditSearch||state.auditActionFilter!=='all'?' ('+filtered.length+' of '+state.auditCache.length+' shown)':'')+'</div><div class="pagination-btns">';
|
||
html+='<button class="page-btn" '+(state.auditPage<=1?'disabled':'')+' onclick="state.auditPage--;renderContent()"><</button>';
|
||
html+='<button class="page-btn active">'+state.auditPage+'</button>';
|
||
html+='<button class="page-btn" '+(!entries||entries.length<PAGE_SIZE?'disabled':'')+' onclick="state.auditPage++;renderContent()">></button>';
|
||
html+='</div></div>';
|
||
return html;
|
||
}
|
||
|
||
function renderAuditRow(e){
|
||
return'<tr><td style="font-size:12px;color:var(--text-faint);white-space:nowrap">'+esc(e.created_at)+'</td>'
|
||
+'<td><strong>'+esc(e.actor_name||e.actor_id)+'</strong></td>'
|
||
+'<td><span class="badge '+actionBadge(e.action)+'">'+esc(e.action)+'</span></td>'
|
||
+'<td>'+esc(e.target_type)+(e.target_id?' #'+e.target_id:'')+'</td>'
|
||
+'<td style="font-size:12px;color:var(--text-faint)">'+esc(e.detail)+'</td></tr>';
|
||
}
|
||
|
||
function refilterAudit(){
|
||
const tbody=document.getElementById('auditTbody');if(!tbody)return;
|
||
const filtered=state.auditCache.filter(e=>{
|
||
if(state.auditActionFilter!=='all'&&e.action!==state.auditActionFilter)return false;
|
||
if(state.auditSearch){const s=state.auditSearch.toLowerCase();
|
||
if(!(e.actor_name||String(e.actor_id)||'').toLowerCase().includes(s)&&!(e.action||'').toLowerCase().includes(s)&&!(e.target_type||'').toLowerCase().includes(s)&&!(e.detail||'').toLowerCase().includes(s))return false}
|
||
return true;
|
||
});
|
||
if(!filtered.length)tbody.innerHTML='<tr><td colspan="5" style="text-align:center;color:var(--text-faint);padding:24px">No matching entries</td></tr>';
|
||
else tbody.innerHTML=filtered.map(renderAuditRow).join('');
|
||
}
|
||
|
||
function copyAuditLog(){
|
||
const filtered=state.auditCache.filter(e=>{
|
||
if(state.auditActionFilter!=='all'&&e.action!==state.auditActionFilter)return false;
|
||
if(state.auditSearch){const s=state.auditSearch.toLowerCase();
|
||
if(!(e.actor_name||String(e.actor_id)||'').toLowerCase().includes(s)&&!(e.action||'').toLowerCase().includes(s)&&!(e.target_type||'').toLowerCase().includes(s)&&!(e.detail||'').toLowerCase().includes(s))return false}
|
||
return true;
|
||
});
|
||
const lines=filtered.map(e=>(e.created_at||'')+'\t'+(e.actor_name||e.actor_id)+'\t'+(e.action||'')+'\t'+(e.target_type||'')+(e.target_id?' #'+e.target_id:'')+'\t'+(e.detail||''));
|
||
navigator.clipboard.writeText(lines.join('\n')).then(()=>showToast('Copied '+lines.length+' entries','info')).catch(()=>showToast('Copy failed','error'));
|
||
}
|
||
|
||
function exportAuditCSV(){
|
||
const filtered=state.auditCache.filter(e=>{
|
||
if(state.auditActionFilter!=='all'&&e.action!==state.auditActionFilter)return false;
|
||
if(state.auditSearch){const s=state.auditSearch.toLowerCase();
|
||
if(!(e.actor_name||String(e.actor_id)||'').toLowerCase().includes(s)&&!(e.action||'').toLowerCase().includes(s)&&!(e.target_type||'').toLowerCase().includes(s)&&!(e.detail||'').toLowerCase().includes(s))return false}
|
||
return true;
|
||
});
|
||
const csvQ=v=>'"'+String(v||'').replace(/"/g,'""')+'"';
|
||
let csv='Time,Actor,Action,Target,Detail\n';
|
||
filtered.forEach(e=>{csv+=csvQ(e.created_at)+','+csvQ(e.actor_name||e.actor_id)+','+csvQ(e.action)+','+csvQ((e.target_type||'')+(e.target_id?' #'+e.target_id:''))+','+csvQ(e.detail)+'\n'});
|
||
const blob=new Blob([csv],{type:'text/csv'});const url=URL.createObjectURL(blob);
|
||
const a=document.createElement('a');a.href=url;a.download='audit_log_'+new Date().toISOString().slice(0,10)+'.csv';a.click();
|
||
URL.revokeObjectURL(url);showToast('Exported '+filtered.length+' entries','info');
|
||
}
|
||
|
||
/* ═══ Server Logs ═══ */
|
||
function renderLogs(){
|
||
const lvlBtn=(l)=>{const on=state.logLevels[l];return'<button class="level-toggle '+(on?'active-'+l.toLowerCase():'')+'" onclick="toggleLogLevel(\''+l+'\')">'+l+'</button>'};
|
||
let html='<div class="page-title">Server Logs</div><div class="page-desc">Real-time structured log stream</div>';
|
||
html+='<div class="log-toolbar">';
|
||
html+=lvlBtn('DEBUG')+lvlBtn('INFO')+lvlBtn('WARN')+lvlBtn('ERROR');
|
||
html+='<input class="filter-search" placeholder="Filter logs..." style="flex:1;min-width:150px" value="'+esc(state.logSearch)+'" oninput="state.logSearch=this.value;renderLogLines()">';
|
||
html+='<button class="btn btn-ghost" onclick="toggleLogAutoScroll()" id="autoScrollBtn" title="Auto-scroll">'+(state.logAutoScroll?'⬇ Auto':'⏸ Manual')+'</button>';
|
||
html+='<button class="btn btn-ghost" onclick="toggleLogPause()" id="pauseBtn">'+(state.logPaused?'▶ Resume':'⏸ Pause')+'</button>';
|
||
html+='<button class="btn btn-ghost" onclick="copyAllLogs()" title="Copy visible logs">Copy All</button>';
|
||
html+='<button class="btn btn-ghost" onclick="clearLogs()" title="Clear log view">Clear</button>';
|
||
html+='</div>';
|
||
html+='<div class="log-output" id="logOutput"></div>';
|
||
html+='<div class="log-status"><span class="'+(state.logPaused?'dot-off':'dot-live')+'" id="logDot"></span><span id="logStatusText">'+(state.logPaused?'Paused':'Connecting...')+'</span><span style="margin-left:auto" id="logCount">'+state.logEntries.length+' entries</span></div>';
|
||
setTimeout(()=>{renderLogLines();if(!state.logPaused)connectLogStream()},0);
|
||
return html;
|
||
}
|
||
|
||
function toggleLogLevel(l){state.logLevels[l]=!state.logLevels[l];const btns=document.querySelectorAll('.level-toggle');btns.forEach(b=>{if(b.textContent===l){b.className='level-toggle '+(state.logLevels[l]?'active-'+l.toLowerCase():'')}});renderLogLines()}
|
||
|
||
function toggleLogAutoScroll(){state.logAutoScroll=!state.logAutoScroll;const btn=document.getElementById('autoScrollBtn');if(btn)btn.textContent=state.logAutoScroll?'⬇ Auto':'⏸ Manual'}
|
||
|
||
function toggleLogPause(){
|
||
state.logPaused=!state.logPaused;
|
||
const btn=document.getElementById('pauseBtn');if(btn)btn.textContent=state.logPaused?'▶ Resume':'⏸ Pause';
|
||
const dot=document.getElementById('logDot');if(dot)dot.className=state.logPaused?'dot-off':'dot-live';
|
||
const txt=document.getElementById('logStatusText');
|
||
if(state.logPaused){state.logConnectSeq++;if(state.logEventSource){state.logEventSource.close();state.logEventSource=null}if(state.logReconnectTimer){clearTimeout(state.logReconnectTimer);state.logReconnectTimer=null}if(txt)txt.textContent='Paused'}
|
||
else{connectLogStream()}
|
||
}
|
||
|
||
function scheduleLogReconnect(){
|
||
if(state.logPaused||state.section!=='logs'||state.logReconnectTimer)return;
|
||
state.logReconnectTimer=setTimeout(function(){state.logReconnectTimer=null;connectLogStream()},1500);
|
||
}
|
||
|
||
async function connectLogStream(){
|
||
if(state.logReconnectTimer){clearTimeout(state.logReconnectTimer);state.logReconnectTimer=null}
|
||
if(state.logEventSource){state.logEventSource.close();state.logEventSource=null}
|
||
if(state.logPaused||state.section!=='logs')return;
|
||
const connectSeq=++state.logConnectSeq;
|
||
let ticket;
|
||
try{const res=await api('POST','/logs/ticket');ticket=res.ticket}catch(err){const t=document.getElementById('logStatusText');const d=document.getElementById('logDot');const msg=(err&&err.message)||'';if(/authorization|invalid or expired session|session has expired|missing or invalid|administrator permission required/i.test(msg)){state.logPaused=true;state.logConnectSeq++;if(state.logReconnectTimer){clearTimeout(state.logReconnectTimer);state.logReconnectTimer=null}if(state.logEventSource){state.logEventSource.close();state.logEventSource=null}state.token='';localStorage.removeItem('admin_token');if(t)t.textContent='Session expired';if(d)d.className='dot-off';showOverlay('loginOverlay');return}if(t)t.textContent='Reconnect failed';if(d)d.className='dot-off';scheduleLogReconnect();return}
|
||
if(connectSeq!==state.logConnectSeq||state.logPaused||state.section!=='logs')return;
|
||
const es=new EventSource('/admin/api/logs/stream?ticket='+encodeURIComponent(ticket));
|
||
state.logEventSource=es;
|
||
es.onopen=function(){const t=document.getElementById('logStatusText');if(t)t.textContent='Connected'};
|
||
es.onmessage=function(e){
|
||
try{const entry=JSON.parse(e.data);state.logEntries.push(entry);
|
||
while(state.logEntries.length>state.logMaxLines)state.logEntries.shift();
|
||
appendLogLine(entry);
|
||
const c=document.getElementById('logCount');if(c)c.textContent=state.logEntries.length+' entries';
|
||
}catch(err){}
|
||
};
|
||
es.onerror=function(){if(state.logEventSource===es){state.logEventSource.close();state.logEventSource=null}const t=document.getElementById('logStatusText');if(t)t.textContent='Reconnecting...';const d=document.getElementById('logDot');if(d)d.className='dot-off';scheduleLogReconnect()};
|
||
}
|
||
|
||
function matchesLogFilter(entry){
|
||
if(!state.logLevels[entry.level])return false;
|
||
if(state.logSearch){const s=state.logSearch.toLowerCase();if(!(entry.msg||'').toLowerCase().includes(s)&&!(entry.source||'').toLowerCase().includes(s)&&!(entry.attrs||'').toLowerCase().includes(s))return false}
|
||
return true;
|
||
}
|
||
|
||
function appendLogLine(entry){
|
||
if(!matchesLogFilter(entry))return;
|
||
const out=document.getElementById('logOutput');if(!out)return;
|
||
const div=document.createElement('div');
|
||
div.className='log-line l-'+entry.level.toLowerCase();
|
||
const ts=entry.ts?entry.ts.substring(11,23):'';
|
||
div.innerHTML='<span class="log-ts">'+esc(ts)+'</span><span class="log-lvl">'+esc(entry.level)+'</span><span class="log-src">['+esc(entry.source||'server')+']</span>'+esc(entry.msg)+(entry.attrs&&entry.attrs!=='{}'?' <span style="color:var(--text-micro)">'+esc(entry.attrs)+'</span>':'');
|
||
out.appendChild(div);
|
||
// Trim DOM to max lines
|
||
while(out.children.length>state.logMaxLines)out.removeChild(out.firstChild);
|
||
if(state.logAutoScroll)out.scrollTop=out.scrollHeight;
|
||
}
|
||
|
||
function renderLogLines(){
|
||
const out=document.getElementById('logOutput');if(!out)return;
|
||
out.innerHTML='';
|
||
state.logEntries.forEach(e=>{if(matchesLogFilter(e))appendLogLine(e)});
|
||
}
|
||
|
||
function copyAllLogs(){
|
||
const out=document.getElementById('logOutput');if(!out)return;
|
||
const lines=[];state.logEntries.forEach(e=>{if(matchesLogFilter(e))lines.push((e.ts||'')+' '+e.level+' ['+( e.source||'server')+'] '+e.msg+(e.attrs&&e.attrs!=='{}'?' '+e.attrs:''))});
|
||
navigator.clipboard.writeText(lines.join('\n')).then(()=>showToast('Copied '+lines.length+' log lines','info')).catch(()=>showToast('Copy failed','error'));
|
||
}
|
||
|
||
function clearLogs(){state.logEntries=[];const out=document.getElementById('logOutput');if(out)out.innerHTML='';const c=document.getElementById('logCount');if(c)c.textContent='0 entries';showToast('Log view cleared','info')}
|
||
|
||
/* ═══ Settings ═══ */
|
||
async function renderSettings(){
|
||
let settings;
|
||
try{settings=await api('GET','/settings')}catch(e){return'<div class="page-title">Settings</div><p style="color:var(--red)">'+esc(e.message)+'</p>'}
|
||
state._settings={...settings};
|
||
const v=k=>settings[k]||'';
|
||
const isOn=k=>v(k)==='1'||v(k)==='true';
|
||
let html='<div class="page-title">Server Settings</div><div class="page-desc">Configure your OwnCord server</div>';
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>General</h3></div><div class="section-card-body">';
|
||
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Server Name</div></div><div class="setting-ctrl"><input class="form-input" id="s-server_name" value="'+esc(v('server_name'))+'" style="width:240px" oninput="markSettingsChanged()"></div></div>';
|
||
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Server Icon URL</div><div class="setting-desc">Not used by the server or client yet — stored for a future release</div></div><div class="setting-ctrl"><input class="form-input" id="s-server_icon" value="'+esc(v('server_icon'))+'" style="width:240px" disabled title="Not implemented yet"></div></div>';
|
||
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Message of the Day</div><div class="setting-desc">Shown to users when they connect</div></div><div class="setting-ctrl"><input class="form-input" id="s-motd" value="'+esc(v('motd'))+'" style="width:300px" oninput="markSettingsChanged()"></div></div>';
|
||
html+='</div></div>';
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>Limits</h3></div><div class="section-card-body">';
|
||
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Max Upload Size (bytes)</div><div class="setting-desc">Controlled by upload.max_size_mb in config.yaml (requires restart) — this display value has no effect</div></div><div class="setting-ctrl"><input class="form-input" id="s-max_upload_bytes" value="'+esc(v('max_upload_bytes'))+'" style="width:160px" type="number" disabled title="Set upload.max_size_mb in config.yaml and restart"></div></div>';
|
||
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Voice Quality</div><div class="setting-desc">Controlled by voice.quality in config.yaml (requires restart) — this display value has no effect</div></div><div class="setting-ctrl"><select class="filter-select" id="s-voice_quality" disabled title="Set voice.quality in config.yaml and restart"><option value="low" '+(v('voice_quality')==='low'?'selected':'')+'>Low</option><option value="medium" '+(v('voice_quality')==='medium'?'selected':'')+'>Medium</option><option value="high" '+(v('voice_quality')==='high'?'selected':'')+'>High</option></select></div></div>';
|
||
html+='</div></div>';
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>Security</h3></div><div class="section-card-body">';
|
||
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Require 2FA</div><div class="setting-desc">Require all users to enable two-factor authentication</div></div><div class="setting-ctrl"><button class="toggle '+(isOn('require_2fa')?'on':'')+'" id="s-require_2fa" onclick="this.classList.toggle(\'on\');markSettingsChanged()"></button></div></div>';
|
||
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Registration Open</div><div class="setting-desc">Allow new users to register with invite codes</div></div><div class="setting-ctrl"><button class="toggle '+(isOn('registration_open')?'on':'')+'" id="s-registration_open" onclick="this.classList.toggle(\'on\');markSettingsChanged()"></button></div></div>';
|
||
html+='</div></div>';
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>Backup</h3></div><div class="section-card-body">';
|
||
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Schedule</div></div><div class="setting-ctrl"><select class="filter-select" id="s-backup_schedule" onchange="markSettingsChanged()"><option value="off" '+(v('backup_schedule')==='off'?'selected':'')+'>Off</option><option value="daily" '+(v('backup_schedule')==='daily'?'selected':'')+'>Daily</option><option value="weekly" '+(v('backup_schedule')==='weekly'?'selected':'')+'>Weekly</option></select></div></div>';
|
||
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Retention (days)</div></div><div class="setting-ctrl"><input class="form-input" id="s-backup_retention" value="'+esc(v('backup_retention'))+'" style="width:100px" type="number" oninput="markSettingsChanged()"></div></div>';
|
||
html+='</div></div>';
|
||
html+='<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px"><button class="btn btn-accent" id="saveSettingsBtn" '+(state.settingsChanged?'':'disabled')+' onclick="saveSettings()">Save Changes</button></div>';
|
||
return html;
|
||
}
|
||
|
||
function markSettingsChanged(){state.settingsChanged=true;renderNav();const btn=document.getElementById('saveSettingsBtn');if(btn)btn.disabled=false}
|
||
|
||
async function saveSettings(){
|
||
const body={};
|
||
['server_name','server_icon','motd','max_upload_bytes','voice_quality','backup_schedule','backup_retention'].forEach(k=>{const el=document.getElementById('s-'+k);if(el)body[k]=el.value});
|
||
['require_2fa','registration_open'].forEach(k=>{const el=document.getElementById('s-'+k);if(el)body[k]=el.classList.contains('on')?'true':'false'});
|
||
const btn=document.getElementById('saveSettingsBtn');
|
||
if(btn){if(btn.disabled)return;btn.disabled=true}
|
||
try{
|
||
await api('PATCH','/settings',body);
|
||
state.settingsChanged=false;renderNav();showToast('Settings saved');
|
||
// Leave the button disabled: there are no unsaved changes any more.
|
||
}catch(e){
|
||
showToast(e.message,'error');
|
||
if(btn)btn.disabled=false;
|
||
}
|
||
}
|
||
|
||
/* ═══ Backups ═══ */
|
||
async function renderBackups(){
|
||
let backups;
|
||
try{backups=await api('GET','/backups')}catch(e){return'<div class="page-title">Backups</div><p style="color:var(--red)">'+esc(e.message)+'</p>'}
|
||
let html='<div class="page-title">Backups</div><div class="page-desc">Database backup and restore</div>';
|
||
html+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px"><div class="section-card"><div class="section-card-header"><h3>Manual Backup</h3></div><div class="section-card-body" style="text-align:center;padding:32px"><button class="btn btn-accent" style="font-size:15px;padding:12px 32px" onclick="createBackup()" '+(state.backupRunning?'disabled':'')+'>'+(state.backupRunning?'<div class="spinner"></div> Running...':I.download+' Create Backup Now')+'</button></div></div>';
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>Schedule</h3></div><div class="section-card-body"><p style="color:var(--text-faint);font-size:13px">Configure backup schedule in Settings.</p><button class="btn btn-ghost" style="margin-top:8px" onclick="navigateTo(\'settings\')">Go to Settings</button></div></div></div>';
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>Backup History</h3></div><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>Filename</th><th>Size</th><th>Date</th><th style="text-align:right">Actions</th></tr></thead><tbody>';
|
||
if(!backups||!backups.length)html+='<tr><td colspan="4" style="text-align:center;color:var(--text-faint);padding:24px">No backups found</td></tr>';
|
||
else backups.forEach(b=>{
|
||
html+='<tr><td><code style="font-family:var(--font-mono);font-size:12px">'+esc(b.name)+'</code></td>';
|
||
html+='<td>'+fmtBytes(b.size)+'</td><td>'+(b.date?new Date(b.date).toLocaleString():'')+'</td>';
|
||
html+='<td><div class="act-group" style="justify-content:flex-end"><button class="btn btn-ghost" onclick="openRestoreModal(\''+jsq(b.name)+'\')">Restore</button><button class="act-btn danger" title="Delete" onclick="openDeleteBackupModal(\''+jsq(b.name)+'\')">'+I.trash+'</button></div></td></tr>';
|
||
});
|
||
html+='</tbody></table></div></div>';
|
||
return html;
|
||
}
|
||
|
||
async function createBackup(){
|
||
state.backupRunning=true;renderContent();
|
||
try{await api('POST','/backup');state.backupRunning=false;showToast('Backup created');renderContent()}catch(e){state.backupRunning=false;showToast(e.message,'error');renderContent()}
|
||
}
|
||
|
||
function openRestoreModal(name){
|
||
openModal('<div class="modal-header"><h3>Restore Backup</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">Overwrite the current database with <strong style="color:white">'+esc(name)+'</strong>? A pre-restore backup will be created. Server restart recommended after restore.</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmRestore(\''+jsq(name)+'\')">Restore</button></div>');
|
||
}
|
||
|
||
async function confirmRestore(name){
|
||
try{await api('POST','/backups/'+encodeURIComponent(name)+'/restore');closeModal();showToast('Database restored. Restart recommended.','info');renderContent()}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
/* Deleting a backup is irreversible — confirm it like every other destructive
|
||
action here. It also used to report success without looking at the response,
|
||
so a failed delete said "Backup deleted" and left the file in place. */
|
||
function openDeleteBackupModal(name){
|
||
openModal('<div class="modal-header"><h3>Delete Backup</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">Permanently delete <strong style="color:white">'+esc(name)+'</strong>? This cannot be undone.</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmDeleteBackup(\''+jsq(name)+'\')">Delete</button></div>');
|
||
}
|
||
|
||
async function confirmDeleteBackup(name){
|
||
try{await api('DELETE','/backups/'+encodeURIComponent(name));closeModal();showToast('Backup deleted');renderContent()}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
/* ═══ API Tokens ═══ */
|
||
function tokenStatus(t){
|
||
if(t.revoked_at)return'<span class="badge badge-red">Revoked</span>';
|
||
if(t.expires_at&&new Date(t.expires_at)<new Date())return'<span class="badge badge-yellow">Expired</span>';
|
||
return'<span class="badge badge-green">Active</span>';
|
||
}
|
||
async function renderTokens(){
|
||
let tokens;
|
||
try{tokens=await api('GET','/tokens')}catch(e){return'<div class="page-title">API Tokens</div><p style="color:var(--red)">'+esc(e.message)+'</p>'}
|
||
let html='<div class="page-title">API Tokens</div><div class="page-desc">Long-lived bearer tokens for bots, CI, and the introspection MCP tool. A token authenticates as its bound user. Owner only.</div>';
|
||
html+='<div style="margin-bottom:16px"><button class="btn btn-accent" onclick="openCreateTokenModal()">'+I.plus+' Create Token</button></div>';
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>Tokens</h3></div><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>Label</th><th>User</th><th>Created</th><th>Last Used</th><th>Expires</th><th>Status</th><th style="text-align:right">Actions</th></tr></thead><tbody>';
|
||
if(!tokens||!tokens.length)html+='<tr><td colspan="7" style="text-align:center;color:var(--text-faint);padding:24px">No API tokens</td></tr>';
|
||
else tokens.forEach(t=>{
|
||
const revoked=!!t.revoked_at;
|
||
html+='<tr><td>'+esc(t.label||'—')+'</td><td>'+esc(t.username)+'</td>';
|
||
html+='<td>'+(t.created_at?new Date(t.created_at).toLocaleString():'')+'</td>';
|
||
html+='<td>'+(t.last_used?new Date(t.last_used).toLocaleString():'<span style="color:var(--text-faint)">never</span>')+'</td>';
|
||
html+='<td>'+(t.expires_at?new Date(t.expires_at).toLocaleString():'<span style="color:var(--text-faint)">never</span>')+'</td>';
|
||
html+='<td>'+tokenStatus(t)+'</td>';
|
||
html+='<td><div class="act-group" style="justify-content:flex-end">'+(revoked?'':'<button class="act-btn danger" title="Revoke" onclick="confirmRevokeToken('+t.id+',\''+jsq(t.label)+'\')">'+I.trash+'</button>')+'</div></td></tr>';
|
||
});
|
||
html+='</tbody></table></div></div>';
|
||
return html;
|
||
}
|
||
|
||
function openCreateTokenModal(){
|
||
openModal('<div class="modal-header"><h3>Create API Token</h3><button class="modal-close" onclick="closeModal()">×</button></div>'+
|
||
'<div class="modal-body"><div class="form-group"><label class="form-label">Label</label><input id="tokLabel" class="form-input" placeholder="ci-bot" autofocus></div>'+
|
||
'<div class="form-group"><label class="form-label">User <span style="color:var(--text-faint)">(optional)</span></label><input id="tokUser" class="form-input" placeholder="owner (default)"></div>'+
|
||
'<div class="form-group"><label class="form-label">Expires in hours <span style="color:var(--text-faint)">(0 = never)</span></label><input id="tokExpires" class="form-input" type="number" min="0" value="0"></div></div>'+
|
||
'<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="createToken()">Create</button></div>');
|
||
}
|
||
|
||
async function createToken(){
|
||
const label=document.getElementById('tokLabel').value.trim();
|
||
const user=document.getElementById('tokUser').value.trim();
|
||
const expires=parseInt(document.getElementById('tokExpires').value,10)||0;
|
||
if(!label){showToast('Label is required','error');return}
|
||
try{
|
||
const d=await api('POST','/tokens',{label,username:user,expires_hours:expires});
|
||
showTokenOnceModal(d);
|
||
}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
// The raw token is shown exactly once here — it is never recoverable afterward.
|
||
function showTokenOnceModal(d){
|
||
openModal('<div class="modal-header"><h3>Token Created</h3><button class="modal-close" onclick="closeModal();renderContent()">×</button></div>'+
|
||
'<div class="modal-body"><p style="color:var(--text-muted)">Store this token now — it is shown only once and cannot be recovered. Bound to <strong style="color:white">'+esc(d.user)+'</strong>.</p>'+
|
||
'<div style="display:flex;gap:8px;margin-top:12px"><code style="flex:1;font-family:var(--font-mono);font-size:12px;background:var(--bg-active);padding:10px;border-radius:var(--radius-sm);word-break:break-all">'+esc(d.token)+'</code>'+
|
||
'<button class="btn btn-ghost" onclick="copyToken(\''+jsq(d.token)+'\')">Copy</button></div></div>'+
|
||
'<div class="modal-footer"><button class="btn btn-accent" onclick="closeModal();renderContent()">Done</button></div>');
|
||
}
|
||
function copyToken(t){navigator.clipboard.writeText(t).then(()=>showToast('Copied!','info')).catch(()=>showToast('Copy failed — select the token and copy it manually','error'))}
|
||
|
||
function confirmRevokeToken(id,label){
|
||
openModal('<div class="modal-header"><h3>Revoke Token</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">Revoke <strong style="color:white">'+esc(label||('#'+id))+'</strong>? Any client using it will immediately lose access. This cannot be undone.</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="revokeToken('+id+')">Revoke</button></div>');
|
||
}
|
||
async function revokeToken(id){
|
||
try{await api('DELETE','/tokens/'+id);closeModal();showToast('Token revoked');renderContent()}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
/* ═══ Emoji ═══ */
|
||
/* Custom emoji live on the ordinary member API (/api/v1/emoji) rather than
|
||
under /admin/api: the desktop client reads the same list, and MANAGE_SERVER
|
||
is enforced by the route itself. The panel's session token authenticates
|
||
there unchanged, so this needs its own fetch helper — like pluginApi. */
|
||
async function emojiApi(method,path,opts){
|
||
const init={method,headers:{'Authorization':'Bearer '+state.token}};
|
||
if(opts&&opts.body!==undefined)init.body=opts.body;
|
||
const res=await fetch('/api/v1/emoji'+path,init);
|
||
if(res.status===401){handleSessionExpired();throw new Error('Your session expired — sign in again.')}
|
||
if(res.status===204)return null;
|
||
const text=await res.text();
|
||
let data=null;
|
||
if(text){try{data=JSON.parse(text)}catch(e){data=null}}
|
||
if(!res.ok)throw new Error((data&&(data.message||data.error))||text.trim()||res.statusText);
|
||
return data;
|
||
}
|
||
|
||
/* The image route needs the Authorization header, which <img src> cannot send.
|
||
Each thumbnail is therefore fetched with the token and swapped in as a blob:
|
||
URL once the section has been written into the DOM. */
|
||
async function loadEmojiThumbnails(){
|
||
const imgs=document.querySelectorAll('img[data-emoji-url]');
|
||
for(const img of imgs){
|
||
try{
|
||
const res=await fetch(img.getAttribute('data-emoji-url'),{headers:{'Authorization':'Bearer '+state.token}});
|
||
if(!res.ok)continue;
|
||
const blob=await res.blob();
|
||
img.src=URL.createObjectURL(blob);
|
||
img.addEventListener('load',()=>URL.revokeObjectURL(img.src),{once:true});
|
||
}catch(e){/* a thumbnail that will not load is not worth an error toast */}
|
||
}
|
||
}
|
||
|
||
async function renderEmoji(){
|
||
let list;
|
||
try{list=await emojiApi('GET','/')}catch(e){return'<div class="page-title">Emoji</div><p style="color:var(--red)">'+esc(e.message)+'</p>'}
|
||
if(!Array.isArray(list))list=[];
|
||
|
||
let html='<div class="page-title">Emoji</div><div class="page-desc">Server-wide custom emoji, usable as <span style="font-family:var(--font-mono)">:shortcode:</span> in messages and reactions</div>';
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>Upload</h3></div><div class="section-card-body">';
|
||
html+='<div style="color:var(--text-faint);font-size:13px;margin-bottom:10px">PNG, JPEG, GIF or WebP. Up to 512 KB and 128×128 pixels. Shortcodes are 2-32 characters of a-z, 0-9 or underscore.</div>';
|
||
html+='<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">';
|
||
html+='<input type="text" id="emojiShortcode" class="form-input" style="max-width:200px" placeholder="shortcode" maxlength="32">';
|
||
html+='<input type="file" id="emojiFile" accept="image/png,image/jpeg,image/gif,image/webp" class="form-input" style="max-width:320px;padding:8px">';
|
||
html+='<button class="btn btn-accent" onclick="uploadEmoji()">'+I.upload+' Upload</button>';
|
||
html+='</div></div></div>';
|
||
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>Installed ('+list.length+')</h3><button class="btn btn-ghost" onclick="renderContent()">'+I.refresh+' Refresh</button></div><div class="section-card-body no-pad">';
|
||
html+='<table class="tbl"><thead><tr><th style="width:60px">Preview</th><th>Shortcode</th><th style="text-align:right">Actions</th></tr></thead><tbody>';
|
||
if(!list.length)html+='<tr><td colspan="3" style="text-align:center;color:var(--text-faint);padding:24px">No custom emoji yet</td></tr>';
|
||
else list.forEach(function(e){
|
||
html+='<tr><td><img alt="'+esc(e.shortcode)+'" data-emoji-url="'+esc(e.url)+'" style="width:32px;height:32px;object-fit:contain"></td>';
|
||
html+='<td style="font-family:var(--font-mono)">:'+esc(e.shortcode)+':</td>';
|
||
html+='<td><div class="act-group" style="justify-content:flex-end"><button class="act-btn danger" title="Delete" onclick="confirmDeleteEmoji('+e.id+',\''+jsq(e.shortcode)+'\')">'+I.trash+'</button></div></td></tr>';
|
||
});
|
||
html+='</tbody></table></div></div>';
|
||
setTimeout(loadEmojiThumbnails,0);
|
||
return html;
|
||
}
|
||
|
||
async function uploadEmoji(){
|
||
const codeInput=document.getElementById('emojiShortcode');
|
||
const fileInput=document.getElementById('emojiFile');
|
||
const shortcode=(codeInput&&codeInput.value||'').trim();
|
||
const file=fileInput&&fileInput.files&&fileInput.files[0];
|
||
if(!shortcode){showToast('Enter a shortcode first','error');return}
|
||
if(!file){showToast('Choose an image first','error');return}
|
||
const fd=new FormData();
|
||
fd.append('shortcode',shortcode);
|
||
fd.append('file',file);
|
||
try{
|
||
/* No explicit Content-Type: the browser must set the multipart boundary. */
|
||
await emojiApi('POST','/',{body:fd});
|
||
showToast('Added :'+shortcode.toLowerCase()+':');
|
||
renderContent();
|
||
}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
function confirmDeleteEmoji(id,shortcode){
|
||
openModal('<div class="modal-header"><h3>Delete Emoji</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">Delete <strong style="color:white">:'+esc(shortcode)+':</strong>? Messages and reactions that use it will show the plain text instead. This cannot be undone.</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="deleteEmoji('+id+')">Delete</button></div>');
|
||
}
|
||
|
||
async function deleteEmoji(id){
|
||
try{
|
||
await emojiApi('DELETE','/'+id);
|
||
closeModal();
|
||
showToast('Emoji deleted');
|
||
renderContent();
|
||
}catch(e){showToast(e.message,'error')}
|
||
}
|
||
|
||
/* ═══ Plugins ═══ */
|
||
/* The plugin lifecycle API lives under /api/v1/admin/plugins (same admin auth
|
||
and IP gate, different prefix), so it needs its own fetch helper rather than
|
||
api(). Errors come back as plain text from http.Error, not JSON. */
|
||
async function pluginApi(method,path,opts){
|
||
const init={method,headers:{'Authorization':'Bearer '+state.token}};
|
||
if(opts&&opts.body!==undefined)init.body=opts.body;
|
||
const res=await fetch('/api/v1/admin/plugins'+path,init);
|
||
if(res.status===401){handleSessionExpired();throw new Error('Your session expired — sign in again.')}
|
||
if(res.status===204)return{data:null,res};
|
||
const text=await res.text();
|
||
let data=null;
|
||
if(text){try{data=JSON.parse(text)}catch(e){data=null}}
|
||
if(!res.ok){
|
||
const msg=(data&&(data.message||data.error))||text.trim()||res.statusText;
|
||
throw new Error(msg);
|
||
}
|
||
return{data,res};
|
||
}
|
||
|
||
function pluginManifestSummary(row){
|
||
const raw=row.manifest_json||row.ManifestJSON||'';
|
||
if(!raw)return'';
|
||
try{
|
||
const m=JSON.parse(raw);
|
||
const bits=[];
|
||
if(m.description)bits.push(m.description);
|
||
if(Array.isArray(m.permissions)&&m.permissions.length)bits.push('permissions: '+m.permissions.join(', '));
|
||
return bits.join(' — ');
|
||
}catch(e){return''}
|
||
}
|
||
|
||
async function renderPlugins(){
|
||
let rows;
|
||
try{
|
||
const out=await pluginApi('GET','/');
|
||
rows=out.data||[];
|
||
state.pluginRuntime=out.res.headers.get('X-Plugin-Runtime')||'unknown';
|
||
}catch(e){
|
||
return'<div class="page-title">Plugins</div><p style="color:var(--red)">'+esc(e.message)+'</p><button class="btn btn-accent" onclick="renderContent()">Retry</button>';
|
||
}
|
||
|
||
const disabled=state.pluginRuntime==='disabled';
|
||
let html='<div class="page-title">Plugins</div><div class="page-desc">Install and manage server plugins</div>';
|
||
|
||
if(disabled){
|
||
html+='<div class="section-card" style="border-color:var(--yellow)"><div class="section-card-body"><strong style="color:var(--yellow)">Plugin runtime is disabled on this server.</strong><div style="color:var(--text-faint);font-size:13px;margin-top:4px">Installed plugins are listed below but cannot be installed, enabled, or removed until the runtime is turned on in the server configuration.</div></div></div>';
|
||
}else{
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>Install Plugin</h3></div><div class="section-card-body">';
|
||
html+='<div style="color:var(--text-faint);font-size:13px;margin-bottom:10px">Upload a plugin package (.zip, max 16 MB) containing a plugin.json manifest at its root.</div>';
|
||
html+='<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">';
|
||
html+='<input type="file" id="pluginFile" accept=".zip,application/zip" class="form-input" style="max-width:320px;padding:8px" onchange="document.getElementById(\'pluginInstallBtn\').disabled=!this.files.length">';
|
||
html+='<button class="btn btn-accent" id="pluginInstallBtn" disabled onclick="installPlugin()">'+I.upload+' Install</button>';
|
||
html+='</div></div></div>';
|
||
}
|
||
|
||
html+='<div class="section-card"><div class="section-card-header"><h3>Installed</h3><button class="btn btn-ghost" onclick="renderContent()">'+I.refresh+' Refresh</button></div><div class="section-card-body no-pad">';
|
||
html+='<table class="tbl"><thead><tr><th>Plugin</th><th>Version</th><th>Status</th><th>Installed</th><th style="text-align:right">Actions</th></tr></thead><tbody>';
|
||
if(!rows.length){
|
||
const empty=disabled?'No plugins installed — and the runtime is off':'No plugins installed yet';
|
||
html+='<tr><td colspan="5" style="text-align:center;color:var(--text-faint);padding:24px">'+empty+'</td></tr>';
|
||
}else rows.forEach(row=>{
|
||
const id=row.id!==undefined?row.id:row.ID;
|
||
const name=row.name||row.Name||'';
|
||
const version=row.version||row.Version||'';
|
||
const enabled=row.enabled!==undefined?row.enabled:row.Enabled;
|
||
const installed=row.installed_at||row.InstalledAt||'';
|
||
const summary=pluginManifestSummary(row);
|
||
html+='<tr><td><div><strong>'+esc(name)+'</strong>'+(summary?'<div style="font-size:12px;color:var(--text-faint);margin-top:2px">'+esc(summary)+'</div>':'')+'</div></td>';
|
||
html+='<td style="font-family:var(--font-mono);font-size:12px">'+esc(version||'—')+'</td>';
|
||
html+='<td>'+(enabled?'<span class="badge badge-green">Enabled</span>':'<span class="badge badge-muted">Disabled</span>')+'</td>';
|
||
html+='<td style="font-size:12px;color:var(--text-faint)">'+(installed?new Date(installed).toLocaleString():'')+'</td>';
|
||
html+='<td><div class="act-group" style="justify-content:flex-end">';
|
||
if(disabled){
|
||
html+='<span style="font-size:12px;color:var(--text-faint)">runtime off</span>';
|
||
}else{
|
||
html+='<button class="btn btn-ghost" onclick="setPluginEnabled('+id+','+(enabled?'false':'true')+')">'+(enabled?'Disable':'Enable')+'</button>';
|
||
html+='<button class="act-btn danger" title="Uninstall" onclick="openUninstallPlugin('+id+',\''+jsq(name)+'\')">'+I.trash+'</button>';
|
||
}
|
||
html+='</div></td></tr>';
|
||
});
|
||
html+='</tbody></table></div></div>';
|
||
return html;
|
||
}
|
||
|
||
async function installPlugin(){
|
||
const input=document.getElementById('pluginFile');
|
||
const btn=document.getElementById('pluginInstallBtn');
|
||
const file=input&&input.files&&input.files[0];
|
||
if(!file){showToast('Choose a .zip package first','error');return}
|
||
if(state.pluginBusy)return;
|
||
state.pluginBusy=true;
|
||
if(btn){btn.disabled=true;btn.textContent='Installing...'}
|
||
const fd=new FormData();
|
||
fd.append('plugin',file);
|
||
try{
|
||
// No explicit Content-Type: the browser must set the multipart boundary.
|
||
const out=await pluginApi('POST','/install',{body:fd});
|
||
const name=(out.data&&out.data.name)||file.name;
|
||
showToast('Installed '+name);
|
||
state.pluginBusy=false;
|
||
renderContent();
|
||
}catch(e){
|
||
state.pluginBusy=false;
|
||
showToast(e.message,'error');
|
||
if(btn){btn.disabled=false;btn.textContent='Install'}
|
||
}
|
||
}
|
||
|
||
async function setPluginEnabled(id,enable){
|
||
if(state.pluginBusy)return;
|
||
state.pluginBusy=true;
|
||
try{
|
||
await pluginApi('POST','/'+id+'/'+(enable?'enable':'disable'));
|
||
showToast(enable?'Plugin enabled':'Plugin disabled');
|
||
}catch(e){showToast(e.message,'error')}
|
||
state.pluginBusy=false;
|
||
renderContent();
|
||
}
|
||
|
||
function openUninstallPlugin(id,name){
|
||
openModal('<div class="modal-header"><h3>Uninstall Plugin</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">Remove <strong style="color:white">'+esc(name)+'</strong> and its files from the server? Any data it stored is discarded. This cannot be undone.</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="uninstallPlugin('+id+')">Uninstall</button></div>');
|
||
}
|
||
|
||
async function uninstallPlugin(id){
|
||
if(state.pluginBusy)return;
|
||
state.pluginBusy=true;
|
||
try{
|
||
await pluginApi('DELETE','/'+id);
|
||
closeModal();
|
||
showToast('Plugin uninstalled');
|
||
}catch(e){showToast(e.message,'error')}
|
||
state.pluginBusy=false;
|
||
renderContent();
|
||
}
|
||
|
||
/* ═══ Updates ═══ */
|
||
async function renderUpdates(){
|
||
// A failed check is not the same as "up to date" — saying so would be a lie
|
||
// that hides a broken update path.
|
||
let info,checkError='';
|
||
try{info=await api('GET','/updates')}catch(e){checkError=e.message||'Update check failed'}
|
||
let html='<div class="page-title">Updates</div><div class="page-desc">Server version management</div>';
|
||
html+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px">';
|
||
html+='<div class="update-card"><div class="update-icon" style="background:rgba(35,165,90,.15);color:var(--green)">'+I.check+'</div><div class="update-info"><div class="update-ver">'+(info?esc(info.current):'unknown')+'</div><div class="update-notes">Current version</div></div></div>';
|
||
if(checkError)html+='<div class="update-card" style="border-color:var(--red)"><div class="update-icon" style="background:rgba(242,63,67,.15);color:var(--red)">'+I.ban+'</div><div class="update-info"><div class="update-ver">Check failed</div><div class="update-notes">'+esc(checkError)+'</div></div></div>';
|
||
else if(info&&info.update_available)html+='<div class="update-card" style="border-color:var(--accent)"><div class="update-icon" style="background:var(--accent-glow);color:var(--accent)">'+I.updates+'</div><div class="update-info"><div class="update-ver">'+esc(info.latest)+' <span class="badge badge-accent">New</span></div><div class="update-notes">Available for download</div></div></div>';
|
||
else html+='<div class="update-card"><div class="update-icon" style="background:rgba(35,165,90,.15);color:var(--green)">'+I.check+'</div><div class="update-info"><div class="update-ver">Up to date</div><div class="update-notes">You\'re running the latest version</div></div></div>';
|
||
html+='</div>';
|
||
if(info&&info.update_available&&info.can_apply===false){
|
||
/* Container deployments: the binary is image content, so in-place apply is
|
||
refused server-side (503 CONTAINER_DEPLOYMENT) — say so instead of
|
||
offering a button that can only fail. */
|
||
html+='<div class="update-card"><div class="update-info"><div class="update-notes">In-place update is unavailable in container deployments — upgrade by pulling the new image and recreating the container.</div></div></div>';
|
||
html+='<div style="margin-top:16px"><button class="btn btn-ghost" onclick="renderContent()">'+I.refresh+' Check Again</button></div>';
|
||
}else if(info&&info.update_available){
|
||
html+='<div style="display:flex;gap:8px"><button class="btn btn-danger" onclick="applyUpdate()" '+(state.updateApplying?'disabled':'')+'>'+(state.updateApplying?'<div class="spinner"></div> Applying...':'Apply Update & Restart')+'</button>';
|
||
html+='<button class="btn btn-ghost" onclick="renderContent()">'+I.refresh+' Check Again</button></div>';
|
||
}else{
|
||
html+='<div style="margin-top:16px"><button class="btn btn-ghost" onclick="renderContent()">'+I.refresh+' Check for Updates</button></div>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
async function applyUpdate(){
|
||
openModal('<div class="modal-header"><h3>Apply Update</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">This will restart the server. All connected users will be briefly disconnected. Continue?</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmApplyUpdate()">Update & Restart</button></div>');
|
||
}
|
||
|
||
async function confirmApplyUpdate(){
|
||
closeModal();state.updateApplying=true;renderContent();
|
||
try{
|
||
const r=await fetch('/admin/api/updates/apply',{method:'POST',headers:{'Authorization':'Bearer '+state.token}});
|
||
if(r.ok){showToast('Update applied! Server restarting...','info');setTimeout(()=>location.reload(),10000);return}
|
||
let msg='Update failed';
|
||
try{const e=await r.json();msg=e.message||msg}catch(parseErr){}
|
||
showToast(msg,'error');
|
||
}catch(e){showToast(e.message,'error')}
|
||
// Failure path only: re-render so the button leaves its "Applying..." state
|
||
// instead of staying disabled until the next navigation.
|
||
state.updateApplying=false;renderContent();
|
||
}
|
||
|
||
/* ═══ Keyboard + Init ═══ */
|
||
document.addEventListener('keydown',e=>{
|
||
if(e.key==='Escape')closeModal();
|
||
if(e.key==='/'&&!document.querySelector('.modal-overlay.visible')){const s=document.querySelector('.filter-search');if(s){e.preventDefault();s.focus()}}
|
||
});
|
||
document.getElementById('modal').addEventListener('click',e=>{if(e.target===e.currentTarget)closeModal()});
|
||
|
||
checkAuth();
|
||
</script>
|
||
</body>
|
||
</html>
|