Files
OwnCord/Server/admin/static/index.html
T
J3vbandClaude Opus 5 4ff199e14f fix: resolve 107 verified defects across ws hub, voice/E2EE, db, and client (#1331)
* fix(client): style the user profile popup

The popup rendered unstyled: it appeared at the bottom of the page and
pushed the rest of the app up, with the avatar drawn as a full-width bar.

app.css carried a complete Discord-shaped card under `.user-popup` /
`.up-*`, but nothing in the codebase renders those classes — the
component emits `.upp-*`. The component had been rewritten with a new
prefix and the stylesheet was left pointing at a DOM that no longer
existed. With no rule matching, the card stayed `position: static`, so
the left/top it computes were discarded and both it and its overlay laid
out as ordinary blocks at the end of <body>.

Replace the orphaned block with rules for the classes actually rendered,
following the same anatomy: banner strip, avatar straddling the
banner/body seam inside a ring punched from the card background, panel
sections, action row. Everything routes through existing tokens, so the
card follows the theme contract.

Two latent bugs fixed while there:

  - Placement guessed a 300px card height and clamped only the top edge,
    so a member clicked low in the list opened a card that ran off the
    bottom of the window. Measure the card and clamp both edges.
  - The avatar has to hang off the body's top edge, but the body scrolls,
    and `overflow-y: auto` clips horizontally too. Make it a child of the
    card rather than the body.

The fade+scale moves from inline styles into CSS so a
`prefers-reduced-motion` override can drop it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): stop vite watching src-tauri

`npm run tauri dev` died on Windows partway through the cargo build:

    Error: EBUSY: resource busy or locked, watch
    'src-tauri\target\debug\deps\owncord_client_lib.dll'
    Error The "beforeDevCommand" terminated with a non-zero status code.

Vite's watcher recursed into `src-tauri/target/`, and the moment cargo
wrote the output DLL, node's FSWatcher raised EBUSY as an unhandled
error event and killed the vite process. Vite is tauri's
`beforeDevCommand`, so its death aborted the whole dev session.

The config matched the upstream Tauri vite template in every respect
except the `server.watch.ignored` block that template ships with. Add
it. Tauri already watches `src-tauri` itself for rebuilds, so nothing
is lost.

