Add admin login overlay and gated UI

Introduce a client-side admin login screen that locks the admin page until the correct password is entered. admelist.html now contains a dedicated admin login card and wraps the actual admin shell content in a hidden section; the body receives an admin-locked class while locked. app.js adds form handling, verifyAdminPassword/unlockAdminShell logic, uses a DELETE /__authcheck__ request to validate the stored password, and shows/hides the admin UI accordingly; it also centralizes auth status messages. styles.css adds styles for the login overlay, locked state, and responsive tweaks. server/server.js removes the previous server-side requireAdmin guard for /admelist so the client can perform the auth check.
This commit is contained in:
2026-03-31 22:47:43 -05:00
parent feb6dd2923
commit 06c6beb0b1
4 changed files with 105 additions and 23 deletions
+25 -12
View File
@@ -6,8 +6,8 @@
<title>Edit List - GD fedl</title> <title>Edit List - GD fedl</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
<body data-page="admelist"> <body data-page="admelist" class="admin-locked">
<header> <header id="admin-page-header">
<h1>FEDL Admin</h1> <h1>FEDL Admin</h1>
<nav> <nav>
<a href="index.html">Home</a> <a href="index.html">Home</a>
@@ -19,22 +19,34 @@
</nav> </nav>
</header> </header>
<main class="admin-shell"> <main class="admin-shell">
<section id="admin-login-screen" class="admin-login-screen">
<div class="admin-login-card">
<p class="hero-kicker">Admin Access</p>
<h2>Enter the admin password</h2>
<p class="muted">Submit the server password to unlock the list editor and run moderation tools.</p>
<form id="admin-login-form" class="admin-login-form">
<label class="admin-password-field" for="admin-password">
<span>Admin password</span>
<input id="admin-password" type="password" placeholder="Enter server password" autocomplete="current-password" />
</label>
<div class="admin-login-actions">
<button id="admin-login-submit" type="submit" class="btn">Submit</button>
<p id="admin-auth-status" class="muted">Saved only in this browser session.</p>
</div>
</form>
</div>
</section>
<section id="admin-shell-content" class="admin-shell-content" hidden>
<section class="panel admin-overview-panel"> <section class="panel admin-overview-panel">
<div> <div>
<p class="hero-kicker">Control Center</p> <p class="hero-kicker">Control Center</p>
<h2>Manage the live list and incoming runs</h2> <h2>Manage the live list and incoming runs</h2>
<p class="muted">Use the first panel to edit placements and the second panel to review new run submissions.</p> <p class="muted">Use the first panel to edit placements and the second panel to review new run submissions.</p>
</div> </div>
<div class="admin-access-panel"> <div class="admin-overview-badges">
<div class="admin-overview-badges"> <span>Live list editor</span>
<span>Live list editor</span> <span>Run moderation</span>
<span>Run moderation</span>
</div>
<label class="admin-password-field" for="admin-password">
<span>Admin password</span>
<input id="admin-password" type="password" placeholder="Enter server password" autocomplete="current-password" />
</label>
<p id="admin-auth-status" class="muted">Saved only in this browser session.</p>
</div> </div>
</section> </section>
@@ -95,6 +107,7 @@
</table> </table>
</div> </div>
</section> </section>
</section>
</main> </main>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
+68 -5
View File
@@ -452,6 +452,9 @@
} }
if(page==='admelist'){ if(page==='admelist'){
const loginScreenEl = qs('admin-login-screen');
const adminShellContentEl = qs('admin-shell-content');
const loginFormEl = qs('admin-login-form');
const statusEl = qs('admin-status'); const statusEl = qs('admin-status');
const listTbody = qs('admin-list-body'); const listTbody = qs('admin-list-body');
const addBtn = qs('add-row'); const addBtn = qs('add-row');
@@ -481,7 +484,7 @@
authStatusEl.textContent = password authStatusEl.textContent = password
? 'Password saved for this browser session.' ? 'Password saved for this browser session.'
: 'Saved only in this browser session.'; : 'Saved only in this browser session.';
authStatusEl.classList.remove('error-text'); if(password) authStatusEl.classList.remove('error-text');
} }
} }
@@ -496,9 +499,54 @@
function handleAdminAuthFailure(message, targetSetter){ function handleAdminAuthFailure(message, targetSetter){
setAdminPassword(''); setAdminPassword('');
document.body.classList.add('admin-locked');
if(adminShellContentEl) adminShellContentEl.hidden = true;
if(loginScreenEl) loginScreenEl.hidden = false;
targetSetter(message || 'Admin password required or incorrect.', true); targetSetter(message || 'Admin password required or incorrect.', true);
} }
function unlockAdminShell(){
document.body.classList.remove('admin-locked');
if(loginScreenEl) loginScreenEl.hidden = true;
if(adminShellContentEl) adminShellContentEl.hidden = false;
}
function verifyAdminPassword(){
if(!getAdminPassword()){
handleAdminAuthFailure('Enter the admin password to continue.', function(message, isError){
if(!authStatusEl) return;
authStatusEl.textContent = message;
authStatusEl.classList.toggle('error-text', !!isError);
});
return Promise.resolve(false);
}
return fetch(`${liveRunsUrl}/__authcheck__`, {
method:'DELETE',
headers:authHeaders()
}).then(r=>{
if(r.status === 401) throw new Error('Admin auth failed');
if(r.status !== 404) throw new Error('Admin verify failed');
return true;
}).then(ok=>{
unlockAdminShell();
if(authStatusEl){
authStatusEl.textContent = 'Access granted for this browser session.';
authStatusEl.classList.remove('error-text');
}
loadAdmin();
loadRunsAdmin();
return ok;
}).catch(err=>{
console.error(err);
handleAdminAuthFailure('Wrong admin password. Try again.', function(message, isError){
if(!authStatusEl) return;
authStatusEl.textContent = message;
authStatusEl.classList.toggle('error-text', !!isError);
});
return false;
});
}
function setStatus(message, isError){ function setStatus(message, isError){
if(!statusEl) return; if(!statusEl) return;
statusEl.textContent = message; statusEl.textContent = message;
@@ -839,8 +887,16 @@
} }
if(adminPasswordEl){ if(adminPasswordEl){
setAdminPassword(getAdminPassword()); setAdminPassword(getAdminPassword());
adminPasswordEl.addEventListener('input', function(){ }
setAdminPassword(adminPasswordEl.value.trim()); if(loginFormEl){
loginFormEl.addEventListener('submit', function(event){
event.preventDefault();
setAdminPassword((adminPasswordEl && adminPasswordEl.value || '').trim());
if(authStatusEl){
authStatusEl.textContent = 'Checking password...';
authStatusEl.classList.remove('error-text');
}
verifyAdminPassword();
}); });
} }
@@ -863,8 +919,15 @@
setRunsStatus('Run queue reloaded from the live server.'); setRunsStatus('Run queue reloaded from the live server.');
}); });
loadAdmin(); if(getAdminPassword()){
loadRunsAdmin(); verifyAdminPassword();
}else{
handleAdminAuthFailure('Enter the admin password to continue.', function(message, isError){
if(!authStatusEl) return;
authStatusEl.textContent = message;
authStatusEl.classList.toggle('error-text', !!isError);
});
}
} }
if(page==='run'){ if(page==='run'){
-4
View File
@@ -297,10 +297,6 @@ const server = http.createServer((req, res) => {
return; return;
} }
if ((pathname === '/admelist.html' || pathname === '/admelist') && !requireAdmin(req, res)) {
return;
}
serveFile(pathname, res); serveFile(pathname, res);
}); });
+12 -2
View File
@@ -34,10 +34,20 @@ table.levels-table tr:hover td{background:rgba(255,255,255,0.01)}
.small-btn{padding:8px 12px} .small-btn{padding:8px 12px}
.danger-btn{background:#ff7b7b;color:#2a0909} .danger-btn{background:#ff7b7b;color:#2a0909}
.admin-shell{padding:18px 20px} .admin-shell{padding:18px 20px}
.admin-shell-content[hidden]{display:none}
.admin-locked{overflow:hidden}
.admin-locked #admin-page-header,.admin-locked #admin-shell-content{visibility:hidden}
.admin-login-screen{position:fixed;inset:0;z-index:2000;display:flex;align-items:center;justify-content:center;padding:30px;background:
radial-gradient(circle at top,rgba(255,184,77,0.16),transparent 24%),
linear-gradient(180deg,rgba(6,16,33,0.98),rgba(7,20,38,0.99))}
.admin-login-card{width:min(100%,520px);padding:34px;border-radius:28px;background:linear-gradient(145deg,rgba(255,184,77,0.12),rgba(7,19,38,0.96) 42%),radial-gradient(circle at top left,rgba(255,255,255,0.08),transparent 35%);border:1px solid rgba(255,184,77,0.16)}
.admin-login-card h2{margin:0 0 10px 0;font-size:clamp(2rem,4vw,2.8rem)}
.admin-login-form{display:grid;gap:16px;margin-top:20px}
.admin-login-actions{display:grid;gap:12px}
.admin-login-screen[hidden]{display:none}
.admin-panel{padding:20px} .admin-panel{padding:20px}
.admin-overview-panel{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 26px;border-radius:28px;background:linear-gradient(135deg,rgba(255,184,77,0.12),rgba(7,19,38,0.95));border:1px solid rgba(255,184,77,0.16)} .admin-overview-panel{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 26px;border-radius:28px;background:linear-gradient(135deg,rgba(255,184,77,0.12),rgba(7,19,38,0.95));border:1px solid rgba(255,184,77,0.16)}
.admin-overview-panel h2{margin:0 0 8px 0;font-size:clamp(1.9rem,4vw,2.8rem)} .admin-overview-panel h2{margin:0 0 8px 0;font-size:clamp(1.9rem,4vw,2.8rem)}
.admin-access-panel{display:grid;gap:12px;justify-items:start;min-width:min(100%,320px)}
.admin-overview-badges{display:flex;flex-wrap:wrap;gap:10px} .admin-overview-badges{display:flex;flex-wrap:wrap;gap:10px}
.admin-overview-badges span{padding:10px 14px;border-radius:999px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);color:#eef5ff;font-weight:700} .admin-overview-badges span{padding:10px 14px;border-radius:999px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);color:#eef5ff;font-weight:700}
.admin-password-field{display:grid;gap:8px;width:100%;font-weight:700;color:#f2f7fd} .admin-password-field{display:grid;gap:8px;width:100%;font-weight:700;color:#f2f7fd}
@@ -259,5 +269,5 @@ linear-gradient(180deg,rgba(8,18,32,0.98),rgba(7,19,38,0.94))}
.status-card span{display:block;color:var(--muted);line-height:1.5} .status-card span{display:block;color:var(--muted);line-height:1.5}
@media(max-width:900px){.layout{flex-direction:column}.sidebar{width:100%}.sidebar .panel{height:auto}.video-modal iframe{height:320px}.home-hero{grid-template-columns:1fr;min-height:auto}.home-copy,.hero-stage{padding:24px 22px}.hero-stats,.home-section-grid,.spotlight-grid{grid-template-columns:1fr}.spotlight-panel{grid-template-columns:1fr;padding:24px 22px}.list-hero-panel,.table-header,.search-row,.roulette-head{flex-direction:column;align-items:flex-start}.control-select{max-width:none;width:100%}.roulette-shell{grid-template-columns:1fr}.discord-panel{flex-direction:column;align-items:flex-start;padding:22px 20px}.discord-panel h2{font-size:1.55rem}.discord-actions{width:100%;justify-content:flex-start}.rules-view header{padding:16px 18px}.rules-view header h1{font-size:1.55rem}.rules-page{padding:24px 20px}.rules-page h2{font-size:1.65rem}} @media(max-width:900px){.layout{flex-direction:column}.sidebar{width:100%}.sidebar .panel{height:auto}.video-modal iframe{height:320px}.home-hero{grid-template-columns:1fr;min-height:auto}.home-copy,.hero-stage{padding:24px 22px}.hero-stats,.home-section-grid,.spotlight-grid{grid-template-columns:1fr}.spotlight-panel{grid-template-columns:1fr;padding:24px 22px}.list-hero-panel,.table-header,.search-row,.roulette-head{flex-direction:column;align-items:flex-start}.control-select{max-width:none;width:100%}.roulette-shell{grid-template-columns:1fr}.discord-panel{flex-direction:column;align-items:flex-start;padding:22px 20px}.discord-panel h2{font-size:1.55rem}.discord-actions{width:100%;justify-content:flex-start}.rules-view header{padding:16px 18px}.rules-view header h1{font-size:1.55rem}.rules-page{padding:24px 20px}.rules-page h2{font-size:1.65rem}}
@media(max-width:900px){.error-grid{grid-template-columns:1fr}.error-copy,.error-side{padding:24px 20px}.error-copy h2{font-size:2.5rem}} @media(max-width:900px){.error-grid{grid-template-columns:1fr}.error-copy,.error-side{padding:24px 20px}.error-copy h2{font-size:2.5rem}}
@media(max-width:900px){.admin-overview-panel,.admin-toolbar{flex-direction:column;align-items:stretch}.admin-actions{flex-direction:column;align-items:stretch}.admin-password-field input,.admin-actions input[type=text],.admin-actions input[type=password]{min-width:0;width:100%}.run-shell{grid-template-columns:1fr}.run-head{flex-direction:column;align-items:flex-start}.run-form{grid-template-columns:1fr}} @media(max-width:900px){.admin-overview-panel,.admin-toolbar{flex-direction:column;align-items:stretch}.admin-actions{flex-direction:column;align-items:stretch}.admin-password-field input,.admin-actions input[type=text],.admin-actions input[type=password]{min-width:0;width:100%}.run-shell{grid-template-columns:1fr}.run-head{flex-direction:column;align-items:flex-start}.run-form{grid-template-columns:1fr}.admin-login-screen{padding:18px}.admin-login-card{padding:26px 22px}}
@media(max-width:640px){header{flex-direction:column;align-items:flex-start;gap:12px}nav{display:flex;flex-wrap:wrap;gap:10px}nav a{margin-left:0}.home-copy h2{max-width:none}.hero-actions{flex-direction:column;align-items:stretch}.hero-actions .btn,.roulette-btn{width:100%;text-align:center}.list-hero-panel,.list-controls-panel,.list-table-panel,.roulette-panel,.roulette-rules-card,.run-panel,.run-side-panel,.submission-panel{padding:22px 18px}.table-hint{text-align:left}.run-admin-actions{justify-content:flex-start}} @media(max-width:640px){header{flex-direction:column;align-items:flex-start;gap:12px}nav{display:flex;flex-wrap:wrap;gap:10px}nav a{margin-left:0}.home-copy h2{max-width:none}.hero-actions{flex-direction:column;align-items:stretch}.hero-actions .btn,.roulette-btn{width:100%;text-align:center}.list-hero-panel,.list-controls-panel,.list-table-panel,.roulette-panel,.roulette-rules-card,.run-panel,.run-side-panel,.submission-panel{padding:22px 18px}.table-hint{text-align:left}.run-admin-actions{justify-content:flex-start}}