Files

2010 lines
142 KiB
HTML
Raw Permalink Normal View History

<!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}
2026-08-01 22:06:14 +02:00
.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>',
2026-08-01 22:06:14 +02:00
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')||'',
2026-08-01 22:06:14 +02:00
me:null,
usersPage:1,auditPage:1,auditSearch:'',auditActionFilter:'all',auditCache:[],settingsChanged:false,backupRunning:false,updateApplying:false,
2026-08-01 22:06:14 +02:00
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}
2026-08-01 22:06:14 +02:00
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;
}
2026-08-01 22:06:14 +02:00
/* ═══ 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)'}
2026-08-01 22:06:14 +02:00
/* 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}
2026-08-01 22:06:14 +02:00
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);
}
2026-08-01 22:06:14 +02:00
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');
2026-08-01 22:06:14 +02:00
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 ═══ */
2026-08-01 22:06:14 +02:00
/* `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},
2026-08-01 22:06:14 +02:00
{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'},
2026-08-01 22:06:14 +02:00
{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},
];
2026-08-01 22:06:14 +02:00
/* 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(){
2026-08-01 22:06:14 +02:00
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){
2026-08-01 22:06:14 +02:00
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>';
}
}
2026-08-01 22:06:14 +02:00
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;
2026-08-01 22:06:14 +02:00
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>'}
2026-08-01 22:06:14 +02:00
/* 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>';
2026-08-01 22:06:14 +02:00
// 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>';
2026-08-01 22:06:14 +02:00
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">';
2026-08-01 22:06:14 +02:00
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;
}
2026-08-01 22:06:14 +02:00
/* 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){
2026-08-01 22:06:14 +02:00
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){
2026-08-01 22:06:14 +02:00
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;
2026-08-01 22:06:14 +02:00
/* 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;
2026-08-01 22:06:14 +02:00
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>';
2026-08-01 22:06:14 +02:00
state.channelCategories=Object.keys(catSet).sort();
return html;
}
2026-08-01 22:06:14 +02:00
/* <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(){
2026-08-01 22:06:14 +02:00
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')}
}
2026-08-01 22:06:14 +02:00
/* 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||'';
2026-08-01 22:06:14 +02:00
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;
2026-08-01 22:06:14 +02:00
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>'
2026-08-01 22:06:14 +02:00
+'<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>'
2026-08-01 22:06:14 +02:00
+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>'
2026-08-01 22:06:14 +02:00
+'<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(),
2026-08-01 22:06:14 +02:00
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'),
2026-08-01 22:06:14 +02:00
nsfw:document.getElementById('chEditNsfw').classList.contains('on'),
};
2026-08-01 22:06:14 +02:00
/* 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')}
}
2026-08-01 22:06:14 +02:00
/* ═══ 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;
2026-08-01 22:06:14 +02:00
/* 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){
2026-08-01 22:06:14 +02:00
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);
2026-08-01 22:06:14 +02:00
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>';
});
2026-08-01 22:06:14 +02:00
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();
}
2026-08-01 22:06:14 +02:00
/* 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{
2026-08-01 22:06:14 +02:00
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. Track
which roles this loop actually wrote — the override matrix below reads
its radios from the pre-save snapshot, so if its target is one of
these roles that snapshot is already stale and must not be trusted. */
const touchedRoles=new Set();
2026-08-01 22:06:14 +02:00
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});touchedRoles.add(role.role_id)}
else if(wasHidden){await api('DELETE','/channels/'+pc.id+'/permissions/'+role.role_id);touchedRoles.add(role.role_id)}
}
/* Then the matrix, if a target is selected — unless the quick-toggle loop
above just wrote that exact role's override row. Its radios reflect
state from before that write, so collecting them now would silently
undo the toggle (e.g. write back an all-inherit row that DELETEs what
was just PUT). An all-inherit row is itself a delete: storing (0,0)
would leave a row that resolves to nothing. */
const sel=document.getElementById('permTarget');
const targetVal=sel?sel.value:'';
const targetIsTouchedRole=targetVal.charAt(0)==='r'&&touchedRoles.has(parseInt(targetVal.slice(2),10));
if(!targetIsTouchedRole){
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);
}
2026-08-01 22:06:14 +02:00
}
closeModal();showToast('Channel permissions updated');renderContent();
}catch(e){showToast(e.message,'error')}
}
2026-08-01 22:06:14 +02:00
/* ═══ 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 class="setting-desc">Not used by the server or client yet &mdash; stored for a future release</div></div><div class="setting-ctrl"><input class="form-input" id="s-server_icon" value="'+esc(v('server_icon'))+'" style="width:240px" disabled title="Not implemented yet"></div></div>';
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Message of the Day</div><div class="setting-desc">Shown to users when they connect</div></div><div class="setting-ctrl"><input class="form-input" id="s-motd" value="'+esc(v('motd'))+'" style="width:300px" oninput="markSettingsChanged()"></div></div>';
html+='</div></div>';
html+='<div class="section-card"><div class="section-card-header"><h3>Limits</h3></div><div class="section-card-body">';
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Max Upload Size (bytes)</div><div class="setting-desc">Controlled by upload.max_size_mb in config.yaml (requires restart) &mdash; this display value has no effect</div></div><div class="setting-ctrl"><input class="form-input" id="s-max_upload_bytes" value="'+esc(v('max_upload_bytes'))+'" style="width:160px" type="number" disabled title="Set upload.max_size_mb in config.yaml and restart"></div></div>';
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Voice Quality</div><div class="setting-desc">Controlled by voice.quality in config.yaml (requires restart) &mdash; this display value has no effect</div></div><div class="setting-ctrl"><select class="filter-select" id="s-voice_quality" disabled title="Set voice.quality in config.yaml and restart"><option value="low" '+(v('voice_quality')==='low'?'selected':'')+'>Low</option><option value="medium" '+(v('voice_quality')==='medium'?'selected':'')+'>Medium</option><option value="high" '+(v('voice_quality')==='high'?'selected':'')+'>High</option></select></div></div>';
html+='</div></div>';
html+='<div class="section-card"><div class="section-card-header"><h3>Security</h3></div><div class="section-card-body">';
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Require 2FA</div><div class="setting-desc">Require all users to enable two-factor authentication</div></div><div class="setting-ctrl"><button class="toggle '+(isOn('require_2fa')?'on':'')+'" id="s-require_2fa" onclick="this.classList.toggle(\'on\');markSettingsChanged()"></button></div></div>';
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Registration Open</div><div class="setting-desc">Allow new users to register with invite codes</div></div><div class="setting-ctrl"><button class="toggle '+(isOn('registration_open')?'on':'')+'" id="s-registration_open" onclick="this.classList.toggle(\'on\');markSettingsChanged()"></button></div></div>';
html+='</div></div>';
html+='<div class="section-card"><div class="section-card-header"><h3>Backup</h3></div><div class="section-card-body">';
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Schedule</div></div><div class="setting-ctrl"><select class="filter-select" id="s-backup_schedule" onchange="markSettingsChanged()"><option value="off" '+(v('backup_schedule')==='off'?'selected':'')+'>Off</option><option value="daily" '+(v('backup_schedule')==='daily'?'selected':'')+'>Daily</option><option value="weekly" '+(v('backup_schedule')==='weekly'?'selected':'')+'>Weekly</option></select></div></div>';
html+='<div class="setting-row"><div class="setting-info"><div class="setting-name">Retention (days)</div></div><div class="setting-ctrl"><input class="form-input" id="s-backup_retention" value="'+esc(v('backup_retention'))+'" style="width:100px" type="number" oninput="markSettingsChanged()"></div></div>';
html+='</div></div>';
html+='<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px"><button class="btn btn-accent" id="saveSettingsBtn" '+(state.settingsChanged?'':'disabled')+' onclick="saveSettings()">Save Changes</button></div>';
return html;
}
function markSettingsChanged(){state.settingsChanged=true;renderNav();const btn=document.getElementById('saveSettingsBtn');if(btn)btn.disabled=false}
async function saveSettings(){
const body={};
['server_name','server_icon','motd','max_upload_bytes','voice_quality','backup_schedule','backup_retention'].forEach(k=>{const el=document.getElementById('s-'+k);if(el)body[k]=el.value});
['require_2fa','registration_open'].forEach(k=>{const el=document.getElementById('s-'+k);if(el)body[k]=el.classList.contains('on')?'true':'false'});
const btn=document.getElementById('saveSettingsBtn');
if(btn){if(btn.disabled)return;btn.disabled=true}
try{
await api('PATCH','/settings',body);
state.settingsChanged=false;renderNav();showToast('Settings saved');
// Leave the button disabled: there are no unsaved changes any more.
}catch(e){
showToast(e.message,'error');
if(btn)btn.disabled=false;
}
}
/* ═══ Backups ═══ */
async function renderBackups(){
let backups;
try{backups=await api('GET','/backups')}catch(e){return'<div class="page-title">Backups</div><p style="color:var(--red)">'+esc(e.message)+'</p>'}
let html='<div class="page-title">Backups</div><div class="page-desc">Database backup and restore</div>';
html+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px"><div class="section-card"><div class="section-card-header"><h3>Manual Backup</h3></div><div class="section-card-body" style="text-align:center;padding:32px"><button class="btn btn-accent" style="font-size:15px;padding:12px 32px" onclick="createBackup()" '+(state.backupRunning?'disabled':'')+'>'+(state.backupRunning?'<div class="spinner"></div> Running...':I.download+' Create Backup Now')+'</button></div></div>';
html+='<div class="section-card"><div class="section-card-header"><h3>Schedule</h3></div><div class="section-card-body"><p style="color:var(--text-faint);font-size:13px">Configure backup schedule in Settings.</p><button class="btn btn-ghost" style="margin-top:8px" onclick="navigateTo(\'settings\')">Go to Settings</button></div></div></div>';
html+='<div class="section-card"><div class="section-card-header"><h3>Backup History</h3></div><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>Filename</th><th>Size</th><th>Date</th><th style="text-align:right">Actions</th></tr></thead><tbody>';
if(!backups||!backups.length)html+='<tr><td colspan="4" style="text-align:center;color:var(--text-faint);padding:24px">No backups found</td></tr>';
else backups.forEach(b=>{
html+='<tr><td><code style="font-family:var(--font-mono);font-size:12px">'+esc(b.name)+'</code></td>';
html+='<td>'+fmtBytes(b.size)+'</td><td>'+(b.date?new Date(b.date).toLocaleString():'')+'</td>';
html+='<td><div class="act-group" style="justify-content:flex-end"><button class="btn btn-ghost" onclick="openRestoreModal(\''+jsq(b.name)+'\')">Restore</button><button class="act-btn danger" title="Delete" onclick="openDeleteBackupModal(\''+jsq(b.name)+'\')">'+I.trash+'</button></div></td></tr>';
});
html+='</tbody></table></div></div>';
return html;
}
async function createBackup(){
state.backupRunning=true;renderContent();
try{await api('POST','/backup');state.backupRunning=false;showToast('Backup created');renderContent()}catch(e){state.backupRunning=false;showToast(e.message,'error');renderContent()}
}
function openRestoreModal(name){
openModal('<div class="modal-header"><h3>Restore Backup</h3><button class="modal-close" onclick="closeModal()">&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')}
}
2026-08-01 22:06:14 +02:00
/* ═══ 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>