Windows-specific — EBUSY on an open handle is a Windows filesystem
semantic, and CI only ever runs `tauri build`, never `tauri dev`, so
neither caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(security): add 2026-08-04 whole-codebase security review (#1326)

Read-only security review of the full tree (Go server, admin panel, WASM
plugin host, LiveKit voice, Tauri client). No code changes.

Three findings, all the same defect class — a security predicate enforced
at some members of a handler family but not all:

- A-2026-08-01 (HIGH) handleDeleteChannelPermission omits the hierarchy and
  grantability guards its PUT twin carries, so a MANAGE_CHANNELS holder can
  clear their own role's channel deny and read private channels.
- A-2026-08-02 (HIGH) the admin channel list/patch/delete handlers omit the
  type == "dm" guard their sibling getPermChannel carries, so the same role
  can enumerate and irreversibly cascade-delete arbitrary DMs and group DMs.
- A-2026-08-03 (MEDIUM) DMService.RingTargets omits the block check the five
  other DM interaction sinks perform, so a blocked user can ring the person
  who blocked them.

Also records one non-vulnerability observation (backup restore writes to a
hardcoded database path, silently no-opping when database.path is
customised), the candidates rejected during verification, the areas verified
clean, and the areas not examined.


Claude-Session: https://claude.ai/code/session_01Q7GUJtdsHHHGs4pSiLn6LJ

Co-authored-by: Claude <noreply@anthropic.com>

* Full audit: docs/spec refresh + remediation (security fixes, dead-code removal, test & CI gaps) (#1327)

* docs: fix server reference docs (api, protocol, server-configuration, deployment)

api.md:
- Correct the login rate limit: 5/min per IP (was documented as 60/min);
  document the per-username lockout and lockout persistence
  (Server/api/constants.go, Server/api/auth_handler.go)
- Complete the middleware list to the real 9-entry chain incl. the
  opt-in Coraza WAF (Server/api/router.go)
- Add voice_sessions and broadcast_drops to the metrics sample
  (Server/api/metrics_handler.go) and document the otel-only
  Prometheus /metrics mount
- Add reference sections for the previously undocumented /admin/api
  endpoints: setup, stats, users, audit-log, settings, tokens,
  backups, updates, and the SSE log stream (Server/admin/api.go)

protocol.md:
- Fix type counts (client->server 26, server->client 37) and add the
  missing rows: call_ring, call_decline, emoji_update, call_incoming,
  call_declined
- Correct rate limits: voice join/leave 5/1s (was "None"), E2EE offer
  64/1s (was 5/1s), and add the call-ring limit (1/3s)
- Document the plugin command wire types (chat_command, command_reply,
  plugin_broadcast) and flag that they sit outside protocol-schema.json

server-configuration.md:
- Add missing keys: server.waf_* (3), database.type,
  telemetry.otlp_insecure, and the whole logging section +
  OWNCORD_LOGGING_LEVEL
- Correct plugin-disabled status code to 503 (was 501)

deployment.md:
- Drop the removed "version" field from the /health sample; add
  broadcast_drops to the metrics sample; note the distroless non-root
  image; refresh build version strings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(architecture): rewrite stale architecture pages against 5630aa1

All six pages carried "Verified against ddc49f0 (2026-07-19)" stamps and had
drifted:

- websocket.md: delete the false claim that docs/protocol-schema.json does
  not exist — it is the codegen source of truth (Server/scripts/genprotocol,
  CI-gated by make protocol-verify); note the hand-declared plugin command
  family as the one exception; refresh LOC
- server.md: fix the websocket dependency (github.com/coder/websocket, not
  nhooyr.io), refresh LOC (42k/71k), migrations 016 -> 028
- data-model.md: migrations 001-028, 23 -> 26 tables, add api_tokens and
  channel_user_overrides to the ER diagram, channels.type now includes
  announcement, note 017/024/027/028 columns; drop the claim that schema.md
  is 6 migrations behind (it is current)
- voice-e2ee.md: drop the stale claim that the E2EE flow is absent from
  protocol.md (it has a full section); document livekitE2EE.ts/identity.ts
  and identity-key pinning
- client.md: rewrite — Solid beachhead is gone; the HTTP path is now
  TOFU-pinned through http_proxy.rs (the doc claimed the opposite); shared
  tofu.rs core with explicit-consent pinning; 9 stores (roles store deleted,
  blocks + emoji added); refreshed LOC and tooling figures
- README.md: 26 tables/001-028; client-architecture.md described as the
  redirect stub it is; companion-audit links refreshed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(architecture/ux): align UI flow specs with current client behavior

- Cert first-use is a blocking trust modal on status "first_use" — the Rust
  proxy rejects the first connection until the user confirms (main.ts:146-176,
  tofu.rs); the specs described the pre-F4/F8 behavior (an 8s banner on
  "trusted_first_use"). Fixed in README.md and connection-and-auth.md.
- Dispatcher event table: add the five missing types (chat_bulk_deleted,
  roles_update, emoji_update, voice_moved, voice_disconnected) and note that
  call_incoming/call_declined are page-scoped listeners in MainPage.ts.
- channels-members-dms.md: the "no in-client block button" gap is closed
  (AdminActions.ts context-menu item -> SidebarMemberSection.ts:177-186);
  document group DMs (MemberPickerModal, 10-participant cap, rename/leave)
  and per-channel notification mutes (lib/channel-mutes.ts); refresh stale
  line anchors.
- voice-and-e2ee.md: document the actual E2EE verification surface (roster
  shield badge -> identity-mismatch modal -> rePinPeerIdentity with TOCTOU-
  safe key capture), noise suppression + fallback, device hot-swap, stream
  preview, and DM ring/incoming-call flow; drop the nonexistent
  VoiceChannel.ts reference.
- settings-and-admin.md: the "ban should collect a reason" gap is closed
  (appendBanFlow with reason + duration); document the admin-panel deep-link
  (lib/admin-panel.ts) and the tray status menu.
- messaging.md: correct the pinned-messages empty-state copy and drop the
  nonexistent components/message-input/ directory reference.
- Re-stamp all six specs "Verified against 5630aa1 (2026-08-04)".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs: refresh client-facing and top-level docs

- security.md: drop the stale "hardcoded Tenor API key" limitation (the GIF
  provider is Klipy, proxied server-side with an operator-supplied key —
  nothing ships in the client bundle); describe credential storage accurately
  (OS keyring primary, verified writes, DPAPI/ChaCha20 file fallback);
  complete the audit-log action list against the actual WriteAudit call sites
  and note that backup restore is not audit-logged; fix the firewall
  checklist to include the LiveKit media ports (7880-7881/TCP,
  50000-60000/UDP) and ACME port 80
- credential-storage.md: probe_credential_store sample now shows the real
  serialized backend value ("Keyring") and the full variant union
- quick-start.md + README.md: refresh build version strings to
  1.2.0-alpha.1; README "audits" section now points at the current audit
  documents
- contributing.md: sqlc rows no longer claim a PostgreSQL engine/pgdbgen
  (removed with the store layer); add the protocol-generate/verify targets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(plans): add verified status headers and fix stale references

Every plan under docs/plans/ now carries a dated status verified against
5630aa1:

- Shipped: audit-2026-07-19-decisions (all 13 rows), channel-visibility-
  unification, http-tofu-proxy, permission-middleware-consolidation (the
  disclosed ws.channelCanSend copy is still open, now at serve_ready.go:119),
  security-hardening-remediation, sqlc-adoption, v2-dispatch-migration,
  tauri-capability-narrowing (DNS-rebinding follow-up still open)
- Shipped with corrections: discord-parity — Phase 1's gap table was never
  re-marked; all six rows have since shipped, including archived channels,
  which are filtered by permissions.VisibleChannelIDs (checker.go:116-121);
  named leftovers (role hoist/mentionable, @RoleName mentions, categories as
  entities, dead-code list) stay open. security-scan-2026-07-22 — all 8
  findings closed; two of the four F3 follow-ups have since shipped (safety
  number rendered in the roster badge; rePinPeerIdentity wired to the
  identity-mismatch modal), getIdentityPin fail-open remains open; noted the
  scan artifact directory is not in the repo
- Design-only: slash-commands — added staleness notes (migration number 016
  now taken, Server/store/ deleted, src/state/ never existed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audits): reconcile prior audit statuses with current CI and code

- audit-test-coverage-2026-07-25: T-2026-07-25-21 (HIGH, 229/255 web e2e
  failing) was fixed by the mock repair but the audit was never updated —
  now RESOLVED, re-verified by a local 270/270 run at 5630aa1; the CI gate
  table row updated to match
- audit-2026-07-19: carried-over item 11 ("no Playwright job in ci.yml") is
  resolved — client-e2e (non-blocking, every PR) and the blocking
  client-e2e-parity job both exist; backlog item 10 marked DONE
  (client-tests is blocking, Playwright wired)

Only status/closure cells were edited; original finding text is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(changelog): add Unreleased section for post-v1.2.0-alpha.1 fixes

Three fixes landed after the release with no changelog home (the file had no
Unreleased section at all): the profile-popup styling fix (a308f81), the
vite/src-tauri watch fix (cdcfc03), and the AppImage env-key signing fix
(9d75890). Also corrects the Deferred-work note that still described the
Solid.js removal in the present progressive — it completed 2026-07-19.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(tests): rewrite the e2e issues log against a real suite run

The old file was dated 2026-03-18, claimed 209/209 passing (the suite is now
270 tests), and pointed at a plan document that does not exist in the repo.
Rewritten from an actual run at 5630aa1: 270/270 web tests green (8.6 min),
15/15 @parity subset green (the blocking CI job), with the suite inventory,
CI wiring, the two real open issues (three native specs matched by no
playwright.config.native.ts project; client-e2e still non-blocking), and
dispositions for every claim the old file carried.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): add 2026-08-04 docs-and-coverage audit report

Companion to the same-day security review (disjoint scope). Contains: the
verified architecture summary; real test-run results for every runnable
suite at 5630aa1 (Go race+deadlock, 4394 client unit tests at 94.66% stmt
coverage, 83 Rust tests + clippy, 270/270 web e2e, 15/15 parity, browser
smoke — with env-blocked suites named and their compensating CI evidence
cited); a 52-row UI/UX flow coverage matrix (30 covered / 21 partial /
1 untested / 0 broken, headline gaps: TOFU flow, E2EE verification, admin
panel, updater — all unit-only); per-doc drift findings with the commit that
fixed each; reconciliation of all four prior audits and eleven plans
(including the orphaned 2026-04-07 #8 resurfaced as DC-11); the dead-code
and TODO inventories; and a prioritized DC-01..DC-15 gap list with ordered
next steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(admin): add hierarchy guard to channel role-override delete (A-2026-08-01)

Deleting an override is a permission mutation: removing a deny row restores
exactly the access the PUT path refuses to grant, so a MANAGE_CHANNELS holder
could unlock a private channel their own role was locked out of. Gate DELETE
identically to handlePutChannelPermission: resolve the role (404 when
missing), fail closed without an actor role, and refuse targets at or above
the actor's position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(admin): exclude DM channels from the admin channel surface (A-2026-08-02)

DMs and group DMs share the channels table and id space with guild channels,
but they belong to their participants, not to MANAGE_CHANNELS holders:
listing exposed ids and group names of every private conversation, PATCH
could silently rename one, and DELETE cascade-destroyed one irreversibly.
List now filters type=dm; PATCH and DELETE resolve through getAdminChannel,
which answers 404 for DM ids so the surface does not confirm which ids are
private conversations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(service): enforce blocks on DM call rings (A-2026-08-03)

RingTargets checked participation but not blocks, so a blocked user could
still make the blocker's client ring. Route rings through
requireDMNotBlocked like every other DM sink; group DMs stay exempt inside
it, matching the send path (blocks are enforced at group creation instead).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(client): delete dead modules (DC audit remediation)

All verified unreferenced by any import before deletion:
- ServerStrip.ts: removed from the layout when the unified sidebar header
  landed (SidebarArea.ts); only its own orphaned unit test still used it.
  The e2e spec already asserted .unified-sidebar-header, so it is renamed
  to sidebar-header.spec.ts and retitled honestly.
- FileUpload.ts: uploads go through api.uploadFile from MessageInput.
- lib/reconcile.ts: nothing imports it; the messages store carries its own
  pending-send reconciliation.
- public/rnnoise-worklet.ts: unreferenced duplicate of the .js worklet the
  runtime actually loads, and public/ ships verbatim into the bundle.
- api.getSounds/deleteSound + SoundResponse: the server has no /sounds
  routes; these called endpoints that do not exist (pairs with the
  sounds-table drop on the server side).
- dm.store incrementDmMention: zero callers; DM mention counts flow from
  the server mention_count via the dispatcher. This was the one live knip
  error the CI '|| true' was masking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(client): retire the tauri-typegen ritual (DC-05)

src/generated/ was tauri-typegen output frozen on 2026-04-03: it covered 21
of the 29 IPC commands lib.rs registers, nothing ever imported it (0%
coverage), and CI carried a bespoke patch step solely to keep the unused
file lint-clean. Delete the directory and every part of the pipeline that
existed to feed it: the client-check patch step, the tauri-build
generate/patch steps, the tauri.conf.json plugin block, and the inert
Cargo.toml build-dependency (build.rs is bare tauri_build::build(); no Rust
source references the crate). Cargo.lock shrinks by exactly the typegen
subtree — no other resolution changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* ci: make knip blocking (DC-06 follow-through)

Pre-verified green locally after the dead-module deletions; the config
hints knip still prints do not affect its exit code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(server): drop the dead sounds table (A-2026-07-13)

The table shipped in 001 for a soundboard that was never built: no query,
model, sqlc definition, route or handler ever referenced it. Migration 029
drops it; the sqlc model regenerates without the Sound struct (sqlc emits a
struct per schema table even with zero queries). schema.md, the data-model
blueprint, and the 2026-07-19 audit closure table are updated in the same
change per the docs maintenance rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(server): remove dead WAF wrapper, use protocol constants, fix stale comments

- NewWAFMiddleware had no production caller (the router mounts the CRS
  variant); its doc text folds into NewWAFMiddlewareCRS and the tests call
  the survivor directly.
- serve_auth compares against MsgTypeAuth and the DM-close REST path builds
  its WS notification from MsgTypeDMChannelClose instead of restating the
  wire strings, so the generated constants are load-bearing again.
- Comment fixes: DatabaseConfig no longer claims Postgres scaffolding that
  main.go removed; host_ui.go no longer advertises a route that is not
  mounted (DC-09's sibling); buildReady cites docs/protocol.md, the file
  PROTOCOL.md was renamed to (DC-09).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(protocol): add the plugin command family to protocol-schema.json (DC-01)

chat_command, command_reply and plugin_broadcast were the only wire types
outside the schema: the first declared by hand in handlers_command.go, the
other two raw string literals, all bypassing the protocol-verify codegen
gate. Add the three schema entries (27 c2s / 39 s2c), regenerate both
constant files, and swap the hand-rolled declarations for the generated
constants. The ws protocol-contract test's exception list is empty now —
and stays that way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): wire orphaned native specs and typecheck the Playwright layer

- dm-system, reconnection and theme-persistence (14 tests) matched no
  project's testMatch in playwright.config.native.ts, so they had never
  executed (E2E-ISSUES open issue #1 / DC-03). All three use the persistent
  fixture + ensureLoggedIn, so they join native-authenticated.
- tests/e2e was excluded from tsconfig, leaving 47 spec files with no
  typechecking anywhere. New tsconfig.e2e.json project (+@types/node for
  the node-API fixtures), a typecheck:e2e script, and a CI step. The one
  real error it surfaced is fixed: mockTotpFailure omitted the required
  simulateWsFlow flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): cover createPromptModal and the external-abort close path

modalFactory.ts was the least-covered file in the repo (57.6%):
createPromptModal had no tests at all and createModal's external-abort
branch never ran with an onClose. Now 100% statements/branches/functions,
including the trimmed-submit, legitimate-empty-submit, Enter-preventDefault
and no-double-close contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): e2e-cover the TOFU certificate ceremony (DC-04 slice)

The first-use confirmation and mismatch warning are the client's core
security ceremony and had no e2e coverage. Six tests drive them through
the mocked Tauri event layer: first-use modal content, trust, cancel,
modal non-stacking, mismatch fingerprint rows, and disconnect-to-connect-
page. The mock now exposes its listener registry so tests can wait for
the async cert-tofu registration instead of racing it (validated with
--repeat-each=3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs: fix the inaccuracies the 2026-08-04 refresh missed

- contributing.md: drop the '-tags postgres' build row (no such tag exists
  anywhere in Server/), add the four Make targets the 07-25 audit created
  (test/test-deadlock/cover/cover-all), align the coverage statement with
  the real gates (client 70%, no Go floor by T-2026-07-25-19), point TS
  style at architecture/client.md instead of the tombstone, and describe
  the real dev-branch PR flow.
- docs/security.md: reporting section now defers to root SECURITY.md as
  the canonical policy (it said 48h where SECURITY.md promises 7 days, and
  described the maintainer's advisory path rather than the reporter's);
  fixed the updater-key link that resolved to docs/Server/... on GitHub.
- audit-2026-04-07.md closure table: #10 and #11 were long-resolved (#10
  verified in db/audit.go, #11 exceeded by per-PR e2e jobs), #6 written in
  future tense for work done 2026-07-19, #7 citing a 113-file count from
  months ago.
- README: Contributing section matched neither ci.yml nor contributing.md
  (branch from dev, not main); Docs Index gains the six missing live docs;
  the plugin system joins the feature list; the security row no longer
  anchors to an aging version string.
- server-configuration.md: the env-var table is explicitly a subset — the
  OWNCORD_<SECTION>_<KEY> scheme covers every key.
- mcp-introspect.md: index.mjs is 266 lines, not ~230.
- types.ts header cited PROTOCOL.md/API.md/SCHEMA.md, filenames that no
  longer exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* ci: pin claude.yml actions by SHA; add docs checkbox to the PR template

claude.yml was the only workflow with unpinned third-party actions —
checkout now uses the same v4.2.2 SHA the other workflows pin, and
claude-code-action pins the commit the v1 tag resolves to (Dependabot's
github-actions ecosystem keeps both fresh).

The PR template gains the docs checkbox A-2026-07-03 recommended: the
architecture/UX maintenance rule ('a PR changing a diagram's source-of-
truth files updates the diagram in the same PR') existed only as prose no
process step ever surfaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* style(client): prettier-format the cert-tofu spec

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): record the remediation pass and close finding statuses

- Security review: A-2026-08-01/02/03 -> RESOLVED with their pinning tests
  named.
- Docs-and-coverage audit: DC statuses updated in place (01/02/03/05
  resolved, 04/09 partial with the remainder named, 14's keep-decision
  recorded) and a remediation addendum added: what shipped, the decisions
  taken (plugin host API kept, reserved protocol entries kept, e2e soak not
  shortcut, the 404-on-missing-role semantics note), and the full
  verification table from real runs — Go race + deadlock suites green,
  4 tag builds, client 4360/4360 units at 95.35% coverage, Playwright
  276/276 in 8.9 min, parity 15/15.
- CHANGELOG Unreleased: security fixes, migration 029, protocol additions,
  dead-code retirement, CI gates.
- E2E-ISSUES: rewritten against the remediation HEAD (276/276), native
  orphan issue moved to resolved, mock listener-registry note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(admin): index the channel slice in the DM filter (gocritic rangeValCopy)

golangci-lint (CI-only gate) flags the range-value copy of the 152-byte
db.Channel struct in the admin list filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(client): add .nvmrc pinning Node 20 to match CI (DC-10)

Also re-triggers CI: the previous run's windows server job died to a Go
runtime unwinder fatal ('traceback did not unwind completely') with no
test failure — toolchain flake, and the integration lacks permission to
rerun failed jobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: 26 bug-hunt findings across WS hub, voice/E2EE, admin, roles, and client (runs 1-3) (#1328)

* fix(ws): keep pubsub subscriptions when a replaced client is unsubscribed

Both pubsub indexes are keyed by userID, but a reconnect registers a new
*Client under that same userID. UnsubscribeAll and Unsubscribe deleted by
userID alone, so a kick of the already-replaced connection stripped the live
one's topics. The live client stays in h.clients and keeps answering
ping/pong, so it never reconnects -- it just silently stops receiving every
global, user, and channel broadcast.

Guard the forward-index delete in unsubscribeLocked with an identity check and
route UnsubscribeAll through it, so the four Unsubscribe call sites
(voice_leave, hub_broadcast x2, handlers) and the three UnsubscribeAll ones
(kickClient, unregisterNow, registerNow) all share one rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): mark kicked clients offline instead of reporting them replaced

Every kick path deletes the hub entry via kickClient, so the readPump defer's
unregisterNow finds nothing and fell through to "return true", conflating
absent with replaced. serve_pumps.go then skipped MarkUserDisconnected, the
offline presence broadcast, and handleVoiceLeave -- already-connected peers
rendered every kicked user as online until that user reconnected and
disconnected cleanly.

Return exists instead: a different client in the slot is a genuine
replacement, an absent entry is a real disconnect. Only serve_pumps.go reads
the return value; the five serve.go/hub.go call sites discard it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): force a full ready when cold-tier replay hits the row cap

GetEventsSinceForChannels is "ORDER BY seq ASC LIMIT n", so a reconnect gap
larger than maxColdReplay returned the oldest 5000 rows and dropped the
newest. handleReconnect accepted any non-empty result as a successful resume,
and the client only tracks max(seq) with no gap detection -- so it accepted
the next live event and silently lost the range in between, including state
events (channel/role/member changes) that REST history fetches never repair.

Treat a result at the cap as overflow and fall through to the full ready
re-sync. An exactly-cap-remaining gap pays one unnecessary full ready.

maxColdReplay is hoisted to the package const block so the test can seed
exactly enough events to hit it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): re-elect the voice E2EE key holder on the two paths that skipped it

