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 crypto = require('crypto');
const { isShopAllowed, addShop, removeShop, listShops } = require('../freeAccessStore');
const { listTokens } = require('../tokenStore');
const router = express.Router();
@ -79,6 +80,25 @@ router.delete('/api/shops/:shop', requireAuth, (req, res) => {
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) ────────────────────
router.get('/', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
@ -353,6 +373,13 @@ function adminHtml() {
cursor:pointer;font-family:inherit;transition:background 0.12s;
}
.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 .icon{font-size:38px;margin-bottom:12px;}
.empty-state p{font-size:14px;color:#94a3b8;font-weight:500;}
@ -436,50 +463,45 @@ function adminHtml() {
</div>
</div>
<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>
</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>
<h2>App Users</h2>
<p>All shops that have installed Data4Autos. Toggle free access directly from this list.</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">
<!-- Grant expiry modal -->
<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 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 style="font-size:16px;font-weight:800;color:#0f172a;margin-bottom:6px;">Grant Free Access</div>
<div id="modal-shop-name" style="font-size:13px;color:#64748b;margin-bottom:20px;font-family:monospace;"></div>
<div class="field" style="margin-bottom:14px;">
<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 class="field">
<div class="field" style="margin-bottom:20px;">
<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>
<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 -->
<!-- Users 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>
<h3>Installed Shops</h3>
<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 id="shops-table-wrap">
<div class="loading-row"><div class="spinner-dark"></div></div>
@ -549,90 +571,109 @@ async function doLogout() {
function showDashboard() {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
loadShops();
loadUsers();
}
// ── LOAD SHOPS ──────────────────────────────────────────────────────────────
async function loadShops() {
// ── ALL USERS DATA ───────────────────────────────────────────────────────────
let allUsers = [];
async function loadUsers() {
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; }
wrap.innerHTML = '<div class="loading-row"><div class="spinner-dark"></div></div>';
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 || [];
const countLabel = shops.length + ' shop' + (shops.length !== 1 ? 's' : '');
document.getElementById('shops-count').textContent = countLabel;
document.getElementById('shops-count-table').textContent = countLabel;
function filterUsers() {
const q = document.getElementById('search-inp').value.toLowerCase();
renderUsers(q ? allUsers.filter(u => u.shop.toLowerCase().includes(q)) : allUsers);
}
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>';
function renderUsers(users) {
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;
}
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>'
const rows = users.map(u => {
const lastSeen = u.savedAt
? new Date(u.savedAt).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})
: '—';
return \`<tr>
<td><div class="shop-name">\${escHtml(s.shop)}</div></td>
let statusTag, actionBtn;
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>\${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>
<td class="note-cell"><span class="note-text">\${escHtml(u.freeEntry?.note || '—')}</span></td>
<td style="text-align:right">\${actionBtn}</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>
<th>Shop Domain</th>
<th>Access Status</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';
// ── MODAL ────────────────────────────────────────────────────────────────────
let _modalShop = null;
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';
}
function openModal(shop) {
_modalShop = shop;
document.getElementById('modal-shop-name').textContent = shop;
document.getElementById('modal-expires').value = '';
document.getElementById('modal-note').value = '';
const m = document.getElementById('expiry-modal');
m.style.display = 'flex';
}
// ── REMOVE SHOP ───────────────────────────────────────────────────────────────
async function doRemoveShop(shop) {
if (!confirm('Remove free access for ' + shop + '?')) return;
function closeModal() {
document.getElementById('expiry-modal').style.display = 'none';
_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));
if (r.ok) loadShops();
else alert('Failed to remove shop');
if (r.ok) loadUsers();
else alert('Failed to revoke access');
}
// ── UTIL ──────────────────────────────────────────────────────────────────────