[object Object]

← back to Sublease Agentabrams

Role-based access: admin vs client. Backend gates /api/claims + /admin + /claims to admin role (403 for clients); /api/me exposes role; frontend hides Admin link for clients. Clients keep browse + claim

2aedc86f0a3bd7ddd3e4a129770e372484cbff8f · 2026-07-20 15:40:39 -0700 · Steve

Files touched

Diff

commit 2aedc86f0a3bd7ddd3e4a129770e372484cbff8f
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 20 15:40:39 2026 -0700

    Role-based access: admin vs client. Backend gates /api/claims + /admin + /claims to admin role (403 for clients); /api/me exposes role; frontend hides Admin link for clients. Clients keep browse + claim
---
 public/index.html |  3 ++-
 server.js         | 25 ++++++++++++++++++-------
 2 files changed, 20 insertions(+), 8 deletions(-)

diff --git a/public/index.html b/public/index.html
index 87494b7..e94edb1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -60,7 +60,7 @@
 <body>
 <header class="top"><div class="wrap">
   <div class="brand">Sublease<small>Marketplace</small></div>
-  <nav><a href="#map-sec">Map</a><a href="#listings">Listings</a><a href="#brokers">Brokers</a><a href="#sponsors">Sponsors</a><a href="/admin.html">Admin</a></nav>
+  <nav><a href="#map-sec">Map</a><a href="#listings">Listings</a><a href="#brokers">Brokers</a><a href="#sponsors">Sponsors</a><a href="/admin.html" id="adminLink" style="display:none">Admin</a></nav>
 </div></header>
 
 <div class="hero"><div class="wrap">
@@ -112,6 +112,7 @@ const $=s=>document.querySelector(s), fmtSF=n=>n?Number(n).toLocaleString()+' SF
 const RENDER_CAP=300; let ALL=[], SPON=[], subOnly=false;
 
 async function boot(){
+  fetch('/api/me').then(r=>r.json()).then(m=>{if(m.role==='admin')$('#adminLink').style.display='';}).catch(()=>{});
   const [st,sp,ls,mp]=await Promise.all([
     fetch('/api/stats').then(r=>r.json()),
     fetch('/api/sponsors').then(r=>r.json()),
diff --git a/server.js b/server.js
index 439dd3f..45e13c8 100644
--- a/server.js
+++ b/server.js
@@ -27,16 +27,25 @@ else {
 app.use(express.json({ limit: '64kb' }));
 const CLAIMS_FILE = path.join(__dirname, 'data', 'claims.jsonl');
 
-const CREDS = ['admin:DW2024!', 'Boomer:Sublease2024!']
-  .concat((process.env.BASIC_AUTH_EXTRA || '').split(',').map(s => s.trim()).filter(Boolean));
-const ACCEPTED = new Set(CREDS.map(c => 'Basic ' + Buffer.from(c).toString('base64')));
+// Credential → role. admin = full access; client = browse + claim, no admin surfaces.
+// Extra logins via BASIC_AUTH_EXTRA as "user:pass" (client) or "user:pass:admin".
+const ROLE_CREDS = new Map([['admin:DW2024!', 'admin'], ['Boomer:Sublease2024!', 'client']]);
+(process.env.BASIC_AUTH_EXTRA || '').split(',').map(s => s.trim()).filter(Boolean).forEach(c => {
+  const parts = c.split(':'); const role = parts.length >= 3 ? parts.pop() : 'client'; ROLE_CREDS.set(parts.join(':'), role);
+});
+const ACCEPTED = new Map([...ROLE_CREDS].map(([c, role]) => ['Basic ' + Buffer.from(c).toString('base64'), { role, user: c.split(':')[0] }]));
 app.get('/api/health', (_q, r) => r.json({ ok: true, mode: USE_SNAPSHOT ? 'snapshot' : 'db', at: new Date().toISOString() }));
 app.get('/healthz', (_q, r) => r.json({ ok: true }));
 app.use((req, res, next) => {
-  if (ACCEPTED.has(req.headers.authorization || '')) return next();
+  const hit = ACCEPTED.get(req.headers.authorization || '');
+  if (hit) { req.role = hit.role; req.user = hit.user; return next(); }
   res.set('WWW-Authenticate', 'Basic realm="Sublease Marketplace"');
   return res.status(401).send('Authentication required');
 });
+// role gate for admin-only surfaces
+const requireAdmin = (req, res, next) => req.role === 'admin' ? next()
+  : res.status(403).send('<h2 style="font:600 18px system-ui;color:#b3261e;padding:40px">Admin access only</h2><p style="font:14px system-ui;padding:0 40px"><a href="/">← back to marketplace</a></p>');
+app.get('/api/me', (req, res) => res.json({ user: req.user, role: req.role }));
 
 const LISTING_COLS = `l.id, l.listing_kind, l.is_sublease, l.title, l.address, l.city, l.state, l.zip, l.lat, l.lng,
   l.space_type, l.asset_class, l.size_sf, l.units, l.cap_rate, l.year_built, l.price_amount, l.price_unit, l.term,
@@ -164,8 +173,8 @@ app.post('/api/brokers/:id/claim', (req, res) => {
   } catch (e) { res.status(500).json({ error: String(e) }); }
 });
 
-// Admin: list submitted claims (newest first)
-app.get('/api/claims', (_q, res) => {
+// Admin: list submitted claims (newest first) — holds people's contact info, admin-only
+app.get('/api/claims', requireAdmin, (_q, res) => {
   try {
     const raw = fs.existsSync(CLAIMS_FILE) ? fs.readFileSync(CLAIMS_FILE, 'utf8') : '';
     const claims = raw.split('\n').filter(Boolean).map((l, i) => { try { return { i, ...JSON.parse(l) }; } catch { return null; } }).filter(Boolean).reverse();
@@ -190,6 +199,8 @@ app.get('/api/stats', async (_q, res) => {
 
 app.get('/broker/:slug', (_q, r) => r.sendFile(path.join(PUB, 'broker.html')));
 app.get('/brokers/:id', (_q, r) => r.sendFile(path.join(PUB, 'broker-profile.html')));
-app.get('/claims', (_q, r) => r.sendFile(path.join(PUB, 'claims.html')));
+// Admin-only pages — gate BEFORE express.static so a client can't fetch admin.html/claims.html directly
+app.get(['/claims', '/claims.html'], requireAdmin, (_q, r) => r.sendFile(path.join(PUB, 'claims.html')));
+app.get(['/admin', '/admin.html'], requireAdmin, (_q, r) => r.sendFile(path.join(PUB, 'admin.html')));
 app.use(express.static(PUB, { extensions: ['html'] }));
 app.listen(PORT, () => console.log(`sublease-agentabrams on ${PORT} (${USE_SNAPSHOT ? 'snapshot' : 'CRUnifiedDB'})`));

← cf6d429 Corrected state labels (national dataset: CA/TX/FL/NV/NY, wa  ·  back to Sublease Agentabrams  ·  Individual client accounts (file-based, snapshot-safe): scry 8301646 →