updateKeyHolder had only two callers (voice_join, voice_leave), so two paths
that remove a participant from voice left voiceKeyHolders naming someone who
is gone. IsVoiceKeyHolder then rejects the real lowest-uid participant's rekey
offers with NOT_KEY_HOLDER -- which the client does not handle -- after it has
already applied its rotated key locally, splitting keys across the room.

1. The LiveKit participant_left webhook (media-only loss, WS stays up) cleared
   voice state and broadcast voice_leave with no re-election.
2. registerNow's fresh-connect replacement (F5 reload) drops the old
   connection's voice state without transferring it. handleVoiceLeave never
   runs there: readPump skips it when replaced, and it early-returns on
   already-cleared state.

Both call updateKeyHolder outside h.mu, since it takes keyHolderMu then
h.mu.RLock. The recompute reads live client voice state, so it is idempotent
and stays correct when a network reconnect transfers voice state -- locked by
TestRegisterNow_KeepsKeyHolderWhenVoiceStateTransfers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): stand down as E2EE key holder on offer, keep peer keys on reconnect

Two independent key-holder desync bugs in E2EEManager:

1. _isKeyHolder had no demotion path -- set at join, promoted on participant
   leave, cleared only on voice leave. The server re-elects the lowest userID
   on every join, so a lower-ID joiner left the incumbent still believing it
   held the key with an armed 5-minute timer. Its rotations applied the new
   key locally before the server rejected the offers with NOT_KEY_HOLDER (which
   the client does not handle), so it went deaf and mute every rotation cycle.
   Accepting an offer proves the sender is the server-authoritative holder, so
   treat it as the demotion signal and clear the timer.

2. reannounceForReconnect cleared _peerPublicKeys and peer verifications with
   nothing able to refill them: handleAnnounce replies with an offer rather
   than a counter-announce, and the server relays stored peer keys only on
   voice_join, which an SFU-level reconnect never runs. handleOffer's
   unknown-peer guard then dropped every later rotation, stranding the
   reconnector on the pre-reconnect key. The clear was also unnecessary --
   peers' keys stay valid when we regenerate our own pair.

vitest 4396/4396; typecheck and prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tauri): give the ws sender slot connection ownership, bound livekit TLS

ws_proxy: state.tx is one slot shared by every ws_connect, and both the
install and the teardown clear were unconditional while the mutex was only
held in short scoped blocks. A handshake pends up to CONNECT_TIMEOUT and a
profile switch starts a second ws_connect without awaiting or cancelling the
first, so a stale connect could complete after a newer one was live, emit an
untagged "open", and install its sender over the live one -- routing the next
auth send to the previously-trusted host, then tearing down the live socket
and emitting "closed" while JS believed it was connected.

Add a generation counter claimed at ws_connect entry and checked under the
slot lock before install, plus same_channel ownership on the teardown clear,
mirroring the Arc::ptr_eq guard ptt.rs already uses for ATOMICRACE-001.

livekit_proxy: the outbound TcpStream::connect and TLS handshake were bare
awaits, while the sibling http_proxy.rs bounds both at 10s. TCP connect is
OS-bounded, but a peer that accepts TCP and never answers the ClientHello
blocked the task forever. The task holds `local` without polling it, so the
SDK closing its side never cancels it, and the detached per-connection tasks
survive stop_livekit_proxy -- so they leaked on every SDK retry.

cargo test 80 passed; clippy --all-targets -D warnings clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): keep voice E2EE alive across WS resume and unify voice teardown

A network reconnect transferred voiceChID/joinToken to the new connection
but left it unsubscribed from voice:<id> (the only transport for
voice_e2ee_announce relays) and wiped the announced ECDH key, so a
resumed key holder could never offer the room key to later joiners and
voice_join replayed nothing for the resumed user. registerNow now
transfers the announced key with the voice state and re-subscribes
VoiceTopic unconditionally (it is CONNECT_VOICE-gated at join; only the
message-stream ChannelTopic needs the READ gate).

The LiveKit participant_left webhook and CleanupVoiceForChannel cleared
voice state without dropping the voice-topic subscription, leaving the
socket receiving another room's announces (which carry no channel_id to
filter on) for its lifetime. All take-out-of-voice paths now go through
one clearVoiceAndUnsubscribe helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): route sequenced DMs through the normal FIFO

