feat: admin panel shows all app users with inline grant/revoke free access

- New GET /d4a-admin/api/users endpoint merges tokens.json (all installed
  shops) with freeAccessStore to show every user and their access status
- Dashboard replaced with a full user list: shop domain, last auth date,
  free-access status badge, and Grant Free / Revoke buttons per row
- Grant opens a modal to set optional expiry date and note (no manual typing)
- Search filter to find shops quickly across large user lists
- Removed the manual text-input add form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MOHAN 2026-06-12 14:52:46 +05:30
parent 4be12dc9d4
commit 0cb6e260f7

View File

@ -2,6 +2,7 @@
const express = require('express'); const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const { isShopAllowed, addShop, removeShop, listShops } = require('../freeAccessStore'); const { isShopAllowed, addShop, removeShop, listShops } = require('../freeAccessStore');
const { listTokens } = require('../tokenStore');
const router = express.Router(); const router = express.Router();
@ -79,6 +80,25 @@ router.delete('/api/shops/:shop', requireAuth, (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// ── all installed users (from tokens.json) merged with free-access status ────
router.get('/api/users', requireAuth, (_req, res) => {
const tokens = listTokens();
const freeList = listShops();
const freeMap = Object.fromEntries(freeList.map(s => [s.shop, s]));
const users = Object.entries(tokens).map(([shop, t]) => {
const fa = freeMap[shop] || null;
return {
shop,
savedAt: t.savedAt || null,
hasToken: !!t.accessToken,
freeAccess: !!fa && !fa.expired,
freeEntry: fa || null,
};
});
users.sort((a, b) => (b.savedAt || '').localeCompare(a.savedAt || ''));
res.json({ users });
});
// ── serve admin HTML (always — login gate is client-side) ──────────────────── // ── serve admin HTML (always — login gate is client-side) ────────────────────
router.get('/', (_req, res) => { router.get('/', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Content-Type', 'text/html; charset=utf-8');
@ -353,6 +373,13 @@ function adminHtml() {
cursor:pointer;font-family:inherit;transition:background 0.12s; cursor:pointer;font-family:inherit;transition:background 0.12s;
} }
.btn-del:hover{background:#fee2e2;} .btn-del:hover{background:#fee2e2;}
.btn-grant{
background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;
color:#2563eb;padding:5px 12px;font-size:12px;font-weight:600;
cursor:pointer;font-family:inherit;transition:background 0.12s;
}
.btn-grant:hover{background:#dbeafe;}
.note-cell{max-width:180px;}
.empty-state{padding:56px 24px;text-align:center;} .empty-state{padding:56px 24px;text-align:center;}
.empty-state .icon{font-size:38px;margin-bottom:12px;} .empty-state .icon{font-size:38px;margin-bottom:12px;}
.empty-state p{font-size:14px;color:#94a3b8;font-weight:500;} .empty-state p{font-size:14px;color:#94a3b8;font-weight:500;}
@ -436,50 +463,45 @@ function adminHtml() {
</div> </div>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
<span class="topbar-badge" id="shops-count">0 shops</span> <span class="topbar-badge" id="shops-count">0 users</span>
<button class="logout-btn" onclick="doLogout()">Sign Out</button> <button class="logout-btn" onclick="doLogout()">Sign Out</button>
</div> </div>
</div> </div>
<div class="content"> <div class="content">
<div class="page-header"> <div class="page-header">
<h2>Whitelisted Shops</h2> <h2>App Users</h2>
<p>Shops listed here bypass subscription checks and get full app access.</p> <p>All shops that have installed Data4Autos. Toggle free access directly from this list.</p>
</div> </div>
<!-- Add shop form --> <!-- Grant expiry modal -->
<div class="add-card"> <div id="expiry-modal" style="display:none;position:fixed;inset:0;background:rgba(15,23,42,0.4);z-index:999;align-items:center;justify-content:center;">
<div class="add-card-header"> <div style="background:#fff;border-radius:16px;padding:32px 28px;width:100%;max-width:400px;box-shadow:0 20px 60px rgba(0,0,0,0.15);margin:0 16px;">
<div class="add-card-icon"></div> <div style="font-size:16px;font-weight:800;color:#0f172a;margin-bottom:6px;">Grant Free Access</div>
<div> <div id="modal-shop-name" style="font-size:13px;color:#64748b;margin-bottom:20px;font-family:monospace;"></div>
<h3>Grant Free Access</h3> <div class="field" style="margin-bottom:14px;">
<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> <label>Expiry Date <span style="color:#b0b8c4;font-weight:400">(blank = permanent)</span></label>
<input type="date" id="inp-expires"/> <input type="date" id="modal-expires" style="width:100%;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:9px;padding:10px 13px;font-size:13px;font-family:inherit;outline:none;color:#0f172a;"/>
</div> </div>
<div class="field"> <div class="field" style="margin-bottom:20px;">
<label>Note <span style="color:#b0b8c4;font-weight:400">(optional)</span></label> <label>Note <span style="color:#b0b8c4;font-weight:400">(optional)</span></label>
<input type="text" id="inp-note" placeholder="e.g. Partner shop"/> <input type="text" id="modal-note" placeholder="e.g. Partner shop" style="width:100%;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:9px;padding:10px 13px;font-size:13px;font-family:inherit;outline:none;color:#0f172a;"/>
</div>
<div style="display:flex;gap:10px;">
<button onclick="confirmGrant()" style="flex:1;background:#2563eb;border:none;border-radius:9px;color:#fff;padding:11px;font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;">Grant Access</button>
<button onclick="closeModal()" style="flex:1;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:9px;color:#374151;padding:11px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;">Cancel</button>
</div> </div>
<button class="btn-add" onclick="doAddShop()">Add Shop</button>
</div> </div>
<div class="success-msg" id="add-success"> Shop added successfully!</div>
<div class="err-msg-dash" id="add-err"></div>
</div> </div>
<!-- Shops table --> <!-- Users table -->
<div class="shops-card"> <div class="shops-card">
<div class="shops-card-header"> <div class="shops-card-header">
<h3>Active Whitelist</h3> <h3>Installed Shops</h3>
<span class="count-badge" id="shops-count-table">0 shops</span> <div style="display:flex;align-items:center;gap:10px;">
<input id="search-inp" placeholder="Search shops…" oninput="filterUsers()" style="background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:8px;padding:6px 12px;font-size:13px;font-family:inherit;outline:none;color:#0f172a;width:200px;"/>
<span class="count-badge" id="shops-count-table">0 shops</span>
</div>
</div> </div>
<div id="shops-table-wrap"> <div id="shops-table-wrap">
<div class="loading-row"><div class="spinner-dark"></div></div> <div class="loading-row"><div class="spinner-dark"></div></div>
@ -549,90 +571,109 @@ async function doLogout() {
function showDashboard() { function showDashboard() {
document.getElementById('login-screen').style.display = 'none'; document.getElementById('login-screen').style.display = 'none';
document.getElementById('dashboard').style.display = 'block'; document.getElementById('dashboard').style.display = 'block';
loadShops(); loadUsers();
} }
// ── LOAD SHOPS ────────────────────────────────────────────────────────────── // ── ALL USERS DATA ───────────────────────────────────────────────────────────
async function loadShops() { let allUsers = [];
async function loadUsers() {
const wrap = document.getElementById('shops-table-wrap'); const wrap = document.getElementById('shops-table-wrap');
const r = await api('GET', '/api/shops'); wrap.innerHTML = '<div class="loading-row"><div class="spinner-dark"></div></div>';
if (!r.ok) { wrap.innerHTML = '<div class="empty-state"><div>Failed to load shops</div></div>'; return; } const r = await api('GET', '/api/users');
if (!r.ok) { wrap.innerHTML = '<div class="empty-state"><p>Failed to load users.</p></div>'; return; }
allUsers = r.data.users || [];
document.getElementById('shops-count').textContent = allUsers.length + ' user' + (allUsers.length !== 1 ? 's' : '');
renderUsers(allUsers);
}
const shops = r.data.shops || []; function filterUsers() {
const countLabel = shops.length + ' shop' + (shops.length !== 1 ? 's' : ''); const q = document.getElementById('search-inp').value.toLowerCase();
document.getElementById('shops-count').textContent = countLabel; renderUsers(q ? allUsers.filter(u => u.shop.toLowerCase().includes(q)) : allUsers);
document.getElementById('shops-count-table').textContent = countLabel; }
if (shops.length === 0) { function renderUsers(users) {
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>'; const wrap = document.getElementById('shops-table-wrap');
document.getElementById('shops-count-table').textContent = users.length + ' shop' + (users.length !== 1 ? 's' : '');
if (users.length === 0) {
wrap.innerHTML = '<div class="empty-state"><div class="icon">🏪</div><p>No shops found.</p></div>';
return; return;
} }
const rows = shops.map(s => { const rows = users.map(u => {
const statusTag = s.expired const lastSeen = u.savedAt
? '<span class="tag tag-exp"><span class="tag-dot"></span>Expired</span>' ? new Date(u.savedAt).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})
: 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> let statusTag, actionBtn;
<td><div class="shop-name">\${escHtml(s.shop)}</div></td> if (u.freeAccess) {
const fa = u.freeEntry;
const expLabel = fa?.expiresAt
? 'Until ' + new Date(fa.expiresAt).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'})
: 'Permanent';
statusTag = \`<span class="tag tag-ok"><span class="tag-dot"></span>Free Access · \${expLabel}</span>\`;
actionBtn = \`<button class="btn-del" onclick="doRevoke('\${escHtml(u.shop)}')">Revoke</button>\`;
} else {
statusTag = '<span class="tag" style="background:#f8fafc;color:#94a3b8;border:1px solid #e2e8f0;"><span class="tag-dot" style="background:#cbd5e1;"></span>Subscribed</span>';
actionBtn = \`<button class="btn-grant" onclick="openModal('\${escHtml(u.shop)}')">Grant Free</button>\`;
}
return \`<tr data-shop="\${escHtml(u.shop)}">
<td>
<div class="shop-name">\${escHtml(u.shop)}</div>
<div class="shop-domain">Last auth: \${lastSeen}</div>
</td>
<td>\${statusTag}</td> <td>\${statusTag}</td>
<td>\${grantedDisplay}</td> <td class="note-cell"><span class="note-text">\${escHtml(u.freeEntry?.note || '—')}</span></td>
<td>\${expiryDisplay}</td> <td style="text-align:right">\${actionBtn}</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>\`; </tr>\`;
}).join(''); }).join('');
wrap.innerHTML = \`<table> wrap.innerHTML = \`<table>
<thead><tr> <thead><tr>
<th>Shop Domain</th><th>Status</th><th>Granted</th><th>Expires</th><th>Note</th><th></th> <th>Shop Domain</th>
<th>Access Status</th>
<th>Note</th>
<th></th>
</tr></thead> </tr></thead>
<tbody>\${rows}</tbody> <tbody>\${rows}</tbody>
</table>\`; </table>\`;
} }
// ── ADD SHOP ───────────────────────────────────────────────────────────────── // ── MODAL ────────────────────────────────────────────────────────────────────
async function doAddShop() { let _modalShop = null;
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; } function openModal(shop) {
_modalShop = shop;
const expiresAt = expires ? new Date(expires + 'T23:59:59.000Z').toISOString() : null; document.getElementById('modal-shop-name').textContent = shop;
const r = await api('POST', '/api/shops', { shop, expiresAt, note }); document.getElementById('modal-expires').value = '';
if (r.ok) { document.getElementById('modal-note').value = '';
ok.style.display = 'block'; const m = document.getElementById('expiry-modal');
document.getElementById('inp-shop').value = ''; m.style.display = 'flex';
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 ─────────────────────────────────────────────────────────────── function closeModal() {
async function doRemoveShop(shop) { document.getElementById('expiry-modal').style.display = 'none';
if (!confirm('Remove free access for ' + shop + '?')) return; _modalShop = null;
}
async function confirmGrant() {
if (!_modalShop) return;
const expires = document.getElementById('modal-expires').value || null;
const note = document.getElementById('modal-note').value.trim();
const expiresAt = expires ? new Date(expires + 'T23:59:59.000Z').toISOString() : null;
const r = await api('POST', '/api/shops', { shop: _modalShop, expiresAt, note });
if (r.ok) { closeModal(); loadUsers(); }
else alert('Failed to grant access: ' + (r.data.error || 'Unknown error'));
}
// ── REVOKE ───────────────────────────────────────────────────────────────────
async function doRevoke(shop) {
if (!confirm('Revoke free access for ' + shop + '? They will need an active subscription.')) return;
const r = await api('DELETE', '/api/shops/' + encodeURIComponent(shop)); const r = await api('DELETE', '/api/shops/' + encodeURIComponent(shop));
if (r.ok) loadShops(); if (r.ok) loadUsers();
else alert('Failed to remove shop'); else alert('Failed to revoke access');
} }
// ── UTIL ────────────────────────────────────────────────────────────────────── // ── UTIL ──────────────────────────────────────────────────────────────────────