openvibers/openvibe.network Commits
Admin: only the owner lifts a site-wide ban; bans as cards; every on/off a switch (#902)
Changes
@@@ -277,7 +277,7 @@ a.btn{text-decoration:none}.pai-table th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);padding:6px 8px;border-bottom:1px solid var(--border);white-space:nowrap}.pai-table td{padding:7px 8px;border-bottom:1px solid var(--border);vertical-align:top;overflow-wrap:anywhere}@media(max-width:800px){.pai-provider{grid-template-columns:minmax(0,1fr)}.pai-provider-actions{justify-content:flex-start;max-width:none}.pai-fields{grid-template-columns:minmax(0,1fr)}}--@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input,#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}++@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input:not([type=checkbox]):not([type=radio]),#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}/* Owner clarity: scoped to the legacy admin sections; AI and Setup own their forms. */.admin-clarity { min-width:0; overflow-wrap:anywhere; }@@@ -286,7 +286,7 @@ a.btn{text-decoration:none}.admin-clarity .admin-help { display:block; color:var(--text-secondary); font-size:13px; font-weight:400; text-transform:none; letter-spacing:normal; line-height:1.55; margin:6px 0 10px; }.admin-clarity .admin-machine-key { display:inline-block; color:var(--text-muted); font-size:12px; font-weight:400; overflow-wrap:anywhere; }.admin-clarity .admin-field { display:flex; flex:1 1 220px; flex-direction:column; min-width:0; max-width:100%; }--.admin-clarity :is(input,select,textarea) { max-width:100%; min-height:44px; font-size:14px; }++.admin-clarity :is(input:not([type=checkbox]):not([type=radio]),select,textarea) { max-width:100%; min-height:44px; font-size:14px; }.admin-clarity :is(button,.btn,summary) { min-height:44px; min-width:44px; white-space:normal; justify-content:center; }.admin-clarity input[type=checkbox] { width:44px!important; height:44px!important; flex:0 0 44px; accent-color:var(--accent); }.admin-clarity .setting-row { gap:12px; align-items:flex-start; }@@@ -549,3 +549,23 @@ body:has(.admin-clarity.active) .ov-sel-opt { min-height:44px; }.oin-kind-body{padding:0 12px 12px}#platform-ai .oin-way{min-height:44px}}++++/* Bans as cards (2026-11-13): who, why, by whom and when, how long; lifting a ban is the owner's. */++.ban-count{margin:0 0 10px}++.ban-list{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(min(100%,320px),1fr))}++.ban-card{display:grid;gap:8px;padding:14px;border:1px solid var(--border);border-radius:14px;background:var(--bg-card,var(--bg-secondary))}++.ban-top{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0}++.ban-who{font-weight:800;font-size:15px;color:var(--text-primary);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}++.ban-who:hover{text-decoration:underline}++.ban-len{flex:none;font-size:12px;font-weight:700;padding:3px 9px;border-radius:999px;background:color-mix(in srgb,var(--text-primary) 8%,transparent);color:var(--text-secondary)}++.ban-len.is-perm{background:color-mix(in srgb,var(--danger,#ef4444) 16%,transparent);color:var(--danger,#f87171)}++.ban-reason{margin:0;font-size:14px;line-height:1.45;overflow-wrap:anywhere}++.ban-meta{font-size:12.5px}++.ban-meta .ban-who{font-size:12.5px;font-weight:700}++.ban-act{display:flex;justify-content:flex-end}++.ban-owner{font-size:12.5px}++++/* Every on/off in the panel is the network's switch (2026-11-13, the owner: "the whole entire admin panel needs to use++ openvibe.design, especially for like these checkboxes"): core.js marks a lone checkbox role="switch" and ov-forms draws++ it; nothing here may stretch it to a field's height. */++.admin-clarity input[type=checkbox], .admin-clarity input[type=radio], #platform-ai input[type=checkbox] { min-height:0; }@@@ -331,7 +331,7 @@ async function loadEmail() {</div></div>-- <form id="email-form" style="max-width:600px;margin-bottom:16px">++ ${data.can_edit === false ? '<p class="muted email-view-only"><i class="fa-solid fa-lock"></i> Only the owner changes how email is sent. You see the delivery numbers.</p>' : `<form id="email-form" style="max-width:600px;margin-bottom:16px"><div class="setting-row"><label for="email-enabled"><strong>Send email</strong></label><input type="checkbox" id="email-enabled" aria-label="Send outgoing email" aria-describedby="help-email-enabled" ${email.enabled ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer"><span class="admin-help" id="help-email-enabled">Off by default, so no email is sent. Turn it on after configuring Resend; turning it off stops delivery, and turning it on resumes it.</span>@@@ -355,9 +355,9 @@ async function loadEmail() {</div><div style="background:#2a2a3822;border:1px solid #333340;border-radius:8px;padding:12px 14px;margin-top:16px;font-size:12px;color:var(--text-muted)"><strong style="color:var(--text)"><i class="fa-solid fa-globe"></i> DNS Setup</strong><br>-- Add your domain at <a href="https://resend.com/domains" target="_blank" style="color:var(--accent)">resend.com/domains</a> and add the SPF (TXT + MX) and DKIM records to your DNS provider. Once verified, emails will be sent from your domain.++ Add your domain at <a href="https://resend.com/domains" target="_blank" rel="noopener" style="color:var(--accent)">resend.com/domains</a> and add the SPF (TXT + MX) and DKIM records to your DNS provider. Once verified, emails will be sent from your domain.</div>-- </form>++ </form>`}<div class="card" style="padding:16px;margin-bottom:16px"><h3 style="margin-bottom:10px"><i class="fa-solid fa-layer-group"></i> Delivery breakdown · last 30 days</h3><p class="admin-intro">This counts sent and failed messages by type over the last 30 days. Review it when one kind of email is failing more often than others.</p>@@@ -377,7 +377,7 @@ async function loadEmail() {: '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}</div>-- <div class="card" style="padding:16px">++ ${data.can_edit === false ? '' : `<div class="card" style="padding:16px"><h3 style="margin-bottom:10px"><i class="fa-solid fa-clock-rotate-left"></i> Recent Email Activity</h3><p class="admin-intro">These are the most recent email delivery attempts and provider errors. Check a failed row after a delivery report, then correct the configuration and send a new test.</p>${recent.length ? `<table class="admin-table">@@@ -396,7 +396,7 @@ async function loadEmail() {</tbody></table>`: '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}-- </div>`;++ </div>`}`;} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }}@@@ -17,6 +17,22 @@ let currentUser = null;// short inside value="..." — which is how JSON settings got rendered as '{' and saved back as '{'.function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }++/**++ * Every on/off in the panel is the network's switch (openvibe-shared ov-forms: a checkbox with role="switch"; 2026-11-13,++ * the owner: "these checkboxes … use openvibe.design"). A checkbox in a table picks rows, so it stays a checkbox;++ * data-ov-native keeps the browser's own.++ */++(function adminSwitches() {++ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;++ const one = (c) => { if (c.type === 'checkbox' && !c.hasAttribute('role') && !c.closest('table, [data-ov-native]')) c.setAttribute('role', 'switch'); };++ const mark = (root) => { if (root.matches && root.matches('input[type="checkbox"]')) one(root); if (root.querySelectorAll) root.querySelectorAll('input[type="checkbox"]').forEach(one); };++ const start = () => {++ mark(document);++ new MutationObserver((list) => { for (const m of list) for (const n of m.addedNodes) if (n.nodeType === 1) mark(n); }).observe(document.body, { childList: true, subtree: true });++ };++ if (document.body) start(); else document.addEventListener('DOMContentLoaded', start, { once: true });++})();++function timeAgo(dateStr) {const d = new Date(dateStr);const s = Math.floor((Date.now() - d.getTime()) / 1000);@@@ -81,7 +97,9 @@ let currentTab = 'dashboard';// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the// AI configuration form inside it is owner-gated (see loadAi).// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.--const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);++// The owner's alone (2026-11-13: Notifications too, "we don't want admins to be able to do this"). Email is open to++// admins as a read-only view of its delivery numbers; its settings and changes are the owner's (server/admin/routes.js).++const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'notifications', 'settings', 'grants', 'eventsops']);function showTab(id, btn) {if (!tabLoaders[id]) id = 'dashboard';@@@ -14,24 +14,23 @@ async function loadStreams() {const data = await api('/api/admin/streamer/streams');const streams = data.streams || [];if (!streams.length) { c.innerHTML = '<p class="muted">No streams are live. Return when someone starts broadcasting.</p>'; return; }-- c.innerHTML = `-- <table class="admin-table">-- <thead><tr>-- <th>Title</th><th>Streamer</th><th>Protocol</th><th>Viewers</th><th>Started</th><th>Actions</th>-- </tr></thead>-- <tbody>${streams.map(s => `-- <tr>-- <td>${esc(s.title || 'Untitled')}</td>-- <td><strong>${esc(s.username || '-')}</strong></td>-- <td><span class="badge badge-info">${esc(s.protocol)}</span></td>-- <td>${s.viewer_count || 0}</td>-- <td>${s.started_at ? timeAgo(s.started_at) : '-'}</td>-- <td>-- <button class="btn btn-sm btn-danger" onclick="endStream('${s.id}')"><i class="fa-solid fa-stop"></i> End broadcast</button><span class="admin-help">Disconnects viewers; the streamer must restart to resume.</span>-- </td>-- </tr>-- `).join('')}</tbody>-- </table>`;++ // Each ban is a card (the owner, 11-13: on a phone the table scrolled who and why out of sight): who, as a name++ // that opens their card; the reason; who banned them and when; how long; and, for the owner, lifting it.++ const when = (t) => (t ? `${timeAgo(t)}` : '');++ const nameTag = (u, id) => u++ ? `<a class="ban-who" href="https://openvibe.network/@${encodeURIComponent(u)}" data-ov-user="${esc(u)}">@${esc(u)}</a>`++ : `<span class="ban-who muted">user ${esc(String(id))}</span>`;++ c.innerHTML = `<p class="muted ban-count">${bans.length === 1 ? '1 person is' : `${bans.length} people are`} banned across the site.</p>++ <ul class="ban-list">${bans.map((b) => `++ <li class="ban-card">++ <div class="ban-top">${nameTag(b.username, b.user_id)}<span class="ban-len ${b.expires_at ? '' : 'is-perm'}">${b.expires_at ? `until ${esc(new Date(b.expires_at).toLocaleDateString())}` : 'Permanent'}</span></div>++ <p class="ban-reason">${b.reason ? esc(b.reason) : '<span class="muted">No reason was written.</span>'}</p>++ <div class="ban-meta muted">Banned ${b.banned_by_username ? `by ${nameTag(b.banned_by_username)}` : ''} ${esc(when(b.created_at))}</div>++ <div class="ban-act">${owner++ ? `<button class="btn btn-sm btn-outline" onclick="unbanStreamer('${esc(String(b.user_id))}')"><i class="fa-solid fa-user-check"></i> Allow access again</button>`++ : '<span class="muted ban-owner" title="Only the owner can lift a site-wide ban">Only the owner can lift it</span>'}</div>++ </li>`).join('')}</ul>`;++ if (window.OVUser && typeof window.OVUser.bind === 'function') { try { window.OVUser.bind(c); } catch (e) { /* the card is optional */ } }} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }}@@@ -82,6 +81,8 @@ async function loadBans() {try {const data = await api('/api/admin/streamer/bans');const bans = data.bans || [];++ // Only the owner lifts a site-wide ban (2026-11-13); admins see the list, who and why.++ const owner = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.is_owner);if (!bans.length) { c.innerHTML = '<p class="muted">No active bans. Review Users if an account needs a restriction.</p>'; return; }c.innerHTML = `<table class="admin-table">@@@ -95,9 +96,9 @@ async function loadBans() {<td>${esc(b.banned_by_username || '-')}</td><td>${b.created_at ? timeAgo(b.created_at) : '-'}</td><td>${b.expires_at ? new Date(b.expires_at).toLocaleString() : 'Permanent'}</td>-- <td>-- <button class="btn btn-sm btn-outline" onclick="unbanStreamer('${b.user_id}')"><i class="fa-solid fa-user-check"></i> Allow access again</button>-- </td>++ <td>${owner++ ? `<button class="btn btn-sm btn-outline" onclick="unbanStreamer('${esc(String(b.user_id))}')"><i class="fa-solid fa-user-check"></i> Allow access again</button>`++ : '<span class="muted" title="Only the owner can lift a site-wide ban">Owner only</span>'}</td></tr>`).join('')}</tbody></table>`;@@@ -228,11 +228,18 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// Email Configuration (Resend)// ═══════════════════════════════════════════════════════-- // GET /api/admin/email — get current email config (owner-only: exposes key metadata)-- router.get('/email', requireOwner, async (req, res) => {++ // GET /api/admin/email — the delivery numbers for every admin; the configuration (key metadata), the recent messages++ // (they carry addresses) and the right to change anything are the owner's (2026-11-13, the owner: "admin should only++ // be able to view the basic stats on this page, no editing, while owner can edit").++ router.get('/email', async (req, res) => {try {++ const metrics = await getEmailMetrics();++ if (!isOwner(req.user)) {++ const status = await emailService.getStatus();++ return res.json({ ok: true, can_edit: false, email: { enabled: !!(status && status.enabled) }, metrics: { summary: metrics.summary || {}, by_type: metrics.by_type || [] } });++ }const status = await emailService.getStatus();-- res.json({ ok: true, email: status, metrics: await getEmailMetrics() });++ res.json({ ok: true, can_edit: true, email: status, metrics });} catch (err) {res.status(500).json({ ok: false, error: err.message });}@@@ -805,7 +812,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// Broadcast Notifications// ═══════════════════════════════════════════════════════-- router.post('/broadcast', async (req, res) => {++ // A message to every person on the network is the owner's to send (2026-11-13).++ router.post('/broadcast', requireOwner, async (req, res) => {try {const { title, message, icon, url, priority, category } = req.body;if (!title) return res.status(400).json({ ok: false, error: 'Title required' });@@@ -67,7 +67,12 @@ function createServiceProxies({ requireAuth, signToken, services }) {const chat = () => services.chat.internalUrl;r.use('/tools-workers', require('./tools-workers').createToolsWorkers({ requireAuth, signToken, services }));-- r.use('/streamer', requireAuth, admin, to(live, '/api/admin', 'OpenVibe.Live', 'Streamer proxy error'));++ // Lifting a site-wide ban is the owner's alone (the owner, 2026-11-13: "admins can't unban … only owner rank, but they++ // can view"); Live checks it too, so this answers first and plainly.++ const { isOwner } = require('../auth/owner-guard');++ const ownerUnban = (req, res, next) => (req.method === 'DELETE' && /^\/(?:users\/[^/]+\/ban|ban\/[^/]+)\/?$/.test(req.path) && !isOwner(req.user)++ ? res.status(403).json({ error: 'Only the owner can lift a site-wide ban', code: 'owner_only' }) : next());++ r.use('/streamer', requireAuth, admin, ownerUnban, to(live, '/api/admin', 'OpenVibe.Live', 'Streamer proxy error'));r.use('/streamer-mod', requireAuth, staff, to(live, '/api/mod', 'OpenVibe.Live', 'Mod proxy error'));r.use('/streamer-tts', requireAuth, admin, to(chat, '/api/tts', 'OpenVibe.Chat', 'TTS proxy error'));r.use('/streamer-pastes', requireAuth, admin, to(live, '/api/pastes', 'OpenVibe.Live', 'Pastes proxy error'));nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++// The admin panel, 2026-11-13 (the owner's phone): bans are cards that show who (a name that opens their card), the++// reason and who banned them, and only the owner sees "Allow access again"; every on/off is the network's switch, never++// a checkbox stretched to a text field's height.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');++++const mod = read('public/js/admin/moderation.js');++assert.ok(/const owner = !!\(typeof currentUser !== 'undefined' && currentUser && currentUser\.is_owner\);/.test(mod), 'the owner is known on the page');++assert.ok(/<li class="ban-card">/.test(mod) && /data-ov-user="\$\{esc\(u\)\}"/.test(mod), 'each ban is a card with a name that opens the person\'s card');++assert.ok(/No reason was written\./.test(mod), 'a ban without a reason says so');++assert.ok(/\$\{owner\s*\?\s*`<button[^`]*unbanStreamer/.test(mod) && /Only the owner can lift it/.test(mod), 'only the owner gets the button');++++const core = read('public/js/admin/core.js');++assert.ok(/function adminSwitches\(\)/.test(core) && /c\.setAttribute\('role', 'switch'\)/.test(core) && /c\.closest\('table, \[data-ov-native\]'\)/.test(core), 'a lone checkbox becomes the switch; a table\'s stays a checkbox');++const css = read('public/css/admin.css');++assert.ok(css.includes('.admin-clarity :is(input:not([type=checkbox]):not([type=radio]),select,textarea) { max-width:100%; min-height:44px;'), 'the field height is for fields only');++assert.ok(css.includes('.admin-clarity input[type=checkbox], .admin-clarity input[type=radio], #platform-ai input[type=checkbox] { min-height:0; }'), 'and never for a switch');++// Email: every admin sees the delivery numbers; the settings, the recent messages (addresses) and changes are the owner's.++const routes = read('server/admin/routes.js');++assert.ok(/router\.get\('\/email', async \(req, res\) => \{[\s\S]{0,300}if \(!isOwner\(req\.user\)\) \{[\s\S]{0,300}can_edit: false/.test(routes), 'an admin reads the numbers only');++assert.ok(/router\.put\('\/email', requireOwner/.test(routes) && /router\.post\('\/broadcast', requireOwner/.test(routes), 'changing email and broadcasting are the owner\'s');++const content = read('public/js/admin/content.js');++assert.ok(/data\.can_edit === false \? '<p class="muted email-view-only">/.test(content), 'the page says it is read-only');++assert.ok(/OWNER_ONLY_TABS = new Set\(\[[^\]]*'notifications'/.test(core) && !/OWNER_ONLY_TABS = new Set\(\[[^\]]*'email'/.test(core), 'Notifications is the owner\'s tab; Email is open to read');++console.log('admin-bans-switches: bans as cards with who and why, unban for the owner only, every on/off a switch');@@@ -63,6 +63,23 @@ function upstream(name) {const reset = () => { live.seen.length = 0; chat.seen.length = 0; };try {++ // ── Lifting a site-wide ban is the owner's (2026-11-13) ──────────++ {++ const ownerUser = { id: 9, role: 'admin', username: process.env.OWNER_USERNAME || 'goosely' };++ const adminNamed = { id: 1, role: 'admin', username: 'someadmin' };++ reset();++ let u = await call(adminNamed, 'DELETE', '/api/admin/streamer/users/5/ban');++ assert.deepStrictEqual([u.status, u.json && u.json.code], [403, 'owner_only'], 'an admin may not lift a site-wide ban');++ u = await call(adminNamed, 'DELETE', '/api/admin/streamer/ban/77');++ assert.strictEqual(u.status, 403, 'nor by its ban id');++ assert.strictEqual(live.seen.length, 0, 'Live never hears it');++ u = await call(adminNamed, 'GET', '/api/admin/streamer/bans');++ assert.strictEqual(u.status, 200, 'an admin still sees the list');++ u = await call(ownerUser, 'DELETE', '/api/admin/streamer/users/5/ban');++ assert.strictEqual(u.status, 200, 'the owner may');++ assert.strictEqual(live.seen.at(-1).url, '/api/admin/users/5/ban');++ reset();++ }// ── TTS → Chat ───────────────────────────────────────────────let r = await call(admin, 'GET', '/api/admin/streamer-tts/admin/settings');assert.strictEqual(r.status, 200);
@@ -277,7 +277,7 @@ a.btn{text-decoration:none}
@@ -277,7 +277,7 @@ a.btn{text-decoration:none}
.pai-table th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);padding:6px 8px;border-bottom:1px solid var(--border);white-space:nowrap}
.pai-table th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);padding:6px 8px;border-bottom:1px solid var(--border);white-space:nowrap}
.pai-table td{padding:7px 8px;border-bottom:1px solid var(--border);vertical-align:top;overflow-wrap:anywhere}
.pai-table td{padding:7px 8px;border-bottom:1px solid var(--border);vertical-align:top;overflow-wrap:anywhere}
@media(max-width:800px){.pai-provider{grid-template-columns:minmax(0,1fr)}.pai-provider-actions{justify-content:flex-start;max-width:none}.pai-fields{grid-template-columns:minmax(0,1fr)}}
@media(max-width:800px){.pai-provider{grid-template-columns:minmax(0,1fr)}.pai-provider-actions{justify-content:flex-start;max-width:none}.pai-fields{grid-template-columns:minmax(0,1fr)}}
-@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input,#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}
+@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input:not([type=checkbox]):not([type=radio]),#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}
/* Owner clarity: scoped to the legacy admin sections; AI and Setup own their forms. */
/* Owner clarity: scoped to the legacy admin sections; AI and Setup own their forms. */
.admin-clarity { min-width:0; overflow-wrap:anywhere; }
.admin-clarity { min-width:0; overflow-wrap:anywhere; }
@@ -286,7 +286,7 @@ a.btn{text-decoration:none}
@@ -286,7 +286,7 @@ a.btn{text-decoration:none}
.admin-clarity .admin-help { display:block; color:var(--text-secondary); font-size:13px; font-weight:400; text-transform:none; letter-spacing:normal; line-height:1.55; margin:6px 0 10px; }
.admin-clarity .admin-help { display:block; color:var(--text-secondary); font-size:13px; font-weight:400; text-transform:none; letter-spacing:normal; line-height:1.55; margin:6px 0 10px; }
.admin-clarity .admin-machine-key { display:inline-block; color:var(--text-muted); font-size:12px; font-weight:400; overflow-wrap:anywhere; }
.admin-clarity .admin-machine-key { display:inline-block; color:var(--text-muted); font-size:12px; font-weight:400; overflow-wrap:anywhere; }
.admin-clarity .admin-field { display:flex; flex:1 1 220px; flex-direction:column; min-width:0; max-width:100%; }
.admin-clarity .admin-field { display:flex; flex:1 1 220px; flex-direction:column; min-width:0; max-width:100%; }
-.admin-clarity :is(input,select,textarea) { max-width:100%; min-height:44px; font-size:14px; }
+.admin-clarity :is(input:not([type=checkbox]):not([type=radio]),select,textarea) { max-width:100%; min-height:44px; font-size:14px; }
.admin-clarity :is(button,.btn,summary) { min-height:44px; min-width:44px; white-space:normal; justify-content:center; }
.admin-clarity :is(button,.btn,summary) { min-height:44px; min-width:44px; white-space:normal; justify-content:center; }
.admin-clarity input[type=checkbox] { width:44px!important; height:44px!important; flex:0 0 44px; accent-color:var(--accent); }
.admin-clarity input[type=checkbox] { width:44px!important; height:44px!important; flex:0 0 44px; accent-color:var(--accent); }
.admin-clarity .setting-row { gap:12px; align-items:flex-start; }
.admin-clarity .setting-row { gap:12px; align-items:flex-start; }
@@ -549,3 +549,23 @@ body:has(.admin-clarity.active) .ov-sel-opt { min-height:44px; }
@@ -549,3 +549,23 @@ body:has(.admin-clarity.active) .ov-sel-opt { min-height:44px; }
.oin-kind-body{padding:0 12px 12px}
.oin-kind-body{padding:0 12px 12px}
#platform-ai .oin-way{min-height:44px}
#platform-ai .oin-way{min-height:44px}
}
}
+
+/* Bans as cards (2026-11-13): who, why, by whom and when, how long; lifting a ban is the owner's. */
+.ban-count{margin:0 0 10px}
+.ban-list{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(min(100%,320px),1fr))}
+.ban-card{display:grid;gap:8px;padding:14px;border:1px solid var(--border);border-radius:14px;background:var(--bg-card,var(--bg-secondary))}
+.ban-top{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0}
+.ban-who{font-weight:800;font-size:15px;color:var(--text-primary);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.ban-who:hover{text-decoration:underline}
+.ban-len{flex:none;font-size:12px;font-weight:700;padding:3px 9px;border-radius:999px;background:color-mix(in srgb,var(--text-primary) 8%,transparent);color:var(--text-secondary)}
+.ban-len.is-perm{background:color-mix(in srgb,var(--danger,#ef4444) 16%,transparent);color:var(--danger,#f87171)}
+.ban-reason{margin:0;font-size:14px;line-height:1.45;overflow-wrap:anywhere}
+.ban-meta{font-size:12.5px}
+.ban-meta .ban-who{font-size:12.5px;font-weight:700}
+.ban-act{display:flex;justify-content:flex-end}
+.ban-owner{font-size:12.5px}
+
+/* Every on/off in the panel is the network's switch (2026-11-13, the owner: "the whole entire admin panel needs to use
+ openvibe.design, especially for like these checkboxes"): core.js marks a lone checkbox role="switch" and ov-forms draws
+ it; nothing here may stretch it to a field's height. */
+.admin-clarity input[type=checkbox], .admin-clarity input[type=radio], #platform-ai input[type=checkbox] { min-height:0; }
@@ -331,7 +331,7 @@ async function loadEmail() {
@@ -331,7 +331,7 @@ async function loadEmail() {
</div>
</div>
</div>
</div>
- <form id="email-form" style="max-width:600px;margin-bottom:16px">
+ ${data.can_edit === false ? '<p class="muted email-view-only"><i class="fa-solid fa-lock"></i> Only the owner changes how email is sent. You see the delivery numbers.</p>' : `<form id="email-form" style="max-width:600px;margin-bottom:16px">
<div class="setting-row">
<div class="setting-row">
<label for="email-enabled"><strong>Send email</strong></label>
<label for="email-enabled"><strong>Send email</strong></label>
<input type="checkbox" id="email-enabled" aria-label="Send outgoing email" aria-describedby="help-email-enabled" ${email.enabled ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer"><span class="admin-help" id="help-email-enabled">Off by default, so no email is sent. Turn it on after configuring Resend; turning it off stops delivery, and turning it on resumes it.</span>
<input type="checkbox" id="email-enabled" aria-label="Send outgoing email" aria-describedby="help-email-enabled" ${email.enabled ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer"><span class="admin-help" id="help-email-enabled">Off by default, so no email is sent. Turn it on after configuring Resend; turning it off stops delivery, and turning it on resumes it.</span>
@@ -355,9 +355,9 @@ async function loadEmail() {
@@ -355,9 +355,9 @@ async function loadEmail() {
</div>
</div>
<div style="background:#2a2a3822;border:1px solid #333340;border-radius:8px;padding:12px 14px;margin-top:16px;font-size:12px;color:var(--text-muted)">
<div style="background:#2a2a3822;border:1px solid #333340;border-radius:8px;padding:12px 14px;margin-top:16px;font-size:12px;color:var(--text-muted)">
<strong style="color:var(--text)"><i class="fa-solid fa-globe"></i> DNS Setup</strong><br>
<strong style="color:var(--text)"><i class="fa-solid fa-globe"></i> DNS Setup</strong><br>
- Add your domain at <a href="https://resend.com/domains" target="_blank" style="color:var(--accent)">resend.com/domains</a> and add the SPF (TXT + MX) and DKIM records to your DNS provider. Once verified, emails will be sent from your domain.
+ Add your domain at <a href="https://resend.com/domains" target="_blank" rel="noopener" style="color:var(--accent)">resend.com/domains</a> and add the SPF (TXT + MX) and DKIM records to your DNS provider. Once verified, emails will be sent from your domain.
</div>
</div>
- </form>
+ </form>`}
<div class="card" style="padding:16px;margin-bottom:16px">
<div class="card" style="padding:16px;margin-bottom:16px">
<h3 style="margin-bottom:10px"><i class="fa-solid fa-layer-group"></i> Delivery breakdown · last 30 days</h3><p class="admin-intro">This counts sent and failed messages by type over the last 30 days. Review it when one kind of email is failing more often than others.</p>
<h3 style="margin-bottom:10px"><i class="fa-solid fa-layer-group"></i> Delivery breakdown · last 30 days</h3><p class="admin-intro">This counts sent and failed messages by type over the last 30 days. Review it when one kind of email is failing more often than others.</p>
@@ -377,7 +377,7 @@ async function loadEmail() {
@@ -377,7 +377,7 @@ async function loadEmail() {
: '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}
: '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}
</div>
</div>
- <div class="card" style="padding:16px">
+ ${data.can_edit === false ? '' : `<div class="card" style="padding:16px">
<h3 style="margin-bottom:10px"><i class="fa-solid fa-clock-rotate-left"></i> Recent Email Activity</h3><p class="admin-intro">These are the most recent email delivery attempts and provider errors. Check a failed row after a delivery report, then correct the configuration and send a new test.</p>
<h3 style="margin-bottom:10px"><i class="fa-solid fa-clock-rotate-left"></i> Recent Email Activity</h3><p class="admin-intro">These are the most recent email delivery attempts and provider errors. Check a failed row after a delivery report, then correct the configuration and send a new test.</p>
${recent.length ? `
${recent.length ? `
<table class="admin-table">
<table class="admin-table">
@@ -396,7 +396,7 @@ async function loadEmail() {
@@ -396,7 +396,7 @@ async function loadEmail() {
</tbody>
</tbody>
</table>`
</table>`
: '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}
: '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}
- </div>`;
+ </div>`}`;
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
}
}
@@ -17,6 +17,22 @@ let currentUser = null;
@@ -17,6 +17,22 @@ let currentUser = null;
// short inside value="..." — which is how JSON settings got rendered as '{' and saved back as '{'.
// short inside value="..." — which is how JSON settings got rendered as '{' and saved back as '{'.
function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
+/**
+ * Every on/off in the panel is the network's switch (openvibe-shared ov-forms: a checkbox with role="switch"; 2026-11-13,
+ * the owner: "these checkboxes … use openvibe.design"). A checkbox in a table picks rows, so it stays a checkbox;
+ * data-ov-native keeps the browser's own.
+ */
+(function adminSwitches() {
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
+ const one = (c) => { if (c.type === 'checkbox' && !c.hasAttribute('role') && !c.closest('table, [data-ov-native]')) c.setAttribute('role', 'switch'); };
+ const mark = (root) => { if (root.matches && root.matches('input[type="checkbox"]')) one(root); if (root.querySelectorAll) root.querySelectorAll('input[type="checkbox"]').forEach(one); };
+ const start = () => {
+ mark(document);
+ new MutationObserver((list) => { for (const m of list) for (const n of m.addedNodes) if (n.nodeType === 1) mark(n); }).observe(document.body, { childList: true, subtree: true });
+ };
+ if (document.body) start(); else document.addEventListener('DOMContentLoaded', start, { once: true });
+})();
+
function timeAgo(dateStr) {
function timeAgo(dateStr) {
const d = new Date(dateStr);
const d = new Date(dateStr);
const s = Math.floor((Date.now() - d.getTime()) / 1000);
const s = Math.floor((Date.now() - d.getTime()) / 1000);
@@ -81,7 +97,9 @@ let currentTab = 'dashboard';
@@ -81,7 +97,9 @@ let currentTab = 'dashboard';
// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
// AI configuration form inside it is owner-gated (see loadAi).
// AI configuration form inside it is owner-gated (see loadAi).
// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.
// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.
-const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);
+// The owner's alone (2026-11-13: Notifications too, "we don't want admins to be able to do this"). Email is open to
+// admins as a read-only view of its delivery numbers; its settings and changes are the owner's (server/admin/routes.js).
+const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'notifications', 'settings', 'grants', 'eventsops']);
function showTab(id, btn) {
function showTab(id, btn) {
if (!tabLoaders[id]) id = 'dashboard';
if (!tabLoaders[id]) id = 'dashboard';
@@ -14,24 +14,23 @@ async function loadStreams() {
@@ -14,24 +14,23 @@ async function loadStreams() {
const data = await api('/api/admin/streamer/streams');
const data = await api('/api/admin/streamer/streams');
const streams = data.streams || [];
const streams = data.streams || [];
if (!streams.length) { c.innerHTML = '<p class="muted">No streams are live. Return when someone starts broadcasting.</p>'; return; }
if (!streams.length) { c.innerHTML = '<p class="muted">No streams are live. Return when someone starts broadcasting.</p>'; return; }
- c.innerHTML = `
- <table class="admin-table">
- <thead><tr>
- <th>Title</th><th>Streamer</th><th>Protocol</th><th>Viewers</th><th>Started</th><th>Actions</th>
- </tr></thead>
- <tbody>${streams.map(s => `
- <tr>
- <td>${esc(s.title || 'Untitled')}</td>
- <td><strong>${esc(s.username || '-')}</strong></td>
- <td><span class="badge badge-info">${esc(s.protocol)}</span></td>
- <td>${s.viewer_count || 0}</td>
- <td>${s.started_at ? timeAgo(s.started_at) : '-'}</td>
- <td>
- <button class="btn btn-sm btn-danger" onclick="endStream('${s.id}')"><i class="fa-solid fa-stop"></i> End broadcast</button><span class="admin-help">Disconnects viewers; the streamer must restart to resume.</span>
- </td>
- </tr>
- `).join('')}</tbody>
- </table>`;
+ // Each ban is a card (the owner, 11-13: on a phone the table scrolled who and why out of sight): who, as a name
+ // that opens their card; the reason; who banned them and when; how long; and, for the owner, lifting it.
+ const when = (t) => (t ? `${timeAgo(t)}` : '');
+ const nameTag = (u, id) => u
+ ? `<a class="ban-who" href="https://openvibe.network/@${encodeURIComponent(u)}" data-ov-user="${esc(u)}">@${esc(u)}</a>`
+ : `<span class="ban-who muted">user ${esc(String(id))}</span>`;
+ c.innerHTML = `<p class="muted ban-count">${bans.length === 1 ? '1 person is' : `${bans.length} people are`} banned across the site.</p>
+ <ul class="ban-list">${bans.map((b) => `
+ <li class="ban-card">
+ <div class="ban-top">${nameTag(b.username, b.user_id)}<span class="ban-len ${b.expires_at ? '' : 'is-perm'}">${b.expires_at ? `until ${esc(new Date(b.expires_at).toLocaleDateString())}` : 'Permanent'}</span></div>
+ <p class="ban-reason">${b.reason ? esc(b.reason) : '<span class="muted">No reason was written.</span>'}</p>
+ <div class="ban-meta muted">Banned ${b.banned_by_username ? `by ${nameTag(b.banned_by_username)}` : ''} ${esc(when(b.created_at))}</div>
+ <div class="ban-act">${owner
+ ? `<button class="btn btn-sm btn-outline" onclick="unbanStreamer('${esc(String(b.user_id))}')"><i class="fa-solid fa-user-check"></i> Allow access again</button>`
+ : '<span class="muted ban-owner" title="Only the owner can lift a site-wide ban">Only the owner can lift it</span>'}</div>
+ </li>`).join('')}</ul>`;
+ if (window.OVUser && typeof window.OVUser.bind === 'function') { try { window.OVUser.bind(c); } catch (e) { /* the card is optional */ } }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
}
}
@@ -82,6 +81,8 @@ async function loadBans() {
@@ -82,6 +81,8 @@ async function loadBans() {
try {
try {
const data = await api('/api/admin/streamer/bans');
const data = await api('/api/admin/streamer/bans');
const bans = data.bans || [];
const bans = data.bans || [];
+ // Only the owner lifts a site-wide ban (2026-11-13); admins see the list, who and why.
+ const owner = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.is_owner);
if (!bans.length) { c.innerHTML = '<p class="muted">No active bans. Review Users if an account needs a restriction.</p>'; return; }
if (!bans.length) { c.innerHTML = '<p class="muted">No active bans. Review Users if an account needs a restriction.</p>'; return; }
c.innerHTML = `
c.innerHTML = `
<table class="admin-table">
<table class="admin-table">
@@ -95,9 +96,9 @@ async function loadBans() {
@@ -95,9 +96,9 @@ async function loadBans() {
<td>${esc(b.banned_by_username || '-')}</td>
<td>${esc(b.banned_by_username || '-')}</td>
<td>${b.created_at ? timeAgo(b.created_at) : '-'}</td>
<td>${b.created_at ? timeAgo(b.created_at) : '-'}</td>
<td>${b.expires_at ? new Date(b.expires_at).toLocaleString() : 'Permanent'}</td>
<td>${b.expires_at ? new Date(b.expires_at).toLocaleString() : 'Permanent'}</td>
- <td>
- <button class="btn btn-sm btn-outline" onclick="unbanStreamer('${b.user_id}')"><i class="fa-solid fa-user-check"></i> Allow access again</button>
- </td>
+ <td>${owner
+ ? `<button class="btn btn-sm btn-outline" onclick="unbanStreamer('${esc(String(b.user_id))}')"><i class="fa-solid fa-user-check"></i> Allow access again</button>`
+ : '<span class="muted" title="Only the owner can lift a site-wide ban">Owner only</span>'}</td>
</tr>
</tr>
`).join('')}</tbody>
`).join('')}</tbody>
</table>`;
</table>`;
@@ -228,11 +228,18 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -228,11 +228,18 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Email Configuration (Resend)
// Email Configuration (Resend)
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
- // GET /api/admin/email — get current email config (owner-only: exposes key metadata)
- router.get('/email', requireOwner, async (req, res) => {
+ // GET /api/admin/email — the delivery numbers for every admin; the configuration (key metadata), the recent messages
+ // (they carry addresses) and the right to change anything are the owner's (2026-11-13, the owner: "admin should only
+ // be able to view the basic stats on this page, no editing, while owner can edit").
+ router.get('/email', async (req, res) => {
try {
try {
+ const metrics = await getEmailMetrics();
+ if (!isOwner(req.user)) {
+ const status = await emailService.getStatus();
+ return res.json({ ok: true, can_edit: false, email: { enabled: !!(status && status.enabled) }, metrics: { summary: metrics.summary || {}, by_type: metrics.by_type || [] } });
+ }
const status = await emailService.getStatus();
const status = await emailService.getStatus();
- res.json({ ok: true, email: status, metrics: await getEmailMetrics() });
+ res.json({ ok: true, can_edit: true, email: status, metrics });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: err.message });
}
}
@@ -805,7 +812,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -805,7 +812,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Broadcast Notifications
// Broadcast Notifications
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
- router.post('/broadcast', async (req, res) => {
+ // A message to every person on the network is the owner's to send (2026-11-13).
+ router.post('/broadcast', requireOwner, async (req, res) => {
try {
try {
const { title, message, icon, url, priority, category } = req.body;
const { title, message, icon, url, priority, category } = req.body;
if (!title) return res.status(400).json({ ok: false, error: 'Title required' });
if (!title) return res.status(400).json({ ok: false, error: 'Title required' });
@@ -67,7 +67,12 @@ function createServiceProxies({ requireAuth, signToken, services }) {
@@ -67,7 +67,12 @@ function createServiceProxies({ requireAuth, signToken, services }) {
const chat = () => services.chat.internalUrl;
const chat = () => services.chat.internalUrl;
r.use('/tools-workers', require('./tools-workers').createToolsWorkers({ requireAuth, signToken, services }));
r.use('/tools-workers', require('./tools-workers').createToolsWorkers({ requireAuth, signToken, services }));
- r.use('/streamer', requireAuth, admin, to(live, '/api/admin', 'OpenVibe.Live', 'Streamer proxy error'));
+ // Lifting a site-wide ban is the owner's alone (the owner, 2026-11-13: "admins can't unban … only owner rank, but they
+ // can view"); Live checks it too, so this answers first and plainly.
+ const { isOwner } = require('../auth/owner-guard');
+ const ownerUnban = (req, res, next) => (req.method === 'DELETE' && /^\/(?:users\/[^/]+\/ban|ban\/[^/]+)\/?$/.test(req.path) && !isOwner(req.user)
+ ? res.status(403).json({ error: 'Only the owner can lift a site-wide ban', code: 'owner_only' }) : next());
+ r.use('/streamer', requireAuth, admin, ownerUnban, to(live, '/api/admin', 'OpenVibe.Live', 'Streamer proxy error'));
r.use('/streamer-mod', requireAuth, staff, to(live, '/api/mod', 'OpenVibe.Live', 'Mod proxy error'));
r.use('/streamer-mod', requireAuth, staff, to(live, '/api/mod', 'OpenVibe.Live', 'Mod proxy error'));
r.use('/streamer-tts', requireAuth, admin, to(chat, '/api/tts', 'OpenVibe.Chat', 'TTS proxy error'));
r.use('/streamer-tts', requireAuth, admin, to(chat, '/api/tts', 'OpenVibe.Chat', 'TTS proxy error'));
r.use('/streamer-pastes', requireAuth, admin, to(live, '/api/pastes', 'OpenVibe.Live', 'Pastes proxy error'));
r.use('/streamer-pastes', requireAuth, admin, to(live, '/api/pastes', 'OpenVibe.Live', 'Pastes proxy error'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+// The admin panel, 2026-11-13 (the owner's phone): bans are cards that show who (a name that opens their card), the
+// reason and who banned them, and only the owner sees "Allow access again"; every on/off is the network's switch, never
+// a checkbox stretched to a text field's height.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
+
+const mod = read('public/js/admin/moderation.js');
+assert.ok(/const owner = !!\(typeof currentUser !== 'undefined' && currentUser && currentUser\.is_owner\);/.test(mod), 'the owner is known on the page');
+assert.ok(/<li class="ban-card">/.test(mod) && /data-ov-user="\$\{esc\(u\)\}"/.test(mod), 'each ban is a card with a name that opens the person\'s card');
+assert.ok(/No reason was written\./.test(mod), 'a ban without a reason says so');
+assert.ok(/\$\{owner\s*\?\s*`<button[^`]*unbanStreamer/.test(mod) && /Only the owner can lift it/.test(mod), 'only the owner gets the button');
+
+const core = read('public/js/admin/core.js');
+assert.ok(/function adminSwitches\(\)/.test(core) && /c\.setAttribute\('role', 'switch'\)/.test(core) && /c\.closest\('table, \[data-ov-native\]'\)/.test(core), 'a lone checkbox becomes the switch; a table\'s stays a checkbox');
+const css = read('public/css/admin.css');
+assert.ok(css.includes('.admin-clarity :is(input:not([type=checkbox]):not([type=radio]),select,textarea) { max-width:100%; min-height:44px;'), 'the field height is for fields only');
+assert.ok(css.includes('.admin-clarity input[type=checkbox], .admin-clarity input[type=radio], #platform-ai input[type=checkbox] { min-height:0; }'), 'and never for a switch');
+// Email: every admin sees the delivery numbers; the settings, the recent messages (addresses) and changes are the owner's.
+const routes = read('server/admin/routes.js');
+assert.ok(/router\.get\('\/email', async \(req, res\) => \{[\s\S]{0,300}if \(!isOwner\(req\.user\)\) \{[\s\S]{0,300}can_edit: false/.test(routes), 'an admin reads the numbers only');
+assert.ok(/router\.put\('\/email', requireOwner/.test(routes) && /router\.post\('\/broadcast', requireOwner/.test(routes), 'changing email and broadcasting are the owner\'s');
+const content = read('public/js/admin/content.js');
+assert.ok(/data\.can_edit === false \? '<p class="muted email-view-only">/.test(content), 'the page says it is read-only');
+assert.ok(/OWNER_ONLY_TABS = new Set\(\[[^\]]*'notifications'/.test(core) && !/OWNER_ONLY_TABS = new Set\(\[[^\]]*'email'/.test(core), 'Notifications is the owner\'s tab; Email is open to read');
+console.log('admin-bans-switches: bans as cards with who and why, unban for the owner only, every on/off a switch');
@@ -63,6 +63,23 @@ function upstream(name) {
@@ -63,6 +63,23 @@ function upstream(name) {
const reset = () => { live.seen.length = 0; chat.seen.length = 0; };
const reset = () => { live.seen.length = 0; chat.seen.length = 0; };
try {
try {
+ // ── Lifting a site-wide ban is the owner's (2026-11-13) ──────────
+ {
+ const ownerUser = { id: 9, role: 'admin', username: process.env.OWNER_USERNAME || 'goosely' };
+ const adminNamed = { id: 1, role: 'admin', username: 'someadmin' };
+ reset();
+ let u = await call(adminNamed, 'DELETE', '/api/admin/streamer/users/5/ban');
+ assert.deepStrictEqual([u.status, u.json && u.json.code], [403, 'owner_only'], 'an admin may not lift a site-wide ban');
+ u = await call(adminNamed, 'DELETE', '/api/admin/streamer/ban/77');
+ assert.strictEqual(u.status, 403, 'nor by its ban id');
+ assert.strictEqual(live.seen.length, 0, 'Live never hears it');
+ u = await call(adminNamed, 'GET', '/api/admin/streamer/bans');
+ assert.strictEqual(u.status, 200, 'an admin still sees the list');
+ u = await call(ownerUser, 'DELETE', '/api/admin/streamer/users/5/ban');
+ assert.strictEqual(u.status, 200, 'the owner may');
+ assert.strictEqual(live.seen.at(-1).url, '/api/admin/users/5/ban');
+ reset();
+ }
// ── TTS → Chat ───────────────────────────────────────────────
// ── TTS → Chat ───────────────────────────────────────────────
let r = await call(admin, 'GET', '/api/admin/streamer-tts/admin/settings');
let r = await call(admin, 'GET', '/api/admin/streamer-tts/admin/settings');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);