writePump drains sendHigh to exhaustion before send, so a seq-stamped DM
on the high queue reached the socket ahead of lower-seq events still
queued behind a slow write. The client acks max(seq) and replay is
strictly seq > last_seq, so a disconnect in that window silently and
permanently lost the overtaken events while auth_ok reported a clean
resume. Sequenced frames now share the one per-client FIFO; the high
queue remains for unsequenced targeted messages (DM opens, voice tokens).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(storage): remove the partial file when Save fails after create

The io.Copy and f.Sync error paths returned without deleting the file
created for the upload, and the orphan sweep is DB-row-driven, so a
write-side failure (ENOSPC, disk I/O error) permanently leaked a partial
storage/<uuid> with no DB row. One success-flag deferred cleanup now
covers every failure path (the oversize branch folds into it), fixing
all three callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): close three voice-E2EE ordering gaps and wire the DM mention badge

- setupKeyExchange generated the room key AFTER draining queued
  announces, so a key holder joining an ongoing call sent drained peers
  no offer — they waited on the 5-minute rotation timer. Keygen now
  precedes the drain and every drained peer gets its offer immediately.
- A key-holder re-election arriving while the elected client was still
  connecting was dropped (getCurrentChannelId is null for the whole
  key-exchange wait), stranding the client until timeout ejection. The
  manager now remembers its channel from setupKeyExchange, and the
  become-holder rotation resolves a pending room-key wait.
- Offers applied concurrently could finish out of order (no epoch on the
  receiver side), leaving the older key active. handleOffer now chains
  applications so offers apply strictly in WS delivery order.
- incrementDmMention had zero callers: the DM @mention badge (dmStore's
  mentionCount, the mute-immune signal DmSidebar renders) never fired
  live, only after a reconnect restored the server count. The dispatcher
  now bumps it under the same guards as the DM unread count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tauri): restart the LiveKit proxy when the TOFU pin changes

After the user accepted a rotated cert, two stale caches kept every
voice rejoin tunneling into the old pin until logout: the Rust reuse
branch returned the running listener (which bakes its fingerprint in at
spawn) without re-reading certs.json, and ensureLiveKitProxy's port
cache never invoked Rust again at all. start_livekit_proxy now loads the
stored fingerprint before the reuse check and tears down on host OR pin
change, and the TS side invokes it on every join — the reuse branch
dedups the unchanged case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(tauri): ignore two unreachable cargo-audit advisories

RUSTSEC-2024-0429 (glib 0.18, Linux-only, Variant::array_iter_str never
called; no semver-compatible fix exists) and RUSTSEC-2026-0097 (rand 0.7
as a phf_generator build-dep with a fixed seed and no log feature; the
pre-release kuchikiki pin blocks the upgrade path). Both entries document
their drop condition inline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): transfer the focused channel on WS resume so the message stream survives

registerNow's replaced-client branch moved voice state and the E2EE key to
the resumed connection but not the focused channel; newClient always starts
with channelID == 0 and the client never re-sends channel_focus on a resume,
so the ChannelTopic re-subscribe was a no-op and the user silently stopped
receiving chat_message until manually switching channels. Transfer the old
connection's focused channel, READ-gated and fail-closed like every other
ChannelTopic subscription.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): always include a voice room's participants in its voice-event audience

broadcastVoiceEvent filtered recipients on READ_MESSAGES while voice
membership is gated on CONNECT_VOICE alone, so a participant in the gap
(e.g. READ revoked mid-call by a channel override) never received the
room's voice_state/voice_leave. The client's E2EE key-holder election and
forward-secrecy rotation run only off the voice_leave WS event, so a
departing key holder was never replaced and new joiners hung until the
e2ee_timeout eject. Union the READ audience with the room's current
participants; what outsiders may observe is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): tear down the failed reconnect attempt's room instead of leaking it

The catch block read this._room, whose typed accessor returns null in the
"reconnecting" state — so the failed attempt's freshly created Room was
never disconnected and kept all its listeners. livekit-client emits
Disconnected synchronously on a failed connect, and in "reconnecting"
state the token/channel/url getters all return values, so each leaked room
spawned an additional concurrent reconnect loop whose AbortController was
discarded and unreachable from leaveVoice. Alias the attempt's room outside
the try and clean it up in the catch, mirroring cleanupAbortedReconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(admin): evict voice participants before deleting a channel

CleanupVoiceForChannel was doc-commented 'Called when a channel is
deleted' but had zero production callers, and the voice_states FK cascade
wipes the rows it reads — so deleting a voice channel stranded its
participants with live client voice state, a voice-topic subscription, and
a LiveKit session, and the stale sweeper could never recover them (a
nonexistent channel resolves base-role permission bits). Wire the cleanup
into handleDeleteChannel BEFORE the row delete, via HubBroadcaster.

Also harden the cleanup itself: the row delete and client-state clear are
now conditional on the participant still being in the deleted channel, so
a user who moved rooms mid-cleanup is untouched, and the evicted
participants are always included in their own voice_leave audience (their
client state is already cleared, so the participant union in
broadcastVoiceEvent cannot see them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): stop leaking an E2EE worker and SetKey listener per voice join

createRoom spun up a fresh E2EE Worker per Room while the key provider
lives for the whole process; livekit's per-room E2EEManager registers a
SetKey listener on the provider with no matching removal and never
terminates the worker. Every join, channel switch, or failed reconnect
attempt therefore permanently added one running worker plus one listener,
and every later setKey posted the new room key into every orphaned worker
— key material outliving its session. Track the worker on the session:
clear provider listeners and terminate the stale worker before each Room,
and terminate it in leaveVoice so the last key does not stay resident.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(service): reject role position collisions on update, matching create

UpdateRole's position branch ran only validatePosition and let an explicit
position land on a slot another role holds — while CreateRole refuses
exactly that, with a comment explaining why: every hierarchy comparison
uses >=/<=, so tied positions read as equal rank and the two roles can no
longer manage each other's members. Refuse a position held by a different
role with the same ErrBadRequest; re-stating the role's own position stays
allowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): close the window where a dying connection re-takes a pubsub topic

Subscribe had no counterpart to unsubscribeLocked's identity guard: an old
connection's in-flight handler (a channel_focus mid DB round-trip shares no
lock with registerNow) could Subscribe after UnsubscribeAll(old) had run,
stealing the topic from its replacement — whose own unsubscribes then skip
the entry while publishes go to the closed connection. Subscribe now
refuses a client whose send is closed (checked under ps.mu), and
registerNow closes the old client's send BEFORE stripping it, so a late
Subscribe either sees the closed send and is refused or slipped in earlier
and is removed by the subsequent UnsubscribeAll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ws): rewrite cold-replay if-else chain as switch (gocritic)

Fixes the ifElseChain lint failure on CI for both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): restore incrementDmMention deleted as dead code on dev

The audit PR (#1327) removed it from dm.store.ts because its only caller
lives on this branch (the DM mention badge wiring), which was not merged
yet. The rebase was textually clean but left dispatcher.ts calling a
function that no longer existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Audit closure pass: DC-04/06/08/09/12/13/15 (E2EE + updater e2e, fail-closed pin lookup, a11y pass, UX polish) (#1329)

* ci(server): run the tag-gated wazero/otel Go tests (DC-06)

The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): fail closed when the identity pin store is unreadable (DC-08)

getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).

getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.

Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(admin): write a backup_restore audit row that survives the restore (DC-09)

Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.

The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)

Two messaging-surface gaps the UX specs carried as open:

- channel_delete on the active channel now toasts "This channel was
  deleted" alongside the existing redirect (ux/channels-members-dms
  §1.2) — the redirect alone read as the app spontaneously changing
  channels. Non-active deletions stay silent.

- Reactions toggle optimistically (ux/messaging §5): the pill flips on
  the click, registered under the send's WS envelope id — the same
  correlation scheme as the optimistic message rows. updateReaction
  consumes the matching self-echo instead of re-applying it (the
  delta-based arithmetic would double-count), other users' echoes apply
  normally, and an error reply or local transport failure rolls back
  exactly that toggle via rollbackReaction in the dispatcher's error and
  send-failure handlers. The pill reverting is the failure feedback.

Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): guard the role-change submenu against double-fire (DC-12)

Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.

The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)

The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.

Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().

The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(e2e): E2EE identity-verification and updater journeys (DC-04)

The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:

voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.

updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.

Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* style(client): prettier-format the drag-reorder module and new e2e specs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): accessibility pass over the modal/overlay stack (DC-13)

The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.

- modalFactory: every factory modal now carries role=dialog + aria-modal,
  moves focus in on open, restores it on every close path, and Tab-cycles
  inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
  Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
  their existing headings, focus trap + restore, aria-label on icon-only
  close buttons, and Escape mapped to each modal's SAFE action (reject on
  the trust prompts — dismissal must never grant trust; cancel on the
  channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
  close; the sidebar is a vertical role=tablist with roving tabindex and
  ArrowUp/Down/Home/End activate-on-focus; the content pane is a
  tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
  aria-activedescendant tracking the active row. QuickSwitchOverlay:
  dialog + keyboard-operable rows (the inert current-server row stays
  unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
  (Arrow/Home/End move the single Tab stop, Enter/Space activate through
  the click path). inline-autocomplete: option ids + combobox attrs and
  aria-activedescendant on the composer textarea — deliberately NOT
  roving tabindex, since moving DOM focus out of the textarea would
  break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).

Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(ux): replace file:line anchors with symbol references (DC-15)

