the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
@@ -0,0 +1,507 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Rec Room — Admin Panel</title>
<style>
:root{
--bg:#0f1724; --panel:#0b1220; --muted:#9aa4b2; --accent:#2dd4bf; --danger:#ff6b6b;
--glass: rgba(255,255,255,0.02);
}
*{box-sizing:border-box}
body{margin:0;font-family:Inter,system-ui,Segoe UI,Roboto,"Helvetica Neue",Arial;background:linear-gradient(180deg,var(--bg),#071021);color:#e6eef6}
.wrap{max-width:1200px;margin:28px auto;padding:18px}
header{display:flex;gap:16px;align-items:center;justify-content:space-between;padding:12px 18px;background:var(--glass);border-radius:12px;border:1px solid rgba(255,255,255,0.04)}
.brand{display:flex;gap:12px;align-items:center}
.logo{width:44px;height:44px;border-radius:10px;background:linear-gradient(135deg,var(--accent),#60a5fa);display:flex;align-items:center;justify-content:center;font-weight:700;color:#052b2a}
.title{font-size:18px;font-weight:700}
.controls{display:flex;gap:8px;align-items:center}
button{background:transparent;border:1px solid rgba(255,255,255,0.06);padding:8px 10px;border-radius:8px;color:inherit;cursor:pointer}
.grid{display:grid;grid-template-columns:280px 1fr;gap:16px;margin-top:18px}
aside{background:var(--panel);padding:12px;border-radius:12px;border:1px solid rgba(255,255,255,0.04)}
main{background:var(--panel);padding:14px;border-radius:12px;border:1px solid rgba(255,255,255,0.04)}
nav ul{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px}
nav li{display:flex;align-items:center;gap:8px}
.menu-item{padding:10px;border-radius:8px;cursor:pointer}
.menu-item.active{background:linear-gradient(90deg,#042027 0%, rgba(255,255,255,0.02) 100%);border:1px solid rgba(255,255,255,0.02)}
.panel-block{background:rgba(255,255,255,0.02);padding:10px;border-radius:8px;margin-bottom:10px}
.list{display:flex;flex-direction:column;gap:8px;max-height:300px;overflow:auto;padding-right:6px}
.row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px;border-radius:8px}
.badge{font-size:12px;padding:6px 8px;border-radius:999px;background:rgba(255,255,255,0.03)}
.muted{color:var(--muted);font-size:13px}
.table{width:100%;border-collapse:collapse}
.table th,.table td{padding:8px;text-align:left;border-bottom:1px dashed rgba(255,255,255,0.02);font-size:13px}
input,select,textarea{padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,0.04);background:transparent;color:inherit;width:100%}
.row-actions{display:flex;gap:6px}
.danger{background:linear-gradient(90deg,var(--danger), #ff8a8a);color:#fff;border:none}
.accent{background:linear-gradient(90deg,var(--accent), #60a5fa);color:#012; border:none}
footer{margin-top:14px;color:var(--muted);font-size:13px}
.logs{background:#031018;padding:10px;border-radius:8px;max-height:220px;overflow:auto;font-family:monospace;font-size:12px}
.stat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.stat{background:rgba(255,255,255,0.02);padding:12px;border-radius:10px;text-align:center}
.player-online{color:#9ff0d9;font-weight:700}
.login-box{max-width:360px;margin:28px auto;background:var(--panel);padding:18px;border-radius:12px}
.hidden{display:none}
@media(max-width:900px){.grid{grid-template-columns:1fr;}.stat-grid{grid-template-columns:repeat(2,1fr)}}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<div class="logo">RR</div>
<div>
<div class="title">Rec Room — Admin</div>
<div class="muted">Moderation & server controls</div>
</div>
</div>
<div class="controls">
<div id="server-status" class="muted">Disconnected</div>
<button id="btn-reconnect">Reconnect</button>
<button id="btn-logout" class="hidden">Logout</button>
</div>
</header>
<!-- Login -->
<div id="login" class="login-box">
<h3>Admin login</h3>
<p class="muted">Use an admin API token or username/password (demo only).</p>
<div style="display:grid;gap:8px">
<input id="login-username" placeholder="username (admin)" />
<input id="login-password" placeholder="password (demo)" type="password" />
<input id="login-token" placeholder="OR paste API token" />
<div style="display:flex;gap:8px">
<button id="btn-login" class="accent">Login</button>
<button id="btn-demo" title="Quick demo token">Demo token</button>
</div>
<div id="login-msg" class="muted"></div>
</div>
</div>
<!-- Main dashboard -->
<div id="dashboard" class="hidden">
<div class="grid">
<aside>
<nav>
<ul>
<li><div class="menu-item active" data-view="overview">Overview</div></li>
<li><div class="menu-item" data-view="players">Players</div></li>
<li><div class="menu-item" data-view="rooms">Rooms</div></li>
<li><div class="menu-item" data-view="games">Games</div></li>
<li><div class="menu-item" data-view="images">Images</div></li>
<li><div class="menu-item" data-view="logs">Logs</div></li>
<li><div class="menu-item" data-view="settings">Settings</div></li>
</ul>
</nav>
<div style="margin-top:12px">
<div class="panel-block">
<div class="muted">Live</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:8px">
<div><strong id="live-players">0</strong><div class="muted">players</div></div>
<div style="margin-left:auto"><button id="btn-refresh">Refresh</button></div>
</div>
</div>
<div class="panel-block">
<div class="muted">Server control</div>
<div style="display:flex;gap:8px;margin-top:8px">
<button id="btn-restart">Restart</button>
<button id="btn-save">Save</button>
</div>
</div>
<div class="panel-block">
<div class="muted">Utilities</div>
<div style="display:flex;gap:8px;margin-top:8px">
<button id="btn-broadcast">Broadcast</button>
<button id="btn-mass-kick">Kick All</button>
</div>
</div>
</div>
</aside>
<main>
<!-- dynamic views -->
<div id="view-overview" class="view">
<h2>Overview</h2>
<div class="stat-grid" style="margin-top:12px">
<div class="stat"><div class="muted">Players online</div><div id="stat-players" class="player-online">0</div></div>
<div class="stat"><div class="muted">Active rooms</div><div id="stat-rooms">0</div></div>
<div class="stat"><div class="muted">Games</div><div id="stat-games">0</div></div>
</div>
<h3 style="margin-top:16px">Live Players</h3>
<div class="list" id="player-list" style="margin-top:8px"></div>
<h3 style="margin-top:16px">Recent events</h3>
<div class="logs" id="live-logs"></div>
</div>
<div id="view-players" class="view hidden">
<h2>Players</h2>
<table class="table" id="players-table" style="margin-top:12px">
<thead><tr><th>id</th><th>name</th><th>online</th><th>room</th><th>actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div id="view-rooms" class="view hidden">
<h2>Rooms</h2>
<div style="display:flex;gap:12px;margin-top:12px">
<div style="flex:1">
<table class="table" id="rooms-table"><thead><tr><th>id</th><th>title</th><th>players</th><th>actions</th></tr></thead><tbody></tbody></table>
</div>
<div style="width:320px">
<div class="muted">Room actions</div>
<div style="margin-top:8px">
<input id="room-title" placeholder="Room title" />
<input id="room-id" placeholder="Room ID (optional)" />
<div style="display:flex;gap:8px;margin-top:8px">
<button id="btn-create-room" class="accent">Create</button>
<button id="btn-update-room">Update</button>
</div>
</div>
</div>
</div>
</div>
<div id="view-games" class="view hidden">
<h2>Games</h2>
<table class="table" id="games-table"><thead><tr><th>id</th><th>title</th><th>created</th><th>actions</th></tr></thead><tbody></tbody></table>
</div>
<div id="view-images" class="view hidden">
<h2>Images</h2>
<div class="muted">Simple image browser (placeholder)</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:10px" id="image-grid"></div>
</div>
<div id="view-logs" class="view hidden">
<h2>Server logs</h2>
<div class="logs" id="server-logs"></div>
</div>
<div id="view-settings" class="view hidden">
<h2>Settings</h2>
<label>API Base URL</label>
<input id="setting-api" placeholder="http://localhost:3000" />
<label style="margin-top:8px">WebSocket URL</label>
<input id="setting-ws" placeholder="ws://localhost:3000" />
<div style="display:flex;gap:8px;margin-top:8px">
<button id="btn-save-settings">Save</button>
<button id="btn-reset-settings">Reset</button>
</div>
</div>
</main>
</div>
<footer>Built for your custom Rec Room server • Minimal demo code • <span id="version">v1.0</span></footer>
</div>
</div>
<script>
/* =========================
CONFIG - edit if needed
========================= */
let API_BASE = localStorage.getItem('api_base') || 'http://localhost:3000';
let WS_URL = localStorage.getItem('ws_url') || 'ws://localhost:3000';
const state = { token: null, ws: null };
/* -------------------------
Helpers
------------------------- */
function el(q,ctx=document){return ctx.querySelector(q)}
function els(q,ctx=document){return Array.from(ctx.querySelectorAll(q))}
function setText(q,t){const e=el(q); if(e) e.textContent=t}
function logEvent(s){const container=el('#live-logs'); if(!container) return; const row=document.createElement('div'); row.textContent = '['+new Date().toLocaleTimeString()+'] '+s; container.prepend(row)}
/* -------------------------
UI wiring
------------------------- */
(function initUI(){
// menu switching
els('.menu-item').forEach(mi=>{
mi.addEventListener('click',()=>{
els('.menu-item').forEach(x=>x.classList.remove('active'));
mi.classList.add('active');
const view = 'view-' + mi.dataset.view;
els('.view').forEach(v=>v.classList.add('hidden'));
el('#'+view).classList.remove('hidden');
});
});
// login
el('#btn-login').addEventListener('click', async ()=>{
const tokenInput = el('#login-token').value.trim();
if(tokenInput){ // paste token
state.token = tokenInput;
afterLogin();
return;
}
const username = el('#login-username').value.trim() || 'admin';
const password = el('#login-password').value.trim() || 'demo';
try{
const res = await fetch(API_BASE + '/api/login', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({username,password})
});
if(!res.ok) throw new Error('login failed');
const data = await res.json();
state.token = data.token;
afterLogin();
}catch(err){
el('#login-msg').textContent = 'Login failed: ' + err.message;
}
});
el('#btn-demo').addEventListener('click', ()=>{ el('#login-token').value = 'DEMO-TOKEN' });
el('#btn-logout').addEventListener('click', ()=>{
state.token = null;
el('#dashboard').classList.add('hidden');
el('#login').classList.remove('hidden');
el('#btn-logout').classList.add('hidden');
if(state.ws) state.ws.close();
});
// refresh button
el('#btn-refresh').addEventListener('click', ()=>fetchOverview());
el('#btn-restart').addEventListener('click', ()=>apiPost('/api/admin/restart',{}, true));
el('#btn-save').addEventListener('click', ()=>apiPost('/api/admin/save',{}, true));
el('#btn-broadcast').addEventListener('click', ()=> {
const msg = prompt('Broadcast message to all players:');
if(msg) apiPost('/api/admin/broadcast',{message:msg}, true);
});
el('#btn-mass-kick').addEventListener('click', ()=> {
if(confirm('Kick all online players?')) apiPost('/api/admin/kick_all',{}, true);
});
// room create/update
el('#btn-create-room').addEventListener('click', async ()=>{
const title = el('#room-title').value.trim(); if(!title) return alert('title required');
await apiPost('/api/rooms',{title});
fetchRooms();
});
el('#btn-update-room').addEventListener('click', async ()=>{
const id = el('#room-id').value.trim(); if(!id) return alert('room id required');
const title = el('#room-title').value.trim(); if(!title) return alert('title required');
await apiPut('/api/rooms/'+id,{title});
fetchRooms();
});
// settings
el('#setting-api').value = API_BASE;
el('#setting-ws').value = WS_URL;
el('#btn-save-settings').addEventListener('click', ()=>{
API_BASE = el('#setting-api').value || API_BASE;
WS_URL = el('#setting-ws').value || WS_URL;
localStorage.setItem('api_base', API_BASE);
localStorage.setItem('ws_url', WS_URL);
alert('Saved');
});
el('#btn-reset-settings').addEventListener('click', ()=>{
localStorage.removeItem('api_base'); localStorage.removeItem('ws_url');
location.reload();
});
})();
/* -------------------------
After login
------------------------- */
function afterLogin(){
el('#login').classList.add('hidden');
el('#dashboard').classList.remove('hidden');
el('#btn-logout').classList.remove('hidden');
setText('#login-msg','');
setText('#server-status','Connecting...');
connectWS();
fetchOverview();
fetchPlayers();
fetchRooms();
fetchGames();
fetchServerLogs();
}
/* -------------------------
API helpers
------------------------- */
function authHeaders(){
const h = {'Content-Type':'application/json'};
if(state.token) h['Authorization'] = 'Bearer ' + state.token;
return h;
}
async function apiGet(path){ const res = await fetch(API_BASE + path, {headers:authHeaders()}); if(!res.ok) throw new Error(await res.text()); return res.json(); }
async function apiPost(path, body={}, showOk=false){ const res = await fetch(API_BASE + path, {method:'POST',headers:authHeaders(),body:JSON.stringify(body)}); if(!res.ok) { alert('Error: '+await res.text()); return null } if(showOk) alert('OK'); return res.json(); }
async function apiPut(path, body={}){ const res = await fetch(API_BASE + path, {method:'PUT',headers:authHeaders(),body:JSON.stringify(body)}); if(!res.ok) { alert('Error: '+await res.text()); return null } return res.json(); }
async function apiDelete(path){ const res = await fetch(API_BASE + path, {method:'DELETE',headers:authHeaders()}); if(!res.ok) { alert('Error: '+await res.text()); return null } return res.json(); }
/* -------------------------
Fetchers / renderers
------------------------- */
async function fetchOverview(){
try{
const data = await apiGet('/api/overview');
setText('#stat-players', data.players_online);
setText('#stat-rooms', data.rooms);
setText('#stat-games', data.games);
el('#player-list').innerHTML = '';
data.players.slice(0,50).forEach(p=>{
const div = document.createElement('div'); div.className='row';
div.innerHTML = `<div style="display:flex;gap:12px;align-items:center"><div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.02);display:flex;align-items:center;justify-content:center">${p.name[0]||'?'}</div>
<div><div style="font-weight:600">${p.name}</div><div class="muted" style="font-size:12px">id ${p.id}</div></div></div>
<div><div class="badge">${p.room||'—'}</div></div>`;
el('#player-list').appendChild(div);
});
}catch(err){
console.error(err); setText('#server-status','Error'); logEvent('Overview fetch failed: '+err.message);
}
}
async function fetchPlayers(){
try{
const data = await apiGet('/api/players');
const tbody = el('#players-table tbody'); tbody.innerHTML='';
data.forEach(p=>{
const tr = document.createElement('tr');
tr.innerHTML = `<td>${p.id}</td><td>${p.name}</td><td>${p.online? 'Yes':'No'}</td><td>${p.room||''}</td>
<td class="row-actions">
<button onclick="inspectPlayer('${p.id}')">Inspect</button>
<button onclick="kickPlayer('${p.id}')" class="danger">Kick</button>
</td>`;
tbody.appendChild(tr);
});
setText('#live-players', data.filter(x=>x.online).length);
}catch(err){ console.error(err) }
}
async function fetchRooms(){
try{
const data = await apiGet('/api/rooms');
const tbody = el('#rooms-table tbody'); tbody.innerHTML='';
data.forEach(r=>{
const tr = document.createElement('tr');
tr.innerHTML = `<td>${r.id}</td><td>${r.title}</td><td>${r.players?.length||0}</td>
<td class="row-actions">
<button onclick="openRoom('${r.id}')">Open</button>
<button onclick="deleteRoom('${r.id}')" class="danger">Delete</button>
</td>`;
tbody.appendChild(tr);
});
setText('#stat-rooms', data.length);
}catch(err){ console.error(err) }
}
async function fetchGames(){
try{
const data = await apiGet('/api/games');
const tbody = el('#games-table tbody'); tbody.innerHTML='';
data.forEach(g=>{
const tr = document.createElement('tr');
tr.innerHTML = `<td>${g.id}</td><td>${g.title}</td><td>${new Date(g.created).toLocaleString()}</td>
<td class="row-actions"><button onclick="deleteGame('${g.id}')" class="danger">Delete</button></td>`;
tbody.appendChild(tr);
});
setText('#stat-games', data.length);
}catch(err){ console.error(err) }
}
async function fetchServerLogs(){
try{
const data = await apiGet('/api/logs');
el('#server-logs').textContent = data.join('\n');
}catch(err){ console.error(err) }
}
/* -------------------------
Actions
------------------------- */
window.inspectPlayer = async function(id){
try{
const p = await apiGet('/api/players/'+id);
alert(JSON.stringify(p, null, 2));
}catch(err){}
}
window.kickPlayer = async function(id){
if(!confirm('Kick player '+id+'?')) return;
await apiPost('/api/players/'+id+'/kick',{}, true);
fetchPlayers();
}
window.openRoom = async function(id){
// open room in a control view (placeholder)
window.open(API_BASE + '/room/' + id, '_blank');
}
window.deleteRoom = async function(id){
if(!confirm('Delete room '+id+'?')) return;
await apiDelete('/api/rooms/'+id);
fetchRooms();
}
window.deleteGame = async function(id){
if(!confirm('Delete game '+id+'?')) return;
await apiDelete('/api/games/'+id);
fetchGames();
}
/* -------------------------
WebSocket realtime
------------------------- */
function connectWS(){
if(state.ws) { try{ state.ws.close(); }catch(e){} }
const wsUrl = WS_URL.replace(/^http/, 'ws');
state.ws = new WebSocket(wsUrl + '/ws?token=' + encodeURIComponent(state.token || ''));
state.ws.addEventListener('open', ()=> {
setText('#server-status','Connected');
logEvent('Connected to server');
fetchPlayers(); fetchRooms();
});
state.ws.addEventListener('message', (ev)=>{
try{
const msg = JSON.parse(ev.data);
handleWsMsg(msg);
}catch(e){ console.log('WS msg parse error',e) }
});
state.ws.addEventListener('close', ()=>{ setText('#server-status','Disconnected'); logEvent('WS closed'); });
state.ws.addEventListener('error', (e)=>{ setText('#server-status','Error'); console.error('ws error',e) });
}
function handleWsMsg(msg){
// expected messages: {type:'player_join', payload:{id,name,room}}, ... etc
switch(msg.type){
case 'player_join':
logEvent(`JOIN ${msg.payload.name} (${msg.payload.id}) -> ${msg.payload.room}`);
fetchPlayers();
break;
case 'player_leave':
logEvent(`LEFT ${msg.payload.name} (${msg.payload.id})`);
fetchPlayers();
break;
case 'room_created':
logEvent(`Room created: ${msg.payload.title}`);
fetchRooms();
break;
case 'broadcast':
logEvent(`BROADCAST: ${msg.payload.message}`);
break;
default:
console.log('ws unknown',msg);
break;
}
}
/* quick status ping */
document.getElementById('btn-reconnect').addEventListener('click', ()=>{connectWS()});
/* first-run helpers */
setInterval(()=>{ if(state.token) fetchOverview(); }, 15000);
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,290 @@
/* Campus admin panel: the main website's look and behaviour (served at /admin/home_page.js).
*
* - Theme: the site's light / dark / autumn and accent, read from the SAME localStorage keys the
* website uses ('campus-theme', 'campus-accent'), so the panel always matches the site.
* - Side drawer: the section list; on narrow screens it slides in from the cat button.
* - Room editor: "View Room Details", "View Dorm" and "Edit" open it in place; the old
* /admin/home/view_room/<id> and /admin/edit-room/<id> links land here with ?room=<id>.
*/
(function () {
'use strict';
// ───── theme (mirrors Website/app.js) ─────
var THEME_KEY = 'campus-theme', ACCENT_KEY = 'campus-accent';
var THEMES = ['light', 'dark', 'autumn'];
var SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
var MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
var LEAF = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 3c-7 0-13 3-16 9 3-1 6-1 9 0-4 1-7 3-9 8 6-2 12-4 14-9 1-2.5 2-5 2-8z"/><path d="M4 20l9-9"/></svg>';
function store(k, v) { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch (e) { return null; } }
function hexRgb(h) { h = h.replace('#', ''); return [0, 2, 4].map(function (i) { return parseInt(h.slice(i, i + 2), 16); }); }
function applyTheme(t) {
if (THEMES.indexOf(t) < 0) t = 'light';
document.documentElement.setAttribute('data-theme', t);
syncToggles();
}
function applyAccent(c) {
if (!c || !/^#[0-9a-f]{6}$/i.test(c)) return;
var s = document.documentElement.style, rgb = hexRgb(c);
s.setProperty('--red', c);
s.setProperty('--red-dark', 'rgb(' + rgb.map(function (x) { return x * 0.82 | 0; }).join(',') + ')');
s.setProperty('--red-soft', 'rgba(' + rgb.join(',') + ',0.15)');
}
function current() { return document.documentElement.getAttribute('data-theme') || 'light'; }
function nextTheme() { return THEMES[(THEMES.indexOf(current()) + 1) % THEMES.length]; }
function syncToggles() {
var n = nextTheme();
var label = { light: SUN + '<span>Light mode</span>', dark: MOON + '<span>Dark mode</span>', autumn: LEAF + '<span>Autumn mode</span>' }[n];
var icon = { light: SUN, dark: MOON, autumn: LEAF }[n];
document.querySelectorAll('.cd-theme-toggle').forEach(function (b) { b.innerHTML = label; });
document.querySelectorAll('.theme-icon-btn').forEach(function (b) { b.innerHTML = icon; b.title = 'Switch to ' + n + ' mode'; b.setAttribute('aria-label', b.title); });
document.querySelectorAll('.cd-mode').forEach(function (b) { b.classList.toggle('active', b.dataset.theme === current()); });
}
function cycleTheme() { var n = nextTheme(); store(THEME_KEY, n); applyTheme(n); }
applyTheme(store(THEME_KEY) || 'light');
applyAccent(store(ACCENT_KEY));
// Another tab (the website, or a second panel tab) changed the theme: follow it.
window.addEventListener('storage', function (e) {
if (e.key === THEME_KEY) applyTheme(e.newValue || 'light');
if (e.key === ACCENT_KEY) applyAccent(e.newValue);
});
// ───── drawer ─────
function setDrawer(open) {
var side = document.getElementById('adminSidebar'), scrim = document.getElementById('adminScrim');
if (side) side.classList.toggle('open', open);
if (scrim) scrim.classList.toggle('open', open);
}
function ready(fn) { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
ready(function () {
syncToggles();
document.querySelectorAll('.cd-theme-toggle, .theme-icon-btn').forEach(function (b) { b.addEventListener('click', cycleTheme); });
document.querySelectorAll('.cd-mode').forEach(function (b) {
b.addEventListener('click', function () { store(THEME_KEY, b.dataset.theme); applyTheme(b.dataset.theme); });
});
var burger = document.getElementById('adminBurger');
if (burger) burger.addEventListener('click', function () {
var side = document.getElementById('adminSidebar');
setDrawer(!(side && side.classList.contains('open')));
});
var scrim = document.getElementById('adminScrim');
if (scrim) scrim.addEventListener('click', function () { setDrawer(false); });
// Picking a section on a phone closes the drawer.
document.querySelectorAll('#adminSidebar .tab').forEach(function (t) {
t.addEventListener('click', function () { if (window.innerWidth <= 900) setDrawer(false); });
});
// Room links open the editor in place instead of leaving the panel.
document.addEventListener('click', function (e) {
var a = e.target.closest && e.target.closest('a[href*="/admin/home/view_room/"], a[href*="/admin/edit-room/"]');
if (!a) return;
var id = (a.getAttribute('href').match(/\/(\d+)\/?$/) || [])[1];
if (!id) return;
e.preventDefault();
openRoomEditor(id);
});
var q = new URLSearchParams(location.search).get('room');
if (q && /^\d+$/.test(q)) {
openRoomEditor(q);
history.replaceState(null, '', location.pathname);
}
});
// ───── room editor ─────
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); }
var ACCESS = ['Private', 'Public', 'Unlisted', 'Dev only', 'Dev unlisted'];
function modal() {
var m = document.getElementById('room-editor');
if (m) return m;
m = document.createElement('div');
m.id = 'room-editor';
// Its own overlay class: the panel's start-up code hides every .popup-overlay, which closed
// an editor opened from a ?room= link the moment the page finished loading.
m.className = 're-overlay';
m.style.cssText = 'display:none; position:fixed; inset:0; z-index:2000; align-items:center; justify-content:center; padding:16px;'
+ ' background:rgba(10,8,20,.5); backdrop-filter:blur(3px);';
m.innerHTML = '<div class="popup" style="max-width:780px; width:100%; max-height:90vh; overflow:auto; padding:22px 24px; position:relative;">'
+ '<button type="button" class="icon-btn" id="re-close" aria-label="Close" style="position:absolute; top:12px; right:12px;">'
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>'
+ '<div id="re-body">Loading…</div></div>';
document.body.appendChild(m);
m.addEventListener('click', function (e) { if (e.target === m) close(); });
m.querySelector('#re-close').addEventListener('click', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && m.style.display === 'flex') close(); });
function close() { m.style.display = 'none'; }
return m;
}
function openRoomEditor(roomId) {
var m = modal(), body = m.querySelector('#re-body');
m.style.display = 'flex';
body.innerHTML = '<p style="color:var(--muted)">Loading room ' + esc(roomId) + '…</p>';
fetch('/admin/api/room/' + encodeURIComponent(roomId), { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) { body.innerHTML = '<h3>Room ' + esc(roomId) + '</h3><p style="color:var(--danger)">' + esc(d.message || 'Not found.') + '</p>'; return; }
var r = d.room;
var subs = (r.SubRooms || []).map(function (s) { return '<span class="re-chip">' + esc(s.Name || ('subroom ' + s.SubRoomId)) + ' · ' + s.MaxPlayers + ' max</span>'; }).join('');
body.innerHTML =
'<h3 style="margin:0 36px 4px 0;">^' + esc(r.Name) + '</h3>'
+ '<div class="re-meta">'
+ '<span class="re-chip">id ' + r.RoomId + '</span>'
+ '<span class="re-chip">by ' + (r.CreatorName ? '@' + esc(r.CreatorName) : '#' + r.CreatorAccountId) + '</span>'
+ '<span class="re-chip">' + r.PlayersNow + ' playing now</span>'
+ (r.IsDorm ? '<span class="re-chip">dorm</span>' : '') + (r.IsRRO ? '<span class="re-chip">Rec Room Original</span>' : '')
+ subs + '</div>'
+ '<img class="re-img" src="/img/' + esc(r.ImageName || 'DefaultRoomImage.jpg') + '?width=640" alt="">'
+ '<div class="re-grid" style="margin-top:16px;">'
+ '<label class="full">Name<input type="text" id="re-name" value="' + esc(r.Name) + '"></label>'
+ '<label class="full">Description<textarea id="re-desc" rows="3">' + esc(r.Description) + '</textarea></label>'
+ '<label>Visibility<select id="re-acc">' + ACCESS.map(function (n, i) { return '<option value="' + i + '"' + (i === r.Accessibility ? ' selected' : '') + '>' + n + '</option>'; }).join('') + '</select></label>'
+ '<label>Max players<input type="number" id="re-max" min="1" max="100" value="' + r.MaxPlayers + '"></label>'
+ '<label class="full" style="flex-direction:row; align-items:center; gap:10px;"><input type="checkbox" id="re-clone"' + (r.CloningAllowed ? ' checked' : '') + '> Allow cloning</label>'
+ '<label class="full" style="flex-direction:row; align-items:center; gap:10px;"><input type="checkbox" id="re-dev"' + (r.DevRoom ? ' checked' : '') + '> Dev room (developer chips)</label>'
+ '</div>'
+ '<div style="display:flex; gap:10px; justify-content:flex-end; margin-top:18px; flex-wrap:wrap;">'
+ '<a class="btn-login" href="/room/' + encodeURIComponent(r.Name) + '" target="_blank" rel="noopener">View on site</a>'
+ '<button type="button" class="btn btn-primary" id="re-save">Save changes</button>'
+ '</div><p id="re-msg" style="margin:10px 0 0; text-align:right;"></p>'
+ '<div id="re-people" style="margin-top:22px; border-top:1px solid var(--line); padding-top:16px;"><p style="color:var(--muted)">Loading people…</p></div>';
body.querySelector('#re-save').addEventListener('click', function () { save(r.RoomId); });
loadPeople(r.RoomId);
})
.catch(function () { body.innerHTML = '<p style="color:var(--danger)">Could not load the room.</p>'; });
}
function save(roomId) {
var f = new FormData(), $ = function (id) { return document.getElementById(id); };
f.append('roomId', roomId);
f.append('name', $('re-name').value);
f.append('description', $('re-desc').value);
f.append('accessibility', $('re-acc').value);
f.append('maxPlayers', $('re-max').value);
f.append('cloningAllowed', $('re-clone').checked ? 'true' : 'false');
f.append('devRoom', $('re-dev').checked ? 'true' : 'false');
var msg = $('re-msg'), btn = $('re-save');
btn.disabled = true; msg.style.color = 'var(--muted)'; msg.textContent = 'Saving…';
fetch('/admin/api/edit_room', { method: 'POST', body: f, credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) { msg.style.color = d.success ? 'var(--ok)' : 'var(--danger)'; msg.textContent = d.message || (d.success ? 'Saved.' : 'Failed.'); })
.catch(function () { msg.style.color = 'var(--danger)'; msg.textContent = 'Save failed.'; })
.finally(function () { btn.disabled = false; });
}
// ───── people: owner, roles, bans (AdminRoomPeopleController) ─────
function who(w) {
if (!w || !w.accountId) return '<i>nobody</i>';
return (w.username ? '@' + esc(w.username) : '#' + w.accountId)
+ (w.displayName && w.displayName !== w.username ? ' <span style="color:var(--muted)">(' + esc(w.displayName) + ')</span>' : '')
+ ' <span style="color:var(--muted); font-size:.85em;">#' + w.accountId + '</span>';
}
function when(t) { return t ? new Date(t).toLocaleString() : ''; }
var BAN_LENGTHS = [[0, 'Until unbanned'], [60, '1 hour'], [1440, '1 day'], [10080, '1 week'], [43200, '30 days']];
function peoplePost(roomId, what, fields) {
var f = new FormData();
Object.keys(fields).forEach(function (k) { f.append(k, fields[k]); });
var msg = document.getElementById('rp-msg');
if (msg) { msg.style.color = 'var(--muted)'; msg.textContent = 'Working…'; }
return fetch('/admin/api/room/' + roomId + '/' + what, { method: 'POST', body: f, credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
loadPeople(roomId, d.message, d.success);
return d;
})
.catch(function () { if (msg) { msg.style.color = 'var(--danger)'; msg.textContent = 'That failed.'; } });
}
function loadPeople(roomId, note, ok) {
var box = document.getElementById('re-people');
if (!box) return;
fetch('/admin/api/room/' + encodeURIComponent(roomId) + '/people', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) { box.innerHTML = '<p style="color:var(--danger)">' + esc(d.message || 'Could not load people.') + '</p>'; return; }
var roleOpts = function (sel) {
return d.grantable.map(function (g) { return '<option value="' + g.value + '"' + (g.value === sel ? ' selected' : '') + '>' + esc(g.name) + '</option>'; }).join('');
};
var rows = d.roles.length ? d.roles.map(function (r) {
return '<div class="rp-row" style="display:flex; gap:10px; align-items:center; justify-content:space-between; padding:6px 0; border-bottom:1px solid var(--line);">'
+ '<span>' + who(r.who) + (r.pending ? ' <span class="re-chip">invited as ' + esc(r.pendingRole) + '</span>' : '') + '</span>'
+ '<span style="display:flex; gap:6px; align-items:center;"><select class="rp-role" data-id="' + r.who.accountId + '">' + roleOpts(r.role) + '</select>'
+ '<button type="button" class="btn btn-danger rp-ban" data-id="' + r.who.accountId + '">Ban</button></span></div>';
}).join('') : '<p style="color:var(--muted); margin:6px 0;">Nobody else has a role on this room.</p>';
var bans = d.bans.length ? d.bans.map(function (b) {
return '<div style="display:flex; gap:10px; align-items:center; justify-content:space-between; padding:6px 0; border-bottom:1px solid var(--line);">'
+ '<span>' + who(b.who) + '<br><span style="color:var(--muted); font-size:.85em;">by ' + who(b.by) + ' · ' + when(b.start)
+ (b.end ? ' · until ' + when(b.end) : ' · until unbanned') + (b.reason ? ' · “' + esc(b.reason) + '”' : '') + '</span></span>'
+ '<button type="button" class="btn btn-primary rp-unban" data-id="' + b.who.accountId + '">Unban</button></div>';
}).join('') : '<p style="color:var(--muted); margin:6px 0;">Nobody is banned from this room.</p>';
var hist = d.history.length ? '<details style="margin-top:8px;"><summary style="cursor:pointer; color:var(--muted);">Earlier bans (' + d.history.length + ')</summary>'
+ d.history.map(function (h) {
return '<div style="font-size:.85em; padding:4px 0; border-bottom:1px solid var(--line);">' + who(h.who) + ' - banned by ' + who(h.by) + ' ' + when(h.start)
+ (h.reason ? ' “' + esc(h.reason) + '”' : '') + ' - <b>' + esc(h.lifted) + '</b>' + (h.unbannedBy ? ' by ' + who(h.unbannedBy) : '') + '</div>';
}).join('') + '</details>' : '';
box.innerHTML =
'<h4 style="margin:0 0 8px;">People</h4>'
+ '<p id="rp-msg" style="margin:0 0 10px; min-height:1.2em; color:' + (ok === false ? 'var(--danger)' : 'var(--ok)') + ';">' + esc(note || '') + '</p>'
+ '<div style="margin-bottom:14px;"><b>Owner:</b> ' + who(d.owner) + '</div>'
+ (d.isDorm ? '<p style="color:var(--muted); margin:0 0 12px;">This is a dorm - it belongs to its player and can\'t be given away.</p>'
: '<div class="re-grid" style="margin-bottom:16px;">'
+ '<label>Transfer ownership to<input type="text" id="rp-owner" placeholder="@username or account id"></label>'
+ '<label style="justify-content:flex-end;"><span style="display:flex; gap:8px; align-items:center;"><input type="checkbox" id="rp-keep" checked> keep the old owner as co-owner</span>'
+ '<button type="button" class="btn btn-warning" id="rp-transfer" style="margin-top:6px;">Transfer</button></label></div>')
+ '<h5 style="margin:6px 0;">Roles</h5>' + rows
+ '<div class="re-grid" style="margin:10px 0 18px;">'
+ '<label>Give a role to<input type="text" id="rp-rolewho" placeholder="@username or account id"></label>'
+ '<label>Role<span style="display:flex; gap:6px;"><select id="rp-rolepick">' + roleOpts(20).replace('value="0"', 'value="0" disabled') + '</select>'
+ '<button type="button" class="btn btn-primary" id="rp-roleadd">Give role</button></span></label></div>'
+ '<h5 style="margin:6px 0;">Bans</h5>' + bans + hist
+ '<div class="re-grid" style="margin-top:10px;">'
+ '<label>Ban<input type="text" id="rp-banwho" placeholder="@username or account id"></label>'
+ '<label>For<select id="rp-banlen">' + BAN_LENGTHS.map(function (x) { return '<option value="' + x[0] + '">' + x[1] + '</option>'; }).join('') + '</select></label>'
+ '<label class="full">Reason (optional)<span style="display:flex; gap:6px;"><input type="text" id="rp-banwhy" placeholder="shown in the ban history" style="flex:1;">'
+ '<button type="button" class="btn btn-danger" id="rp-banadd">Ban from room</button></span></label></div>';
var $ = function (id) { return document.getElementById(id); };
box.querySelectorAll('.rp-role').forEach(function (sel) {
sel.addEventListener('change', function () {
var label = sel.options[sel.selectedIndex].text;
if (sel.value === '0' && !confirm('Remove their role?')) { loadPeople(roomId); return; }
peoplePost(roomId, 'role', { player: sel.dataset.id, role: sel.value });
});
});
box.querySelectorAll('.rp-ban').forEach(function (b) {
b.addEventListener('click', function () { $('rp-banwho').value = b.dataset.id; $('rp-banwho').scrollIntoView({ block: 'center' }); $('rp-banwhy').focus(); });
});
box.querySelectorAll('.rp-unban').forEach(function (b) {
b.addEventListener('click', function () { peoplePost(roomId, 'unban', { player: b.dataset.id }); });
});
var t = $('rp-transfer');
if (t) t.addEventListener('click', function () {
var to = $('rp-owner').value.trim();
if (!to) { $('rp-owner').focus(); return; }
if (!confirm('Give ^' + d.name + ' to ' + to + '? They become its owner.')) return;
peoplePost(roomId, 'owner', { player: to, keepOld: $('rp-keep').checked ? 'true' : 'false' });
});
$('rp-roleadd').addEventListener('click', function () {
var to = $('rp-rolewho').value.trim();
if (!to) { $('rp-rolewho').focus(); return; }
peoplePost(roomId, 'role', { player: to, role: $('rp-rolepick').value });
});
$('rp-banadd').addEventListener('click', function () {
var to = $('rp-banwho').value.trim();
if (!to) { $('rp-banwho').focus(); return; }
if (!confirm('Ban ' + to + ' from ^' + d.name + '? If they are in it now they are sent out.')) return;
peoplePost(roomId, 'ban', { player: to, minutes: $('rp-banlen').value, reason: $('rp-banwhy').value });
});
})
.catch(function () { box.innerHTML = '<p style="color:var(--danger)">Could not load people.</p>'; });
}
window.openRoomEditor = openRoomEditor;
})();
@@ -0,0 +1,347 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Campus Admin</title>
<link rel="icon" href="/admin-assets/AnimatedIcon.png" type="image/png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--purple: #791B8D;
--magenta: #BF2966;
--orange: #F95D24;
--gold: #FA9912;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: 'Rubik', system-ui, sans-serif;
color: #fff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
/* Base gradient echoes the sunset ramp used in the UIGradient recolor patch:
purple top-left -> magenta -> orange -> gold bottom-right. Sits under the
tiled AnimatedIcon watermark so the whole page reads as "Campus" without
any single hero image dominating. */
background:
url('/admin-assets/Background.png') no-repeat center center fixed,
linear-gradient(135deg, var(--purple) 0%, var(--magenta) 40%, var(--orange) 75%, var(--gold) 100%);
background-size: cover;
position: relative;
overflow-x: hidden;
}
/* AnimatedIcon tile pattern, rotated + inset so its corners never show, slowly
drifts across the page. body::before so no admin-page HTML has to change to
include it - it just sits behind everything at 12% opacity. */
body::before {
content: "";
position: fixed;
inset: -40vmax;
background-image: url('/admin-assets/AnimatedIcon.png');
background-repeat: repeat;
background-size: 150px 150px;
transform: rotate(-8deg);
opacity: 0.12;
pointer-events: none;
z-index: 0;
animation: driftPattern 60s linear infinite;
}
@keyframes driftPattern {
from { background-position: 0 0; }
to { background-position: 300px 300px; }
}
.hero {
display: block;
max-width: 320px;
width: 60vw;
margin: 0 auto 24px auto;
filter: drop-shadow(0 6px 14px rgba(0,0,0,.35));
}
.login-card {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
background: rgba(20, 12, 30, 0.72);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,0.14);
border-radius: 18px;
padding: 36px 34px 30px;
box-shadow: 0 24px 60px rgba(0,0,0,0.45);
}
h2 {
font-family: 'Rubik', sans-serif;
font-weight: 700;
text-align: center;
margin: 0 0 22px;
font-size: 26px;
letter-spacing: 0.5px;
}
.input-group { margin-bottom: 16px; }
label {
display: block;
font-weight: 500;
font-size: 13px;
margin-bottom: 6px;
opacity: 0.85;
letter-spacing: 0.3px;
}
input {
width: 100%;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.08);
color: #fff;
font-family: inherit;
font-size: 15px;
transition: border-color .15s, background .15s;
}
input:focus {
outline: none;
border-color: var(--orange);
background: rgba(255,255,255,0.12);
}
input::placeholder { color: rgba(255,255,255,0.4); }
.login-btn {
display: block;
width: 100%;
padding: 13px;
margin-top: 6px;
border: none;
border-radius: 10px;
/* Same purple->orange sweep as the ramp - reads as "the primary action" without
fighting the background. */
background: linear-gradient(90deg, var(--purple), var(--orange));
color: #fff;
font-family: inherit;
font-weight: 600;
font-size: 16px;
letter-spacing: 0.4px;
cursor: pointer;
transition: transform .1s, box-shadow .15s;
box-shadow: 0 6px 16px rgba(249,93,36,0.35);
}
.login-btn:hover { transform: translateY(-1px); box-shadow: 0 10px 22px rgba(249,93,36,0.45); }
.login-btn:active { transform: translateY(0); }
.divider {
text-align: center;
font-size: 12px;
letter-spacing: 2px;
opacity: 0.55;
margin: 22px 0 14px;
position: relative;
}
.divider::before, .divider::after {
content: "";
position: absolute;
top: 50%;
width: 38%;
height: 1px;
background: rgba(255,255,255,0.18);
}
.divider::before { left: 0; }
.divider::after { right: 0; }
.discord-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 12px;
border: none;
border-radius: 10px;
background: #5865F2;
color: #fff;
font-family: inherit;
font-weight: 500;
font-size: 15px;
cursor: pointer;
transition: background .15s;
}
.discord-btn:hover { background: #4752c4; }
.error-message {
text-align: center;
margin-top: 14px;
font-size: 13px;
color: #ffcccc;
min-height: 1em;
}
.footer {
position: relative;
z-index: 1;
text-align: center;
font-size: 12px;
opacity: 0.55;
margin-top: 14px;
}
</style>
</head>
<body>
<div>
<img class="hero" src="/admin-assets/Cutout.png" alt="Campus"
onerror="this.style.display='none'">
<div class="login-card">
<h2 id="cardTitle">Campus Admin</h2>
<form id="passForm">
<div class="input-group">
<label for="username">Username</label>
<input id="username" type="text" name="username" autocomplete="username" required>
</div>
<div class="input-group">
<label for="password">Password</label>
<input id="password" type="password" name="password" autocomplete="current-password" required>
</div>
<button class="login-btn" type="submit" id="passBtn">Log in</button>
</form>
<!-- Shown instead of the form above once the password checks out on a 2FA account. -->
<form id="codeForm" style="display:none;">
<div class="input-group">
<label for="code">6-digit code (or a recovery code)</label>
<input id="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="123456" autofocus>
</div>
<button class="login-btn" type="submit" id="codeBtn">Verify</button>
<button class="discord-btn" type="button" id="backBtn" style="background:rgba(255,255,255,.12); margin-top:10px;">&larr; Back</button>
</form>
<!-- Shown instead of the password form when the password checks out but the account has no 2FA yet -
the admin panel is 2FA-only, so this is the only way in: set it up right now, off the password-proof
ticket. Same QR-via-qrcode-generator (no third party ever sees the secret) as the website's own
Settings > Security setup, just driven by a ticket instead of a logged-in session. -->
<form id="setupForm" style="display:none;">
<p style="font-size:13px; opacity:.85; margin:0 0 14px; text-align:center;">
This admin panel requires two-factor authentication. Scan this with an authenticator app
(Google Authenticator, Authy, 1Password, ...) or type the key in by hand.
</p>
<div id="setupQr" style="background:#fff; border-radius:10px; padding:10px; width:160px; margin:0 auto 14px;"></div>
<div class="input-group">
<label for="setupSecret">Manual key</label>
<input id="setupSecret" type="text" readonly style="font-family:monospace; font-size:13px;">
</div>
<div class="input-group">
<label for="setupCode">6-digit code from the app</label>
<input id="setupCode" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="123456">
</div>
<button class="login-btn" type="submit" id="setupBtn">Turn on 2FA &amp; log in</button>
<button class="discord-btn" type="button" id="setupBackBtn" style="background:rgba(255,255,255,.12); margin-top:10px;">&larr; Back</button>
</form>
<div class="error-message" id="error-message"></div>
</div>
<div class="footer">Campus &middot; private server</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js"></script>
<script>
const errEl = document.getElementById('error-message');
const passForm = document.getElementById('passForm'), codeForm = document.getElementById('codeForm'), setupForm = document.getElementById('setupForm');
let pendingTicket = '';
function setBusy(btn, busy, label) { btn.disabled = busy; btn.textContent = busy ? 'Please wait...' : label; }
async function post(url, fields) {
const body = new URLSearchParams(fields);
const r = await fetch(url, { method: 'POST', body, credentials: 'same-origin' });
try { return await r.json(); } catch { return { success: false, error: 'Unexpected response from the server.' }; }
}
function hideAllSteps() {
passForm.style.display = 'none'; codeForm.style.display = 'none'; setupForm.style.display = 'none';
}
function showCodeStep() {
hideAllSteps();
codeForm.style.display = 'block';
document.getElementById('cardTitle').textContent = 'Verification code';
document.getElementById('code').focus();
}
function showPasswordStep() {
hideAllSteps();
passForm.style.display = 'block';
document.getElementById('cardTitle').textContent = 'Campus Admin';
errEl.textContent = '';
}
async function showSetupStep() {
hideAllSteps();
setupForm.style.display = 'block';
document.getElementById('cardTitle').textContent = 'Set up 2FA to continue';
errEl.textContent = '';
const d = await post('/api/web/admin/login/setup2fa/begin', { pending: pendingTicket });
if (!d.success) { errEl.textContent = d.error || 'Could not start setup.'; showPasswordStep(); return; }
document.getElementById('setupSecret').value = d.secret;
const qrBox = document.getElementById('setupQr');
qrBox.innerHTML = '';
try {
const qr = qrcode(0, 'M');
qr.addData(d.otpauth);
qr.make();
qrBox.innerHTML = qr.createSvgTag({ scalable: true });
} catch { qrBox.innerHTML = '<div style="color:#333; font-size:12px; padding:8px;">QR unavailable - use the key.</div>'; }
document.getElementById('setupCode').value = '';
document.getElementById('setupCode').focus();
}
passForm.addEventListener('submit', async e => {
e.preventDefault();
errEl.textContent = '';
const btn = document.getElementById('passBtn'); setBusy(btn, true, 'Log in');
const d = await post('/api/web/admin/login', { username: document.getElementById('username').value, password: document.getElementById('password').value });
setBusy(btn, false, 'Log in');
if (d.need2fa) { pendingTicket = d.pending; showCodeStep(); return; }
if (d.needSetup) { pendingTicket = d.pending; showSetupStep(); return; }
if (!d.success) { errEl.textContent = d.error || 'Invalid username or password.'; return; }
location.href = '/admin/home/v4';
});
codeForm.addEventListener('submit', async e => {
e.preventDefault();
errEl.textContent = '';
const btn = document.getElementById('codeBtn'); setBusy(btn, true, 'Verify');
const d = await post('/api/web/admin/login/2fa', { pending: pendingTicket, code: document.getElementById('code').value });
setBusy(btn, false, 'Verify');
if (d.pending) pendingTicket = d.pending;
if (!d.success) { errEl.textContent = d.error || 'Wrong code.'; document.getElementById('code').value = ''; document.getElementById('code').focus(); return; }
location.href = '/admin/home/v4';
});
setupForm.addEventListener('submit', async e => {
e.preventDefault();
errEl.textContent = '';
const btn = document.getElementById('setupBtn'); setBusy(btn, true, 'Turn on 2FA & log in');
const d = await post('/api/web/admin/login/setup2fa/confirm', { pending: pendingTicket, code: document.getElementById('setupCode').value });
setBusy(btn, false, 'Turn on 2FA & log in');
if (!d.success) { errEl.textContent = d.error || 'Wrong code.'; document.getElementById('setupCode').value = ''; document.getElementById('setupCode').focus(); return; }
if (d.recoveryCodes) {
alert('2FA is on. Save these one-time recovery codes somewhere safe - each works once if you lose your authenticator app:\n\n' + d.recoveryCodes.join('\n'));
}
location.href = '/admin/home/v4';
});
document.getElementById('backBtn').addEventListener('click', showPasswordStep);
document.getElementById('setupBackBtn').addEventListener('click', showPasswordStep);
// Discord sign-in is also 2FA-gated (DiscordController.cs) - instead of handing out a session directly it
// redirects back here with a ticket in the query string, exactly like a password login's JSON response would
// carry one. Pick it up the same way and jump straight into the matching step.
(function () {
const params = new URLSearchParams(location.search);
const step = params.get('step'), ticket = params.get('pending');
if (!ticket) return;
history.replaceState(null, '', location.pathname);
pendingTicket = ticket;
if (step === '2fa') showCodeStep();
else if (step === 'setup') showSetupStep();
})();
</script>
</body>
</html>