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:
parent
4be12dc9d4
commit
0cb6e260f7
@ -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 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user