The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place

- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
  resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
  handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
  from headline gaps to covered, the §4 UX-problem bullets closed, and a
  §12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
  fix, the a11y pass, the UX polish, the backup_restore audit row, the
  tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
  passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
  updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): fix install-settle race in the updater e2e spec

The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Final audit closure: admin-panel e2e, container-safe updates, blocking e2e gate, dependency policy (#1330)

* ci(server): run the tag-gated wazero/otel Go tests (DC-06)

The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): fail closed when the identity pin store is unreadable (DC-08)

getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).

getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.

Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(admin): write a backup_restore audit row that survives the restore (DC-09)

Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.

The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)

Two messaging-surface gaps the UX specs carried as open:

- channel_delete on the active channel now toasts "This channel was
  deleted" alongside the existing redirect (ux/channels-members-dms
  §1.2) — the redirect alone read as the app spontaneously changing
  channels. Non-active deletions stay silent.

- Reactions toggle optimistically (ux/messaging §5): the pill flips on
  the click, registered under the send's WS envelope id — the same
  correlation scheme as the optimistic message rows. updateReaction
  consumes the matching self-echo instead of re-applying it (the
  delta-based arithmetic would double-count), other users' echoes apply
  normally, and an error reply or local transport failure rolls back
  exactly that toggle via rollbackReaction in the dispatcher's error and
  send-failure handlers. The pill reverting is the failure feedback.

Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): guard the role-change submenu against double-fire (DC-12)

Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.

The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)

The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.

Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().

The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(e2e): E2EE identity-verification and updater journeys (DC-04)

The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:

voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.

updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.

Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* style(client): prettier-format the drag-reorder module and new e2e specs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): accessibility pass over the modal/overlay stack (DC-13)

The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.

- modalFactory: every factory modal now carries role=dialog + aria-modal,
  moves focus in on open, restores it on every close path, and Tab-cycles
  inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
  Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
  their existing headings, focus trap + restore, aria-label on icon-only
  close buttons, and Escape mapped to each modal's SAFE action (reject on
  the trust prompts — dismissal must never grant trust; cancel on the
  channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
  close; the sidebar is a vertical role=tablist with roving tabindex and
  ArrowUp/Down/Home/End activate-on-focus; the content pane is a
  tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
  aria-activedescendant tracking the active row. QuickSwitchOverlay:
  dialog + keyboard-operable rows (the inert current-server row stays
  unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
  (Arrow/Home/End move the single Tab stop, Enter/Space activate through
  the click path). inline-autocomplete: option ids + combobox attrs and
  aria-activedescendant on the composer textarea — deliberately NOT
  roving tabindex, since moving DOM focus out of the textarea would
  break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).

Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(ux): replace file:line anchors with symbol references (DC-15)

The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place

- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
  resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
  handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
  from headline gaps to covered, the §4 UX-problem bullets closed, and a
  §12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
  fix, the a11y pass, the UX polish, the backup_restore audit row, the
  tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
  passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
  updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): fix install-settle race in the updater e2e spec

The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* ci: promote client-e2e to blocking (DC-07)

The soak is decided: green full-suite runs at 270, 276 and 291 tests across
the audit branches, and the one hard failure in the window was a real spec
bug a non-blocking job would have let rot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(server): refuse in-place self-update in container deployments

Resolves the long-standing handleApplyUpdate TODO. In a container the
running binary is image content: the staged replacement dies with the
container and the restart comes back as the old image. RunningInContainer
(OWNCORD_CONTAINER authoritative both ways — the shipped Dockerfile sets 1,
bind-mount operators can set 0 — with /.dockerenv//run/.containerenv as
fallback) now gates POST /admin/api/updates/apply with 503
CONTAINER_DEPLOYMENT before any updater logic, GET /admin/api/updates gains
can_apply, and the admin SPA swaps the apply button for an image-upgrade
note when it is false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs: adopt the dependency pinning/review policy (DC-11, 2026-04-07 #8)

Writes down the policy the lockfiles already enforce: lockfiles
authoritative with npm ci-only installs, weekly Dependabot with majors
adopted deliberately, per-PR security gates (npm audit on shipped deps,
govulncheck, cargo audit, knip), and toolchain-level version pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(admin): add the admin-panel e2e journey against a real server (DC-04)

The admin SPA was the one surface no suite could reach: it is served by the
Go server and mocked nowhere. start-server.sh builds and boots a real
server (fresh temp data dir, TLS off, loopback) and the journey drives the
SPA end to end — first-run wizard creating the owner, dashboard stats,
channel create/rename, audit-log rows for both mutations, and sign-out/
sign-in. One shared page keeps the localStorage session across the serial
steps, mirroring the native suite's persistent fixture and staying under
the 5-logins/min limiter; on a Playwright retry the wizard branch downgrades
to login since setup is one-shot server-side. New non-blocking admin-e2e CI
job on the same graduation convention client-e2e followed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): final closure — every DC finding resolved or deliberately reserved

Records the owner-directed closure pass (§13): DC-04 fully (admin journey
was the last row), DC-07 (client-e2e blocking), DC-09 fully (container-
aware update refusal), DC-11 + 2026-04-07 #8 (dependency policy).
Remaining open items are all deliberate: DC-14 reserved protocol entries,
the admin-e2e soak graduation, and the accepted/tracked 2026-04 carryovers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(attachments): stop the orphan sweep destroying every avatar and the grace period

The 15-minute maintenance sweep deleted attachment rows and their files for
any attachment with message_id IS NULL. Avatars are exactly that by design:
users.avatar points at the attachment by URL and nothing ever links it to a
message (migration 027). Every avatar in the instance was therefore destroyed
on the first tick past the grace period, permanently 404ing every profile
picture. The query now excludes attachments a user's avatar still points at.

Independently, the cutoff was formatted RFC3339 while uploaded_at is written
by SQLite as 'YYYY-MM-DD HH:MM:SS'. TEXT comparison is bytewise and ' ' sorts
before 'T', so every unlinked upload sharing the cutoff's UTC date was swept
regardless of time -- the one-hour grace collapsed to 'immediately'. Rather
than fix the format at the one call site, DeleteOrphanedAttachments now takes
a time.Time and formats it internally, so no caller can reintroduce the class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): send old_password so changing a password can succeed

The client posted {current_password, new_password} while the server decodes
json:"old_password" (Server/api/profile_handler.go:43). Go's encoding/json
does no alias matching, so OldPassword was always empty and every password
change returned 400 INVALID_INPUT -- the feature could never work for anyone.
docs/api.md and every server test already document old_password.

The existing unit test asserted the client's own broken payload, so it passed
while the feature was dead; it now asserts the documented server contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(admin): roll back and restart when a backup restore fails mid-copy

copyFile truncates the destination with os.Create before it can know whether
the read will succeed. On the restore path the destination is the live
database, already closed, so a failure in io.Copy or Sync left a zero-byte
chatserver.db, no rollback, and -- because the old code returned before the
restart -- a process still answering requests against a closed DB while the
response and the server_restart broadcast both claimed a restart was underway.

The failure branch now puts the pre-restore safety copy back (saying so
honestly in the error, including when the rollback itself fails) and requests
the restart the success path already did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): refuse client-side unmute while server-muted by a moderator

Push-to-talk called LiveKitSession.setMuted directly, which had no
server-mute guard -- only the voice widget's own handler checked. Unmuting
re-publishes a fresh mic track, and since MuteParticipantAudio only mutes the
track SIDs that exist at mute time while the LiveKit grant still carries the
microphone publish source, the SFU accepted it: holding PTT lifted a
moderator's mute and never told the server.

The guard now lives in setMuted itself, the one entry point every caller
shares, so PTT and any future caller are covered. Muting stays allowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): force a full ready when a client's seq is ahead of the ring buffer

EventsSince/EventsSinceFiltered guarded only the lower bound, so a client
asking for events newer than anything the buffer ever held got a non-nil empty
slice -- which handleReconnect reads as a successful, complete replay. It then
registers the client, sends auth_ok with replay_source=buffer and skips ready
entirely, leaving stale members, channels and read state until the counter
climbs back past the client's remembered value.

That disagreement is reachable in normal operation: the hub seeds its counter
from GetMaxEventSeq, which is 0 once the 24h pruner has emptied the table, so
a restart can reseed seq below a lastSeq clients preserve across reconnects.

Both functions now return nil (the existing 'cannot guarantee coverage'
signal) when afterSeq exceeds the newest buffered seq, so the caller falls
through to the cold tier and the intended full ready. afterSeq == newestSeq
remains the legitimate caught-up case and still replays empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): re-send channel_focus on auth_ok so reconnects keep receiving messages

channel_focus was sent only by mountChannel, which early-returns when the
channel id is unchanged, so a reconnect into the same channel never re-sent it.
The server transfers the focused channel from the old connection, but only
while that connection is still registered -- readPump's defer unregisters it
and drops every topic subscription the moment the server observes the close,
about a second before the client's first retry. Any server-observed close
(restart, proxy close, network reset) therefore resumed with no ChannelTopic
subscription: server channel messages, edits and reactions are delivered
exclusively over that topic, so the message stream went silently dead while
global events kept arriving and made the connection look healthy.

auth_ok fires on every connection including resumes and the full-ready
fallback, and it also covers the server-restart case where there is no old
state to transfer from. The server's handler is idempotent, so the extra focus
on a fresh connect is harmless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(messages): stop persisting HTML-escaped text, safely

