- Login: vibrant blue gradient left panel + pure white right form panel - Dashboard: white topbar with blue accent, white cards, light gray page bg - Status tags: light-coloured pill badges (green/red/blue) with dot indicators - Remove all dark glassmorphism and near-black surfaces - Sync count badge across topbar and table header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
648 lines
29 KiB
JavaScript
648 lines
29 KiB
JavaScript
// 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 Portal</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:'Inter',system-ui,sans-serif;background:#f1f5f9;min-height:100vh;color:#0f172a;overflow-x:hidden}
|
||
|
||
/* ═══════════════════════════ LOGIN ═══════════════════════════ */
|
||
#login-screen{display:flex;align-items:stretch;min-height:100vh;}
|
||
|
||
/* Left — vibrant blue panel */
|
||
.login-left{
|
||
flex:1;display:flex;flex-direction:column;justify-content:center;
|
||
padding:64px 56px;
|
||
background:linear-gradient(145deg,#1e40af 0%,#2563eb 45%,#4f46e5 100%);
|
||
position:relative;overflow:hidden;
|
||
}
|
||
@media(max-width:860px){.login-left{display:none}}
|
||
.login-left::before{
|
||
content:'';position:absolute;width:500px;height:500px;border-radius:50%;
|
||
background:rgba(255,255,255,0.06);top:-160px;right:-120px;
|
||
}
|
||
.login-left::after{
|
||
content:'';position:absolute;width:320px;height:320px;border-radius:50%;
|
||
background:rgba(255,255,255,0.05);bottom:-80px;left:-60px;
|
||
}
|
||
.ll-logo{
|
||
display:flex;align-items:center;gap:14px;margin-bottom:52px;position:relative;z-index:1;
|
||
}
|
||
.ll-icon{
|
||
width:52px;height:52px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.25);
|
||
border-radius:14px;display:flex;align-items:center;justify-content:center;font-size:24px;
|
||
}
|
||
.ll-brand{font-size:20px;font-weight:800;color:#fff;letter-spacing:-0.3px;}
|
||
.ll-brand span{display:block;font-size:12px;font-weight:500;color:rgba(255,255,255,0.6);letter-spacing:0;margin-top:1px;}
|
||
.ll-heading{font-size:34px;font-weight:900;color:#fff;line-height:1.2;letter-spacing:-1px;margin-bottom:16px;position:relative;z-index:1;}
|
||
.ll-heading em{font-style:normal;color:#bfdbfe;}
|
||
.ll-desc{font-size:15px;color:rgba(255,255,255,0.72);line-height:1.7;max-width:340px;margin-bottom:48px;position:relative;z-index:1;}
|
||
.ll-features{display:flex;flex-direction:column;gap:12px;position:relative;z-index:1;}
|
||
.ll-feat{
|
||
display:flex;align-items:center;gap:12px;
|
||
background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);
|
||
border-radius:10px;padding:12px 16px;
|
||
}
|
||
.ll-feat-check{
|
||
width:22px;height:22px;border-radius:6px;background:rgba(255,255,255,0.15);
|
||
display:flex;align-items:center;justify-content:center;
|
||
font-size:12px;color:#fff;flex-shrink:0;font-weight:700;
|
||
}
|
||
.ll-feat span{font-size:13px;color:rgba(255,255,255,0.85);font-weight:500;}
|
||
|
||
/* Right — white form panel */
|
||
.login-right{
|
||
width:500px;flex-shrink:0;background:#fff;
|
||
display:flex;align-items:center;justify-content:center;
|
||
padding:48px 40px;
|
||
}
|
||
@media(max-width:860px){.login-right{width:100%;}}
|
||
.login-card{width:100%;max-width:380px;}
|
||
@keyframes cardIn{from{opacity:0;transform:translateY(16px);}to{opacity:1;transform:none;}}
|
||
.login-card{animation:cardIn 0.4s ease both;}
|
||
|
||
.lc-top{margin-bottom:36px;}
|
||
.lc-eyebrow{
|
||
display:inline-flex;align-items:center;gap:6px;
|
||
background:#eff6ff;border:1px solid #bfdbfe;border-radius:20px;
|
||
padding:4px 12px;font-size:11px;font-weight:700;color:#1d4ed8;
|
||
letter-spacing:0.06em;text-transform:uppercase;margin-bottom:18px;
|
||
}
|
||
.lc-eyebrow .status-dot{width:6px;height:6px;border-radius:50%;background:#16a34a;}
|
||
.lc-title{font-size:26px;font-weight:900;color:#0f172a;letter-spacing:-0.8px;margin-bottom:6px;}
|
||
.lc-sub{font-size:14px;color:#64748b;line-height:1.5;}
|
||
|
||
/* fields */
|
||
.lc-field{margin-bottom:20px;}
|
||
.lc-field label{
|
||
display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:7px;
|
||
}
|
||
.lc-inp-wrap{position:relative;}
|
||
.lc-inp-icon{
|
||
position:absolute;left:13px;top:50%;transform:translateY(-50%);
|
||
width:18px;height:18px;display:flex;align-items:center;justify-content:center;
|
||
color:#9ca3af;font-size:14px;pointer-events:none;
|
||
}
|
||
.lc-field input{
|
||
width:100%;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:10px;
|
||
padding:12px 40px 12px 40px;color:#0f172a;font-size:14px;font-family:inherit;
|
||
outline:none;transition:border-color 0.15s,box-shadow 0.15s,background 0.15s;
|
||
}
|
||
.lc-field input::placeholder{color:#b0b8c4;}
|
||
.lc-field input:focus{
|
||
border-color:#2563eb;background:#fff;
|
||
box-shadow:0 0 0 3px rgba(37,99,235,0.1);
|
||
}
|
||
.lc-eye{
|
||
position:absolute;right:12px;top:50%;transform:translateY(-50%);
|
||
background:none;border:none;cursor:pointer;color:#9ca3af;font-size:14px;
|
||
padding:4px;transition:color 0.15s;line-height:1;
|
||
}
|
||
.lc-eye:hover{color:#374151;}
|
||
|
||
/* divider */
|
||
.lc-divider{height:1px;background:#f1f5f9;margin:4px 0 20px;}
|
||
|
||
/* sign in button */
|
||
.btn-login{
|
||
width:100%;padding:13px 20px;border:none;border-radius:10px;
|
||
background:#2563eb;color:#fff;font-size:15px;font-weight:700;font-family:inherit;
|
||
cursor:pointer;position:relative;overflow:hidden;
|
||
transition:background 0.15s,transform 0.1s,box-shadow 0.15s;
|
||
box-shadow:0 1px 3px rgba(37,99,235,0.3),0 4px 12px rgba(37,99,235,0.2);
|
||
}
|
||
.btn-login:hover{background:#1d4ed8;transform:translateY(-1px);box-shadow:0 4px 16px rgba(37,99,235,0.35);}
|
||
.btn-login:active{transform:none;background:#1e40af;}
|
||
.btn-login:disabled{background:#93c5fd;cursor:not-allowed;transform:none;box-shadow:none;}
|
||
|
||
/* error */
|
||
.err-msg{
|
||
background:#fef2f2;border:1px solid #fecaca;border-radius:8px;
|
||
padding:10px 14px;font-size:13px;color:#dc2626;margin-top:14px;display:none;
|
||
}
|
||
@keyframes shake{0%,100%{transform:none;}20%,60%{transform:translateX(-5px);}40%,80%{transform:translateX(5px);}}
|
||
.err-msg.shake{animation:shake 0.3s ease;}
|
||
|
||
/* footer */
|
||
.lc-footer{
|
||
display:flex;align-items:center;justify-content:center;gap:6px;
|
||
margin-top:24px;font-size:11px;color:#94a3b8;
|
||
}
|
||
.lc-footer svg{opacity:0.5;}
|
||
|
||
/* spinner */
|
||
.spinner{display:inline-block;width:15px;height:15px;border:2px solid rgba(255,255,255,0.35);border-top-color:#fff;border-radius:50%;animation:spin 0.65s linear infinite;vertical-align:middle;margin-right:6px;}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
|
||
/* ═══════════════════════════ DASHBOARD ═══════════════════════════ */
|
||
#dashboard{display:none}
|
||
|
||
/* topbar */
|
||
.topbar{
|
||
background:#fff;border-bottom:1px solid #e2e8f0;
|
||
padding:0 32px;height:60px;
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
position:sticky;top:0;z-index:100;
|
||
box-shadow:0 1px 3px rgba(0,0,0,0.06);
|
||
}
|
||
.topbar-left{display:flex;align-items:center;gap:12px;}
|
||
.topbar-logo{
|
||
width:36px;height:36px;background:linear-gradient(135deg,#2563eb,#4f46e5);
|
||
border-radius:10px;display:flex;align-items:center;justify-content:center;
|
||
font-size:18px;
|
||
}
|
||
.topbar-title{font-size:16px;font-weight:800;color:#0f172a;letter-spacing:-0.3px;}
|
||
.topbar-sub{font-size:12px;color:#94a3b8;margin-top:1px;}
|
||
.topbar-right{display:flex;align-items:center;gap:12px;}
|
||
.topbar-badge{
|
||
background:#eff6ff;border:1px solid #bfdbfe;border-radius:20px;
|
||
padding:3px 10px;font-size:11px;font-weight:700;color:#2563eb;
|
||
}
|
||
.logout-btn{
|
||
background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;
|
||
color:#374151;padding:7px 14px;font-size:13px;font-weight:600;
|
||
cursor:pointer;font-family:inherit;transition:background 0.12s,border-color 0.12s;
|
||
}
|
||
.logout-btn:hover{background:#f1f5f9;border-color:#cbd5e1;}
|
||
|
||
/* content */
|
||
.content{max-width:1000px;margin:0 auto;padding:32px 24px;}
|
||
|
||
/* page header inside dashboard */
|
||
.page-header{margin-bottom:24px;}
|
||
.page-header h2{font-size:20px;font-weight:800;color:#0f172a;letter-spacing:-0.4px;}
|
||
.page-header p{font-size:13px;color:#64748b;margin-top:4px;}
|
||
|
||
/* add card */
|
||
.add-card{
|
||
background:#fff;border:1px solid #e2e8f0;border-radius:14px;
|
||
padding:24px 28px;margin-bottom:24px;
|
||
box-shadow:0 1px 3px rgba(0,0,0,0.05);
|
||
}
|
||
.add-card-header{display:flex;align-items:center;gap:10px;margin-bottom:20px;}
|
||
.add-card-icon{
|
||
width:36px;height:36px;background:#eff6ff;border-radius:9px;
|
||
display:flex;align-items:center;justify-content:center;font-size:17px;flex-shrink:0;
|
||
}
|
||
.add-card h3{font-size:15px;font-weight:700;color:#0f172a;}
|
||
.add-card .sub{font-size:12px;color:#94a3b8;margin-top:1px;}
|
||
|
||
.form-row{display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:14px;align-items:end;}
|
||
@media(max-width:720px){.form-row{grid-template-columns:1fr}}
|
||
.field{margin-bottom:0;}
|
||
.field label{display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:6px;}
|
||
.field input{
|
||
width:100%;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:9px;
|
||
padding:10px 13px;color:#0f172a;font-size:13px;font-family:inherit;
|
||
outline:none;transition:border-color 0.15s,box-shadow 0.15s,background 0.15s;
|
||
}
|
||
.field input:focus{border-color:#2563eb;background:#fff;box-shadow:0 0 0 3px rgba(37,99,235,0.1);}
|
||
.field input::placeholder{color:#b0b8c4;}
|
||
.btn-add{
|
||
background:#2563eb;border:none;border-radius:9px;color:#fff;
|
||
padding:10px 20px;font-size:13px;font-weight:700;cursor:pointer;
|
||
white-space:nowrap;font-family:inherit;
|
||
box-shadow:0 1px 3px rgba(37,99,235,0.25);transition:background 0.12s,transform 0.1s;
|
||
}
|
||
.btn-add:hover{background:#1d4ed8;transform:translateY(-1px);}
|
||
.btn-add:active{transform:none;}
|
||
|
||
.success-msg{
|
||
background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;
|
||
padding:10px 14px;font-size:13px;color:#15803d;font-weight:600;
|
||
margin-top:14px;display:none;
|
||
}
|
||
.err-msg-dash{
|
||
background:#fef2f2;border:1px solid #fecaca;border-radius:8px;
|
||
padding:10px 14px;font-size:13px;color:#dc2626;
|
||
margin-top:14px;display:none;
|
||
}
|
||
|
||
/* shops card */
|
||
.shops-card{
|
||
background:#fff;border:1px solid #e2e8f0;border-radius:14px;overflow:hidden;
|
||
box-shadow:0 1px 3px rgba(0,0,0,0.05);
|
||
}
|
||
.shops-card-header{
|
||
padding:16px 24px;border-bottom:1px solid #f1f5f9;
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
background:#fafafa;
|
||
}
|
||
.shops-card-header h3{font-size:14px;font-weight:700;color:#0f172a;}
|
||
.count-badge{
|
||
background:#eff6ff;border:1px solid #bfdbfe;border-radius:20px;
|
||
padding:3px 10px;font-size:11px;font-weight:700;color:#2563eb;
|
||
}
|
||
|
||
table{width:100%;border-collapse:collapse;}
|
||
th{
|
||
background:#fafafa;padding:10px 20px;
|
||
font-size:11px;font-weight:700;color:#6b7280;
|
||
text-transform:uppercase;letter-spacing:0.06em;text-align:left;
|
||
border-bottom:1px solid #f1f5f9;
|
||
}
|
||
td{padding:14px 20px;font-size:13px;border-top:1px solid #f8fafc;vertical-align:middle;color:#374151;}
|
||
tr:hover td{background:#fafcff;}
|
||
.shop-name{font-weight:700;color:#0f172a;font-size:14px;}
|
||
.shop-domain{font-size:11px;color:#94a3b8;margin-top:2px;font-family:monospace;}
|
||
.note-text{color:#94a3b8;font-size:12px;font-style:italic;}
|
||
.tag{display:inline-flex;align-items:center;gap:5px;border-radius:20px;padding:3px 10px;font-size:11px;font-weight:700;}
|
||
.tag-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;}
|
||
.tag-ok{background:#f0fdf4;color:#15803d;border:1px solid #bbf7d0;}
|
||
.tag-ok .tag-dot{background:#16a34a;}
|
||
.tag-exp{background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
|
||
.tag-exp .tag-dot{background:#dc2626;}
|
||
.tag-perm{background:#eff6ff;color:#2563eb;border:1px solid #bfdbfe;}
|
||
.tag-perm .tag-dot{background:#2563eb;}
|
||
.date-cell{font-size:12px;color:#6b7280;}
|
||
.btn-del{
|
||
background:#fef2f2;border:1px solid #fecaca;border-radius:6px;
|
||
color:#dc2626;padding:5px 12px;font-size:12px;font-weight:600;
|
||
cursor:pointer;font-family:inherit;transition:background 0.12s;
|
||
}
|
||
.btn-del:hover{background:#fee2e2;}
|
||
.empty-state{padding:56px 24px;text-align:center;}
|
||
.empty-state .icon{font-size:38px;margin-bottom:12px;}
|
||
.empty-state p{font-size:14px;color:#94a3b8;font-weight:500;}
|
||
.empty-state small{font-size:12px;color:#b0b8c4;display:block;margin-top:4px;}
|
||
.loading-row{padding:32px;text-align:center;}
|
||
.spinner-dark{display:inline-block;width:20px;height:20px;border:2px solid #e2e8f0;border-top-color:#2563eb;border-radius:50%;animation:spin 0.7s linear infinite;}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ═══════════════════════════ LOGIN ═══════════════════════════ -->
|
||
<div id="login-screen">
|
||
|
||
<!-- Left branding panel -->
|
||
<div class="login-left">
|
||
<div class="ll-logo">
|
||
<div class="ll-icon">🛡️</div>
|
||
<div class="ll-brand">Data4Autos <span>Admin Portal</span></div>
|
||
</div>
|
||
<div class="ll-heading">Manage <em>free access</em><br>with confidence</div>
|
||
<div class="ll-desc">Grant, revoke, and schedule free-access windows for any Shopify store — no code changes, no redeployment needed.</div>
|
||
<div class="ll-features">
|
||
<div class="ll-feat"><div class="ll-feat-check">✓</div><span>Grant free access to any Shopify store</span></div>
|
||
<div class="ll-feat"><div class="ll-feat-check">✓</div><span>Set custom expiry dates per shop</span></div>
|
||
<div class="ll-feat"><div class="ll-feat-check">✓</div><span>Changes take effect instantly</span></div>
|
||
<div class="ll-feat"><div class="ll-feat-check">✓</div><span>Persisted to disk — survives restarts</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right login panel -->
|
||
<div class="login-right">
|
||
<div class="login-card">
|
||
<div class="lc-top">
|
||
<div class="lc-eyebrow"><div class="status-dot"></div>Secure Portal</div>
|
||
<div class="lc-title">Welcome back</div>
|
||
<div class="lc-sub">Sign in to manage shop access permissions.</div>
|
||
</div>
|
||
|
||
<div class="lc-divider"></div>
|
||
|
||
<div class="lc-field">
|
||
<label for="inp-user">Username</label>
|
||
<div class="lc-inp-wrap">
|
||
<span class="lc-inp-icon">👤</span>
|
||
<input type="text" id="inp-user" placeholder="d4a-admin" autocomplete="username"/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lc-field">
|
||
<label for="inp-pass">Password</label>
|
||
<div class="lc-inp-wrap">
|
||
<span class="lc-inp-icon">🔒</span>
|
||
<input type="password" id="inp-pass" placeholder="••••••••••••" autocomplete="current-password"/>
|
||
<button class="lc-eye" id="eye-btn" onclick="togglePass()" type="button" tabindex="-1">👁</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="btn-login" id="login-btn" onclick="doLogin()">
|
||
Sign In to Admin Portal
|
||
</button>
|
||
|
||
<div class="err-msg" id="login-err"></div>
|
||
|
||
<div class="lc-footer">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||
Session secured · Expires in 8 hours
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════ DASHBOARD ═══════════════════════════ -->
|
||
<div id="dashboard">
|
||
|
||
<div class="topbar">
|
||
<div class="topbar-left">
|
||
<div class="topbar-logo">🛡️</div>
|
||
<div>
|
||
<div class="topbar-title">Data4Autos Admin</div>
|
||
<div class="topbar-sub">Free Access Manager</div>
|
||
</div>
|
||
</div>
|
||
<div class="topbar-right">
|
||
<span class="topbar-badge" id="shops-count">0 shops</span>
|
||
<button class="logout-btn" onclick="doLogout()">Sign Out</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<div class="page-header">
|
||
<h2>Whitelisted Shops</h2>
|
||
<p>Shops listed here bypass subscription checks and get full app access.</p>
|
||
</div>
|
||
|
||
<!-- Add shop form -->
|
||
<div class="add-card">
|
||
<div class="add-card-header">
|
||
<div class="add-card-icon">➕</div>
|
||
<div>
|
||
<h3>Grant Free Access</h3>
|
||
<div class="sub">Add a shop domain and optional expiry date.</div>
|
||
</div>
|
||
</div>
|
||
<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:#b0b8c4;font-weight:400">(blank = permanent)</span></label>
|
||
<input type="date" id="inp-expires"/>
|
||
</div>
|
||
<div class="field">
|
||
<label>Note <span style="color:#b0b8c4;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-dash" id="add-err"></div>
|
||
</div>
|
||
|
||
<!-- Shops table -->
|
||
<div class="shops-card">
|
||
<div class="shops-card-header">
|
||
<h3>Active Whitelist</h3>
|
||
<span class="count-badge" id="shops-count-table">0 shops</span>
|
||
</div>
|
||
<div id="shops-table-wrap">
|
||
<div class="loading-row"><div class="spinner-dark"></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();
|
||
|
||
// ── PASSWORD TOGGLE ──────────────────────────────────────────────────────────
|
||
function togglePass() {
|
||
const inp = document.getElementById('inp-pass');
|
||
const btn = document.getElementById('eye-btn');
|
||
if (inp.type === 'password') { inp.type = 'text'; btn.textContent = '🙈'; }
|
||
else { inp.type = 'password'; btn.textContent = '👁'; }
|
||
}
|
||
|
||
// ── 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> Signing in…';
|
||
err.style.display = 'none';
|
||
|
||
const r = await api('POST', '/login', { username: user, password: pass });
|
||
btn.disabled = false;
|
||
btn.textContent = 'Sign In to Admin Portal';
|
||
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 || [];
|
||
const countLabel = shops.length + ' shop' + (shops.length !== 1 ? 's' : '');
|
||
document.getElementById('shops-count').textContent = countLabel;
|
||
document.getElementById('shops-count-table').textContent = countLabel;
|
||
|
||
if (shops.length === 0) {
|
||
wrap.innerHTML = '<div class="empty-state"><div class="icon">🏪</div><p>No shops added yet.</p><small>Use the form above to grant free access to a shop.</small></div>';
|
||
return;
|
||
}
|
||
|
||
const rows = shops.map(s => {
|
||
const statusTag = s.expired
|
||
? '<span class="tag tag-exp"><span class="tag-dot"></span>Expired</span>'
|
||
: s.permanent
|
||
? '<span class="tag tag-perm"><span class="tag-dot"></span>Permanent</span>'
|
||
: '<span class="tag tag-ok"><span class="tag-dot"></span>Active</span>';
|
||
|
||
const expiryDisplay = s.expiresAt
|
||
? '<span class="date-cell">' + new Date(s.expiresAt).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}) + '</span>'
|
||
: '<span class="date-cell" style="color:#b0b8c4">Never</span>';
|
||
|
||
const grantedDisplay = s.grantedAt
|
||
? '<span class="date-cell">' + 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
module.exports = router;
|