MOHAN 35b3f98d5f feat: free-access whitelist + admin panel
- freeAccessStore.js: JSON-persisted whitelist of shops with optional
  expiry dates; isShopAllowed(), addShop(), removeShop(), listShops()
- routes/adminPanel.js: password-protected single-page admin dashboard
  served at /d4a-admin; cookie-based session auth (no extra deps);
  add / remove / list shops with expiry dates and notes
- server.js: mount /d4a-admin panel; expose GET /free-access/:shop
  public API for frontend loaders; import freeAccessStore

Credentials: d4a-admin / Data4autos@2026.
Migrates racewerksengg.myshopify.com from hardcode into the JSON store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:19:06 +05:30

370 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// routes/adminPanel.js — password-protected admin panel for free-access shop management
const express = require('express');
const crypto = require('crypto');
const { isShopAllowed, addShop, removeShop, listShops } = require('../freeAccessStore');
const router = express.Router();
const ADMIN_USER = 'd4a-admin';
const ADMIN_PASS = 'Data4autos@2026.';
const COOKIE_NAME = 'd4a_admin_tok';
const COOKIE_MAX_AGE = 8 * 60 * 60 * 1000; // 8 hours
// In-memory valid tokens (cleared on server restart)
const validTokens = new Set();
// ── auth middleware ─────────────────────────────────────────────────────────
function requireAuth(req, res, next) {
const tok = req.cookies?.[COOKIE_NAME] || req.headers['x-admin-token'];
if (tok && validTokens.has(tok)) return next();
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Unauthorised' });
res.redirect('/d4a-admin');
}
// ── cookie parser (lightweight, no dependency) ──────────────────────────────
router.use((req, _res, next) => {
const raw = req.headers.cookie || '';
req.cookies = Object.fromEntries(
raw.split(';').map(c => c.trim().split('=').map(decodeURIComponent))
.filter(([k]) => k).map(([k, ...v]) => [k, v.join('=')])
);
next();
});
router.use(express.urlencoded({ extended: false }));
router.use(express.json());
// ── login ────────────────────────────────────────────────────────────────────
router.post('/login', (req, res) => {
const { username, password } = req.body;
if (username === ADMIN_USER && password === ADMIN_PASS) {
const token = crypto.randomBytes(32).toString('hex');
validTokens.add(token);
setTimeout(() => validTokens.delete(token), COOKIE_MAX_AGE);
res.setHeader('Set-Cookie', `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${COOKIE_MAX_AGE / 1000}; SameSite=Lax`);
return res.json({ ok: true });
}
res.status(401).json({ error: 'Invalid credentials' });
});
// ── logout ───────────────────────────────────────────────────────────────────
router.post('/logout', (req, res) => {
const tok = req.cookies?.[COOKIE_NAME];
if (tok) validTokens.delete(tok);
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`);
res.json({ ok: true });
});
// ── check-auth ───────────────────────────────────────────────────────────────
router.get('/api/check-auth', (req, res) => {
const tok = req.cookies?.[COOKIE_NAME];
if (tok && validTokens.has(tok)) return res.json({ ok: true });
res.status(401).json({ ok: false });
});
// ── shops API (protected) ────────────────────────────────────────────────────
router.get('/api/shops', requireAuth, (_req, res) => {
res.json({ shops: listShops() });
});
router.post('/api/shops', requireAuth, (req, res) => {
const { shop, expiresAt, note } = req.body;
if (!shop) return res.status(400).json({ error: 'shop is required' });
const entry = addShop(shop.trim(), expiresAt || null, note || '');
res.json({ ok: true, shop: shop.trim(), entry });
});
router.delete('/api/shops/:shop', requireAuth, (req, res) => {
removeShop(decodeURIComponent(req.params.shop));
res.json({ ok: true });
});
// ── serve admin HTML (always — login gate is client-side) ────────────────────
router.get('/', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(adminHtml());
});
// ── HTML ─────────────────────────────────────────────────────────────────────
function adminHtml() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Data4Autos Admin</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f172a;min-height:100vh;color:#f1f5f9}
/* ── login screen ── */
#login-screen{display:flex;align-items:center;justify-content:center;min-height:100vh}
.login-card{background:#1e293b;border:1px solid #334155;border-radius:16px;padding:44px 40px;width:100%;max-width:400px;box-shadow:0 24px 48px rgba(0,0,0,0.5)}
.login-logo{text-align:center;margin-bottom:28px}
.login-logo .badge{display:inline-block;background:linear-gradient(135deg,#1e3a5f,#2563eb);border-radius:12px;padding:12px 20px;font-size:22px;font-weight:900;color:#fff;letter-spacing:-0.5px}
.login-logo p{margin-top:10px;font-size:13px;color:#64748b}
.login-card h2{font-size:18px;font-weight:800;color:#f1f5f9;margin-bottom:24px;text-align:center}
.field{margin-bottom:16px}
.field label{display:block;font-size:12px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px}
.field input{width:100%;background:#0f172a;border:1px solid #334155;border-radius:8px;padding:11px 14px;color:#f1f5f9;font-size:14px;outline:none;transition:border-color 0.15s}
.field input:focus{border-color:#2563eb}
.btn-primary{width:100%;background:linear-gradient(135deg,#1d4ed8,#2563eb);border:none;border-radius:8px;color:#fff;padding:12px;font-size:15px;font-weight:700;cursor:pointer;margin-top:8px;transition:opacity 0.15s}
.btn-primary:hover{opacity:0.9}
.btn-primary:disabled{opacity:0.5;cursor:not-allowed}
.err-msg{background:#fff1f2;border:1px solid #fecdd3;border-radius:8px;padding:10px 14px;font-size:13px;color:#dc2626;margin-top:12px;display:none}
/* ── dashboard ── */
#dashboard{display:none}
.topbar{background:linear-gradient(135deg,#1e3a5f 0%,#2563eb 100%);padding:16px 32px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
.topbar-title{font-size:18px;font-weight:900;color:#fff}
.topbar-sub{font-size:13px;color:rgba(255,255,255,0.65);margin-top:2px}
.logout-btn{background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.25);border-radius:8px;color:#fff;padding:7px 16px;font-size:13px;font-weight:600;cursor:pointer}
.logout-btn:hover{background:rgba(255,255,255,0.2)}
.content{max-width:960px;margin:0 auto;padding:32px 24px}
/* add form card */
.add-card{background:#1e293b;border:1px solid #334155;border-radius:14px;padding:24px 28px;margin-bottom:28px}
.add-card h3{font-size:15px;font-weight:800;color:#f1f5f9;margin-bottom:18px}
.form-row{display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:12px;align-items:end;flex-wrap:wrap}
@media(max-width:700px){.form-row{grid-template-columns:1fr}}
.form-row .field{margin-bottom:0}
.btn-add{background:linear-gradient(135deg,#15803d,#16a34a);border:none;border-radius:8px;color:#fff;padding:11px 20px;font-size:14px;font-weight:700;cursor:pointer;white-space:nowrap}
.btn-add:hover{opacity:0.9}
.success-msg{background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:10px 14px;font-size:13px;color:#15803d;margin-top:12px;display:none}
/* shops table */
.shops-card{background:#1e293b;border:1px solid #334155;border-radius:14px;overflow:hidden}
.shops-card-header{padding:18px 24px;border-bottom:1px solid #334155;display:flex;align-items:center;justify-content:space-between}
.shops-card-header h3{font-size:15px;font-weight:800;color:#f1f5f9}
.count-badge{background:#1e3a5f;border:1px solid #2563eb;border-radius:20px;padding:3px 12px;font-size:12px;font-weight:700;color:#60a5fa}
table{width:100%;border-collapse:collapse}
th{background:#0f172a;padding:11px 16px;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.06em;text-align:left}
td{padding:13px 16px;font-size:13px;border-top:1px solid #334155;vertical-align:middle}
tr:hover td{background:rgba(255,255,255,0.02)}
.shop-name{font-weight:700;color:#f1f5f9;font-size:14px}
.note-text{color:#64748b;font-style:italic}
.tag{display:inline-block;border-radius:20px;padding:3px 10px;font-size:11px;font-weight:700}
.tag-ok{background:#f0fdf4;color:#15803d;border:1px solid #bbf7d0}
.tag-exp{background:#fff1f2;color:#dc2626;border:1px solid #fecdd3}
.tag-perm{background:#eff6ff;color:#2563eb;border:1px solid #bfdbfe}
.date-text{color:#94a3b8;font-size:12px}
.btn-del{background:#fff1f2;border:1px solid #fecdd3;border-radius:6px;color:#dc2626;padding:5px 12px;font-size:12px;font-weight:700;cursor:pointer}
.btn-del:hover{background:#fecdd3}
.empty-state{padding:48px;text-align:center;color:#475569}
.empty-state .icon{font-size:36px;margin-bottom:12px}
.spinner{display:inline-block;width:20px;height:20px;border:2px solid rgba(255,255,255,0.2);border-top-color:#fff;border-radius:50%;animation:spin 0.7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<!-- ── LOGIN ── -->
<div id="login-screen">
<div class="login-card">
<div class="login-logo">
<div class="badge">D4A Admin</div>
<p>Data4Autos Free Access Manager</p>
</div>
<h2>Sign In</h2>
<div class="field"><label>Username</label><input type="text" id="inp-user" placeholder="d4a-admin" autocomplete="username"/></div>
<div class="field"><label>Password</label><input type="password" id="inp-pass" placeholder="••••••••••••" autocomplete="current-password"/></div>
<button class="btn-primary" id="login-btn" onclick="doLogin()">Sign In</button>
<div class="err-msg" id="login-err"></div>
</div>
</div>
<!-- ── DASHBOARD ── -->
<div id="dashboard">
<div class="topbar">
<div>
<div class="topbar-title">🛡️ Data4Autos — Free Access Manager</div>
<div class="topbar-sub">Manage shops with free / bypassed subscription access</div>
</div>
<button class="logout-btn" onclick="doLogout()">Sign Out</button>
</div>
<div class="content">
<!-- Add shop form -->
<div class="add-card">
<h3> Grant Free Access to a Shop</h3>
<div class="form-row">
<div class="field">
<label>Shop Domain</label>
<input type="text" id="inp-shop" placeholder="yourstore.myshopify.com"/>
</div>
<div class="field">
<label>Expiry Date <span style="color:#475569;font-weight:400">(leave blank = permanent)</span></label>
<input type="date" id="inp-expires"/>
</div>
<div class="field">
<label>Note <span style="color:#475569;font-weight:400">(optional)</span></label>
<input type="text" id="inp-note" placeholder="e.g. Partner shop"/>
</div>
<button class="btn-add" onclick="doAddShop()">Add Shop</button>
</div>
<div class="success-msg" id="add-success">✅ Shop added successfully!</div>
<div class="err-msg" id="add-err"></div>
</div>
<!-- Shops table -->
<div class="shops-card">
<div class="shops-card-header">
<h3>🏪 Whitelisted Shops</h3>
<span class="count-badge" id="shops-count">0 shops</span>
</div>
<div id="shops-table-wrap">
<div class="empty-state"><div class="spinner"></div></div>
</div>
</div>
</div>
</div>
<script>
const BASE = '/d4a-admin';
async function api(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(BASE + path, opts);
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
}
// ── AUTH CHECK ──────────────────────────────────────────────────────────────
async function checkAuth() {
const r = await api('GET', '/api/check-auth');
if (r.ok) showDashboard();
}
checkAuth();
// ── LOGIN ───────────────────────────────────────────────────────────────────
async function doLogin() {
const btn = document.getElementById('login-btn');
const err = document.getElementById('login-err');
const user = document.getElementById('inp-user').value.trim();
const pass = document.getElementById('inp-pass').value;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>';
err.style.display = 'none';
const r = await api('POST', '/login', { username: user, password: pass });
btn.disabled = false;
btn.textContent = 'Sign In';
if (r.ok) {
showDashboard();
} else {
err.textContent = r.data.error || 'Invalid credentials';
err.style.display = 'block';
}
}
document.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
// ── LOGOUT ──────────────────────────────────────────────────────────────────
async function doLogout() {
await api('POST', '/logout');
document.getElementById('dashboard').style.display = 'none';
document.getElementById('login-screen').style.display = 'flex';
document.getElementById('inp-user').value = '';
document.getElementById('inp-pass').value = '';
}
// ── SHOW DASHBOARD ──────────────────────────────────────────────────────────
function showDashboard() {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
loadShops();
}
// ── LOAD SHOPS ──────────────────────────────────────────────────────────────
async function loadShops() {
const wrap = document.getElementById('shops-table-wrap');
const r = await api('GET', '/api/shops');
if (!r.ok) { wrap.innerHTML = '<div class="empty-state"><div>Failed to load shops</div></div>'; return; }
const shops = r.data.shops || [];
document.getElementById('shops-count').textContent = shops.length + ' shop' + (shops.length !== 1 ? 's' : '');
if (shops.length === 0) {
wrap.innerHTML = '<div class="empty-state"><div class="icon">🏪</div><div>No shops added yet.<br>Use the form above to grant free access.</div></div>';
return;
}
const rows = shops.map(s => {
const statusTag = s.expired
? '<span class="tag tag-exp">Expired</span>'
: s.permanent
? '<span class="tag tag-perm">Permanent</span>'
: '<span class="tag tag-ok">Active</span>';
const expiryDisplay = s.expiresAt
? '<span class="date-text">' + new Date(s.expiresAt).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}) + '</span>'
: '<span style="color:#475569;font-size:12px">Never</span>';
const grantedDisplay = s.grantedAt
? '<span class="date-text">' + new Date(s.grantedAt).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}) + '</span>'
: '—';
return \`<tr>
<td><div class="shop-name">\${escHtml(s.shop)}</div></td>
<td>\${statusTag}</td>
<td>\${grantedDisplay}</td>
<td>\${expiryDisplay}</td>
<td><span class="note-text">\${escHtml(s.note || '—')}</span></td>
<td><button class="btn-del" onclick="doRemoveShop('\${escHtml(s.shop)}')">Remove</button></td>
</tr>\`;
}).join('');
wrap.innerHTML = \`<table>
<thead><tr>
<th>Shop Domain</th><th>Status</th><th>Granted</th><th>Expires</th><th>Note</th><th></th>
</tr></thead>
<tbody>\${rows}</tbody>
</table>\`;
}
// ── ADD SHOP ─────────────────────────────────────────────────────────────────
async function doAddShop() {
const shop = document.getElementById('inp-shop').value.trim();
const expires = document.getElementById('inp-expires').value || null;
const note = document.getElementById('inp-note').value.trim();
const ok = document.getElementById('add-success');
const err = document.getElementById('add-err');
ok.style.display = 'none';
err.style.display = 'none';
if (!shop) { err.textContent = 'Shop domain is required.'; err.style.display = 'block'; return; }
const expiresAt = expires ? new Date(expires + 'T23:59:59.000Z').toISOString() : null;
const r = await api('POST', '/api/shops', { shop, expiresAt, note });
if (r.ok) {
ok.style.display = 'block';
document.getElementById('inp-shop').value = '';
document.getElementById('inp-expires').value = '';
document.getElementById('inp-note').value = '';
loadShops();
} else {
err.textContent = r.data.error || 'Failed to add shop';
err.style.display = 'block';
}
}
// ── REMOVE SHOP ───────────────────────────────────────────────────────────────
async function doRemoveShop(shop) {
if (!confirm('Remove free access for ' + shop + '?')) return;
const r = await api('DELETE', '/api/shops/' + encodeURIComponent(shop));
if (r.ok) loadShops();
else alert('Failed to remove shop');
}
// ── UTIL ──────────────────────────────────────────────────────────────────────
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
</body>
</html>`;
}
module.exports = router;