bluemonday writes text tokens through html.EscapeString, so sanitizeContent
persisted and broadcast the escaped form: every apostrophe, quote, ampersand
and angle bracket reached other users as a literal entity, and because stored
quote lines began with '&gt;' the client's blockquote regex could never fire.
cleanText (display names, about, custom status, DM names) had the same bug.

Unescaping bluemonday's output alone would be a sanitizer bypass: surviving
text tokens can recombine into live markup -- '<<script>script>alert(1)<'
+ '</script>/script>' reassembles a real end tag. Instead the whole
unescape -> Sanitize -> unescape cycle now runs to a fixpoint, so the stored
result is by construction stable under re-sanitizing: any '<' that the
tokenizer would read as a tag start is stripped rather than re-encoded, and
only inert punctuation survives. The loop is bounded by the input length and
each pass is non-increasing; measured worst case over pathological tag/entity
soup at the 16 KiB input ceiling is under a millisecond.

The fuzz sinks are tightened to match the new contract rather than loosened:
they now require a tag-like start ('<' + letter or '/') because a bare '<'
followed by punctuation is inert plain text under every client render path.
The <script substring and idempotency checks are unchanged. Verified with
4.2M fuzz executions, zero crashers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): close the WS task and TLS socket on disconnect via generation-owned teardown

ws_disconnect dropped the slot's sender expecting the write task to end, but
the monitor task held my_tx — a Sender clone kept only to prove teardown
ownership — so rx.recv() could never yield None: the monitor waits on the
writer via join_next() while the writer waits on the monitor's clone
dropping. Every intentional disconnect or profile-switch reconnect leaked
the writer, the reader, and the TLS socket, and with no server-side read
deadline the connection stayed registered — the user remained presence-online
after logout, and the stale Rust reader kept injecting the old server's
events into the new session's stores.

Ownership is now proven by the connection generation that already guards
install: the monitor captures my_generation plus the generation Arc and
clears/announces only if the generation is still current, checked under the
slot lock (generation only advances inside begin_connection while that lock
is held, so check-and-clear is atomic against new attempts). install_sender
receives the only Sender, so dropping the slot's sender really closes the
channel: writer exits, join_next returns, abort_all reaps the reader, and
the socket drops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): send voice_leave when the E2EE key exchange times out

connectAndSetup's key-exchange failure branch called leaveVoice(false) — no
voice_leave frame, no leaveVoiceChannel(). The timeout fires BEFORE
room.connect(), so no SFU participant ever exists and no LiveKit webhook can
clean up, while the server already registered the join when it sent
voice_token. The orphaned voice_states row matches the connected client's
channel, so sweepStaleVoiceStates never reaps it; once the ghost has the
lowest uid it wins key-holder election with a cleared E2EE state, every
later joiner's exchange times out and ghosts too, and rejoining the same
channel bounces off ALREADY_JOINED.

Mirror the reconnect-exhausted give-up path: leaveVoice(true) +
leaveVoiceChannel(), so the server drops the row and the local store
converges. The supersession checkpoints keep leaveVoice(false) — there a
newer attempt owns the server-side state and a voice_leave would destroy it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): keep voice state when the replay-failure fallback will transfer it

handleFreshConnect's stale-voice cleanup ran unconditionally, but the
replay-failure fallback (lastSeq > 0, e.g. after a restart reset the seq
counter) reaches it while the old connection is still registered — and
registerNow then transfers that connection's live voice state into the new
client. The cleanup had already deleted the DB row, broadcast voice_leave,
and removed the live LiveKit participant (using the very JoinedAt token
being transferred), so the user ended up "in voice" on the hub only:
voice_join bounced off ALREADY_JOINED and sweepStaleVoiceStates never
reaps in-memory state without a row.

Skip the cleanup when lastSeq > 0 and the still-registered old client's
voiceChID matches the row — exactly the case registerNow transfers. All
other cases (F5 fresh connects, no old client, mismatched channel) keep
the existing cleanup, and if the old client unregisters in the window
before registerNow, the untransferred row is reaped by the next sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): stop the mic meter stream when it resolves after teardown

The mic-meter IIFE had no post-await guard: a getUserMedia resolving after
SettingsOverlay.hide() ran cleanup() (or after the tab's signal aborted)
opened the microphone anyway, started the rAF meter loop, and registerMic
re-armed state that cleanupMic() had already cleared — the mic stayed hot
for the rest of the session with nobody left to stop it.

Mirror the camera preview's request-id guard: cleanupMic()'s invalidation
callback now bumps a micRequestId alongside cameraRequestId, the IIFE
captures the id before the await, and a stale or aborted request stops the
just-acquired tracks and bails before touching the AudioContext.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api,db): close the API-token and stale-ban holes in 2FA and account deletion

Four related gaps around sessionless (API-token) principals and account
teardown, all found by the bughunt harvest:

- 2FA enable/disable skipped the BUG-108 "revoke other sessions" step
  entirely when the caller authenticated with an API token (nil session).
  Both handlers now use the change-password pattern: keep=0 matches no
  row, so every login session is revoked.
- verify-totp issued a session to a user banned after the password step;
  it now runs the same IsEffectivelyBanned refusal as login.
- DeleteAccount left API tokens active (they authenticate independently
  of the purged sessions) and left a stale lapsed ban_expires in place,
  which makes banned=1 read as NOT banned — together a previously
  temp-banned self-deleted account stayed fully usable through any
  owner-minted token. Tokens are now revoked in the purge and
  anonymiseUser sets ban_expires = NULL.
- The last-admin guard resolved admin-class roles by display name
  ('Owner','Admin'), so renaming the seeded Admin role silently disabled
  self-deletion protection for its holders. It now keys on the canonical
  OwnerRoleID/AdminRoleID plus any role holding the Administrator bit.
  (The harvest's suggested criterion — Owner ID or Administrator bit
  alone — would have DROPPED seeded Admins, whose 0x3FFFFFFF permissions
  lack bit 30; the ID-based form preserves existing guard semantics.)
- DeleteAccount also now applies LeaveGroupDM's invariant: DM channels
  left with zero participants are removed instead of becoming
  unreachable, undeletable rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api,ws): rate-limit gaps — bucket isolation, focus/mark_read, call_decline, reaper horizon

- Every empty-prefix RateLimitMiddleware mount shared one bare-IP bucket,
  and the limiter records a timestamp per call regardless of the limit
  passed — so unrelated endpoints capped each other at the minimum limit
  (five ordinary profile edits 429'd the password endpoint; NAT'd logins
  blocked register). The prefix is now a required parameter and every
  mount names its own bucket, mirroring the existing client_update:/
  livekit_proxy:/gif: pattern. The sessions-list handler also stops
  401ing API-token principals (nil session only ever fed IsCurrent).
- channel_focus and mark_read were the only user-facing V2 handlers with
  no rate limit, and each drives an unmetered SQLite write plus pubsub
  churn; they now share a 5/s per-user budget (same underlying service
  call), silently dropping over-budget frames like their siblings.
- call_decline gets the same limiter as its sibling call_ring — the
  identical participant-lookup-plus-fan-out cost shape.
- The rate-limiter reaper pruned any entry idle past 15 minutes, but slow
  mode passes windows up to the 6 h admin cap, so long slow modes were
  silently reset; the cleanup horizon now covers the largest real window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(server): unreachable route envelopes, dead env override, admin paging, plugin/updater defects

Seven harvest findings across router, config, admin, plugin and updater:

- The global 1 MiB body cap shadowed every route with a larger documented
  envelope: the 16 MiB plugin install 400'd at ~1 MiB and an at-limit
  avatar could never fit its multipart framing. The exemption list is now
  a named var covering uploads, plugin install, and avatar — each of
  which enforces its own cap at the route/handler level.
- queryInt clamped offset with the limit's 500 cap, so the admin audit
  log and user list could never page past row 550; the cap is now an
  explicit per-call bound (offset callers pass MaxInt32).
- OWNCORD_EVENT_PERSISTENCE_* env overrides were documented but dead:
  envKeyToKoanf cut at the first underscore, producing the unknown path
  event.persistence_* that koanf silently drops.
- InstallPlugin trusted LastInsertId, which SQLite does not update on the
  upsert's DO UPDATE branch — on the shared writer connection a reinstall
  returned the rowid of some unrelated prior INSERT, so EnablePlugin
  no-opped and plugin_kv wrote to a nonexistent plugin id. RETURNING id
  is correct on both branches.
- Every wazero plugin re-activation compiled the module again and leaked
  the previous CompiledModule; the handle is now retained on the instance
  and closed in deactivate, the lost-activation race, and the
  closed-module release path.
- Linux server self-update was gated on the Windows-only
  chatserver.exe.sig asset it never uses; the required-asset check and
  the signature fetch are now GOOS-aware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): batch S4 — seq-gap shed, replay tail merge, drain-on-close, handshake teardown

Eight harvest findings in the hub/replay/pump paths, each locked by a
watched-red test in harvest_s4_internal_test.go / reconnect_db_test.go:

- kickClient closes the send channel BEFORE UnsubscribeAll so a racing
  Subscribe can never leave a dead client holding a topic.
- deliverBroadcast consults the topic limiter BEFORE allocating a seq: a
  shed frame no longer burns a sequence number that sits in the replay
  buffer forever unpublished.
- onStaleTick prunes idle topic-limiter buckets (Cleanup had no caller).
- dm_channel_open bumps the visibility watermark so a client resuming
  from an older seq takes the full-ready path instead of silently losing
  the targeted, unsequenced open.
- computeAllowedChannels treats a DM-lookup failure as fatal (full ready)
  instead of replaying with every DM event silently stripped.
- Cold-tier replay merges the ring-buffer tail past the newest persisted
  row; if the buffer cannot vouch for the flush gap it forces full ready.
- writePump drains queued frames (e.g. the BANNED kick reason) after
  closeSend instead of dropping them on the first closed channel.
- A failed post-registerNow handshake runs the offline teardown when no
  replacement connection holds the slot — no more users stuck online.

Declined by design: hoisting registerNow above the replay snapshot
(report L390) — every fallback path would re-register the same client
and registerNow self-kicks the slot holder; the µs dedup window does not
justify that risk in the hottest path.

The kickClient ordering test is a 300-iteration stress whose race window
is too narrow to hit reliably; it documents the invariant rather than
having been watched red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C1 — store merges, ready-badge resync, pending-send teardown

Ten harvest findings in the stores/dispatcher layer, each locked by a
watched-red vitest test:

- setMessages merges instead of clobbering: live broadcasts and
  pending/failed optimistic rows that landed while the history GET was
  in flight survive the snapshot.
- addChannel is idempotent — the re-sent channel_create on role edits no
  longer wipes unread/mention counts, lastMessageId, or canSend.
- setChannels carries client-synthesized DM rows across the rebuild.
- READY marks the focused channel read after the store repopulation so
  stale server read_states cannot resurrect badges on the channel the
  user is actively reading (skipped on first connect).
- setVoiceStates maps the ready payload's camera/screenshare flags
  instead of blanking live streams on a mid-call resync.
- The dm_channels length guard is gone: an empty array is authoritative
  and clears ghost DMs.
- addMessage's defensive pending-row reconcile requires content equality
  so another session's replayed message cannot consume the pending row.
- performSend into a detached history window reattaches to present
  first, mirroring onJumpToPresent.
- prependMessages at the cap keeps the fetched older page and detaches
  the window instead of silently discarding the fetch (which refetched
  the same page forever); hasMore is the server's value again.
- A connection leaving "connected" fails every pending optimistic send
  (retry affordance) instead of letting rows spin forever.

One existing assertion updated to the corrected semantics: trimming on
prepend now drops rows below the window, so hasMore stays the server's
value and the test asserts the detach instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): batch S5 — voice eviction scoping, fail-closed camera cap, role-service races

Seven harvest findings in the voice/service layer, each locked by a
watched-red test:

- The CONNECT_VOICE revocation sweep evicts via a channel-conditional
  clear (the in-memory analogue of LeaveVoiceChannelIfMatch): a
  voice_join to a permitted channel that commits while the DB-backed
  permission check runs can no longer be torn down. The report's
  suggested pre-check guard was rejected — it leaves the same race open
  between guard and clear, proven by the interleaving test.
- A failed channel switch's abort branch re-subscribes the restored
  session to its VoiceTopic and re-elects the key holder; without them
  the session silently missed every voice_e2ee relay.
- voice_camera fails closed when the VoiceMaxVideo lookup errors instead
  of skipping the cap check and enabling unconditionally.
- LiveKitProcess starts the child inside the p.mu critical section that
  publishes p.cmd (Wait stays outside), removing the data race between
  Start's cmd.Process write and IsRunning/Stop reads.
- AffectedUserIDs reports lookup success; handlePatchRole falls back to
  a blanket permission-cache invalidation when the member list was
  unreadable, instead of evicting nobody and leaving revoked grants live.
- RoleService serializes its read-check-write mutations (position
  uniqueness and the role cap are snapshot-enforced, not DB-enforced);
  concurrent creates can no longer land on the same position and tie
  every hierarchy comparison.
- channel_focus writes the read state even when the channel has no
  undeleted messages — the upsert is what zeroes mention_count, so
  emptied channels finally clear their badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C2 — voice eviction teardown, supersession scoping, VAD generation

Seven harvest findings in the voice/session layer, each locked by a
watched-red vitest test:

- A server-initiated voice_leave for self tears down the LiveKit session
  (mic publish + E2EE key material), guarded on channel match so a
  late-arriving leave cannot kill a newer join.
- VIDEO_LIMIT refusal rolls back with disableCamera() — max_video has no
  SFU-level enforcement, so the already-published track kept streaming.
- teardownForReconnect sends voice_camera/voice_screenshare OFF frames
  before stopping local tracks, freeing the server-side max_video slot a
  reconnect otherwise occupies forever.
- Supersession checkpoints 3/4/5 disconnect only their own local room
  (mirroring checkpoint 2) instead of calling the global leaveVoice,
  which by then tears down the newer attempt's live session.
- retryMicPermission honors a moderator's server-mute like it honors
  deafen — granting mic while listen-only no longer hands the channel an
  unmuted track.
- handleDisconnected defers to the active reconnect loop (livekit-client
  fires Disconnected synchronously inside the loop's own connect call),
  preventing a second uncancellable retry loop.
- stopVadPolling invalidates an in-flight startVadPolling addModule via
  a VAD-scoped generation counter, so VAD cannot resurrect itself with a
  stale threshold.

Deliberately skipped: the report's optional RATE_LIMITED camera rollback
— that error code is shared by unrelated actions and the payload cannot
attribute it to a camera toggle, so a blind rollback would be wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C3 — E2EE rotation races, pin-write tri-state, stale-offer guards

Six harvest findings in the voice-E2EE key-exchange layer, each locked
by a watched-red vitest test; the security-invariant sweep (re-pin
TOCTOU, forward-secrecy rekey, concurrent-rotation, blind-repin — 239
E2EE-adjacent tests) stays green:

- Re-election as key holder during an in-flight rotation defers (sets
  _isKeyHolder + _rotationPending, mirroring the sibling branch) instead
  of dropping the election and stranding the room without a holder.
- storeIdentityPin returns tri-state stored/no-store/failed; a FAILED
  pin write now marks the peer unverified instead of displaying
  "verified" with no pin persisted — an unpinned peer could never trip
  mismatch detection, the exact MITM window the pin exists to close.
- handleOfferInner discards a stale offer when the session keypair
  changed, not just the epoch — a non-key-holder never bumps epoch, so
  an offer surviving clearState() into the next session passed the
  epoch-only check.
- handleAnnounce's wrap-and-offer path gets the same epoch guard as the
  receive path, so a rotation landing mid-wrap cannot ship a dead key.
- The key-exchange retry races a FRESH promise (the first rejection had
  permanently settled the old one, making the retry window zero), and
  aborts cleanly when clearState() tore the session down mid-exchange.
- setupKeyExchange publishes _ecdhKeyPair only after _isKeyHolder and
  _roomKey are ready, so a concurrent announce is queued and drained
  through the offer-sending path instead of being consumed offer-less.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch R1 — secret store must not report a broken keyring as empty

Three harvest findings in the Tauri credential store, each locked by a
watched-red test:

- secret_store::get treated a keyring read error as "nothing stored"
  whenever the fallback file was also empty, which is indistinguishable
  from first login. loadOrGenerateIdentityKeyPair reads exactly that
  signal, so an unreadable keychain made the client mint and publish a
  fresh identity key over the existing one, invalidating every peer's
  TOFU pin. It now prefers a fallback copy and otherwise propagates the
  error; loadIdentityKey rethrows instead of swallowing to null.
- A failed keyring write left any older entry in place while the fresh
  secret went to the fallback file — and get() reads the keyring first,
  so the stale value shadowed the new one forever. The write-failure arm
  now purges the entry, mirroring the read-back-mismatch arm beside it.
- fallback_crypto deleted nothing when the key file's write or sync
  failed, leaving a short file that every later load rejects; since the
  key file is never rewritten once it exists, one ENOSPC poisoned the
  fallback store permanently.

Both Rust fixes needed a small injectable seam (get_with/set_with,
finish_new_key_file) because the keyring error branches are otherwise
unreachable without a live OS credential store.

The saved-login path is unaffected in behavior: loadCredential still
catches and degrades to "no saved credential" rather than surfacing the
new error. The persistence re-read in loadOrGenerateIdentityKeyPair
deliberately does not rethrow — the keypair already exists in memory by
then, so a transient failure keeps the existing "did not persist" warning.

fallback_crypto is cfg(not(windows)), so its test ran only under a
temporary local gate lift (reverted, verified no residual diff); it
executes for real on the Linux and macOS CI runners.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: rewrite CLAUDE.md per Claude 5 context guidance; commit skills + hunt workflow

The CLAUDE.md files were a manual: build commands, code style, layout Claude
can read off the filesystem. Rewritten so they are short and spend their
tokens on gotchas instead — the things that are invisible until they cost an
afternoon.

Moved out of CLAUDE.md into skills (progressive disclosure), which also fixes
three references to skills that never existed:
- ci-check: the full local CI mirror, including the four Go build-tag variants
  and the deadlock pass a plain build/test misses, and the windows-latest
  runtime.scanstack GC fault that should be rerun rather than investigated.
- db-change: the sqlc workflow plus three silent traps — non-ASCII query files
  truncating the NEXT query's emitted SQL, semicolons in migration comments
  orphaning statements, and LIMIT 1 mis-emitting on a :one query.
- protocol-change: regenerate both constant files and commit the pair.

Dropped: command lists duplicated from the Makefile and package.json, prettier
style rules the formatter already enforces, and layout facts a directory
listing answers. Added the subsystem invariants that keep getting rediscovered
the hard way — the ws seq/FIFO contract, voice-session supersession scoping,
E2EE staleness guards, and the Node 22 webstorage failure mode.

.claude/ is no longer ignored wholesale: skills and workflows are tracked so a
cloud session, which sees only tracked files, starts with instructions rather
than nothing. Machine-local settings and locks stay ignored. Deleted
bughunting.js, a superseded copy declaring the same workflow name as
bughunt.js, which left the registry ambiguous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C4 — cert-latch scoping, stale active channel, credential opt-out

Thirteen harvest findings across the client UI and wiring, each locked by a
watched-red vitest test:

- The TLS cert-mismatch latch fired on any host's event, so an unrelated
  saved profile's rotated certificate permanently killed this socket's
  reconnect loop. It now latches only for the connected host, cancels any
  pending reconnect (a timer armed before the mismatch would otherwise fire
  connect(), clearing the latch and resuming against that host), and resets
  on a fresh connect.
- ready never cleared activeChannelId when the channel vanished from the
  snapshot, and MainPage's subscriber had no else branch — the message list
  and composer stayed mounted and enabled against a channel the server no
  longer recognizes. Both sides fixed; the mark-read from batch C1 is
  suppressed when the clear happens.
- user_update re-saved the session token unconditionally, bypassing the
  remember-password opt-out, and dropped the stored password while doing it.
- A failed older-page fetch latched loadingOlder, permanently killing
  infinite scroll for that view; it now clears in a finally.
- Concurrent message jumps raced, letting the older response overwrite the
  newer window. Guarded by a generation counter.
- A FORBIDDEN send in a group DM flagged participants[0] as blocking, which
  disabled the unrelated 1:1 composer with that person; block gating is
  1:1-only.
- streamPreview added an abort listener per call instead of per signal.
- dm_channel_close had no fallback when the closed DM was being viewed;
  both call sites now share one closeDmLocally helper.
- The GIF picker routed through the textarea and discarded the draft.
- QuickSwitcher listed DM rows that the DM section already shows.
- Accepting a rotated certificate reconnected into a page with nothing left
  listening, stranding the user on the connect screen.
- Logout read voiceStore after clearAuth had already reset it, so the
  voice_leave was never sent; clearAuth now snapshots logoutWasInVoice.
- disconnect() left reconnectAttempt set, carrying a stale backoff ceiling
  into the next login.

Also fixes two lint errors this branch introduced earlier and that only a
full `npm run lint` catches: a useless spread in the C1 pending-send sweep
(now Array.from, which states the snapshot intent), and two floating
promises in C2's voice_leave handler, where converting an implicit-return
arrow to a block body stopped chaining them.

main.ts and MainPage.ts have no unit-test seam, so three focused pieces were
extracted to make the fixes testable: createUserUpdateCredentialSaver,
reconnectAfterCertAccept, and the logoutWasInVoice snapshot.

One existing assertion corrected: a dispatcher test claimed ready must keep
an active channel that was absent from the payload, which locked the bug.
It now keeps a channel that is present, with a sibling test for the absent
case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: resolve 107 verified defects across ws hub, voice/E2EE, db, and client

Findings came from a multi-round hunt, each verified against the tree by an
independent adversarial pass before any code changed, then fixed and re-reviewed.
Every behavioural fix carries a regression test that was confirmed to fail
against the pre-fix code.

Server (Go)
- Reconnect/replay: force a full ready when retention pruning has removed the
  events after a client's last_seq, rather than accepting the surviving suffix
  as a complete resume; close the snapshot-to-registration window under seqMu;
  restore the focused-channel subscription during the handshake via a new
  READ-gated active_channel_id auth field; supplement replay with the client's
  own voice room; tear down transferred voice sessions on a failed handshake.
- Hub: ratchet visibilityChangeSeq upward only (all three writers); make the
  stale-voice sweep error-aware so a transient DB failure no longer evicts
  every participant; re-elect the E2EE key holder on sweep and cleanup paths.
- Voice: preserve moderator mute/deafen across channel switches; deliver
  voice_leave to evicted users; gate camera/screenshare permission checks on
  the enabling direction only; reject joins to non-voice and archived channels.
- Permissions: archived channels are now read-only and unjoinable, and
  can_send is recomputed per client on role/override changes.
- Data: stop cascaded message deletes from stranding uploaded files
  (migration 030 unlinks instead); clear personal data on account deletion;
  exclude banned users from owner lookup; drop the silent 1000-member cap;
  advance the author's own read state on send.

Client (TypeScript / Rust)
- Voice: make joinGeneration monotonic so a superseded attempt can no longer
  pass supersession checks; scope aborted-path cleanup to the attempt's own
  room; send voice_leave on connect failure; stop push-to-talk from writing the
  user's explicit mute flag; gate join-time PTT muting on a new backend
  capability probe so platforms that cannot report key state are unaffected.
- E2EE: act on the tri-state pin-write result instead of reporting an
  unverified peer as verified; use keypair ownership rather than null checks.
- State: reset the message cache on logout; clear NSFW acknowledgements on
  logout; scope channel mutes, NSFW acks and DM notes per server host.
- UI: make the attachment remove button and the failed-send Retry/Discard
  buttons work; fix drag-reorder's phantom-drag latch and its permission gate.

Docs: protocol.md now documents can_send, active_channel_id, the archive
read-only contract, and the sequenced/unsequenced presence split.

Verified: all four Go build tag variants, go vet, go test -race, the ws
deadlock detector, sqlc and protocol generation, tsc, eslint, prettier, and
the full client suite (169 files, 4664 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWUzUmsjCKNAzAfQzT4fz7

* fix(ci): satisfy golangci-lint, knip, and the host-scoped mute key in e2e

Three CI failures on the previous commit, all genuine fallout from it.

golangci-lint (v2.11.3) — 6 issues in tests added by that commit:
- contextcheck: the temp-ban subtest captured an outer ctx while calling
  seedTokenUser, which builds its own; declare ctx inside the subtest instead.
- modernize: use WaitGroup.Go and range-over-int in three tests.

knip — SessionResponse in lib/types.ts became unused. The getSessions fix
replaced it with SessionInfo in lib/api.ts, which documents why the old
declaration was wrong (it named ip_address/expires_at, which the server never
sends, and omitted ip/is_current, which it always does). Delete the dead type
rather than re-export it, and fold that reasoning into the surviving comment.

Client E2E — the per-channel-mute parity test asserted the pre-scoping
localStorage key. Channel mutes are now keyed mutedChannels:<host>, because
channel ids are per-server autoincrement integers sharing one webview origin;
verified in a browser that the app writes
owncord:settings:mutedChannels:localhost:8443. The test now resolves whichever
scoped key exists instead of pinning the test server's host, so it still
asserts the same thing: the id persists on mute and is gone on unmute.

Verified with the CI linter version built against Go 1.26 (0 issues), all four
build tag variants, go vet, go test -race, the ws deadlock detector, knip,
tsc for both tsconfigs, prettier, the full client unit suite, and the
previously-failing parity specs run in a real browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWUzUmsjCKNAzAfQzT4fz7

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:20:48 +02:00

1997 lines
141 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &rarr; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
/* 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 &mdash; recommended</option>'
+'<option value="acme"'+(d.tls_mode==='acme'?' selected':'')+'>Let&#39;s Encrypt certificate &mdash; needs a public domain</option>'
+'<option value="manual"'+(d.tls_mode==='manual'?' selected':'')+'>Manual certificates &mdash; advanced</option>'
+'<option value="off"'+(d.tls_mode==='off'?' selected':'')+'>No encryption &mdash; 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&#39;t set up yet, pick self-signed for now — you can switch later.</div></div>'
+err+nav('Next');
break;
case 3:
h+='<h2>Uploads &amp; 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 &mdash; least bandwidth, phone-call quality</option>'
+'<option value="medium"'+(d.voice_quality==='medium'?' selected':'')+'>Medium &mdash; recommended balance</option>'
+'<option value="high"'+(d.voice_quality==='high'?' selected':'')+'>High &mdash; best quality, most bandwidth</option>'
+'</select>')
+err+nav('Next');
break;
case 4:
h+='<h2>Who can join?</h2>'
+'<p class="wiz-sub">You&#39;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&#39;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)||'&mdash;']];
h+='<h2>Review &amp; 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 Lets 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 Lets 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)+' &mdash; <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()">&lt;</button>';
html+='<button class="page-btn active">'+state.usersPage+'</button>';
html+='<button class="page-btn" '+(users.length<PAGE_SIZE?'disabled':'')+' onclick="state.usersPage++;renderContent()">&gt;</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()">&times;</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()">&times;</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()">&times;</button></div><div class="modal-body"><p style="color:var(--text-muted)">Terminate all sessions for this user? They can sign back in immediately &mdash; 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()">&times;</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()">&times;</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()">&times;</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()">&times;</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()">&times;</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()">&times;</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()">&lt;</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()">&gt;</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><div class="setting-ctrl"><input class="form-input" id="s-server_icon" value="'+esc(v('server_icon'))+'" style="width:240px" oninput="markSettingsChanged()"></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><div class="setting-ctrl"><input class="form-input" id="s-max_upload_bytes" value="'+esc(v('max_upload_bytes'))+'" style="width:160px" type="number" oninput="markSettingsChanged()"></div></div>';
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Voice Quality</div></div><div class="setting-ctrl"><select class="filter-select" id="s-voice_quality" onchange="markSettingsChanged()"><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()">&times;</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()">&times;</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()">&times;</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()">&times;</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()">&times;</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&times;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()">&times;</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()">&times;</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()">&times;</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>