← back to Commercialrealestate
CRCP: replace whole-site Basic-auth popup with single real-user login
05eae71570ba106be1b081c0a2f5db9c36af6396 · 2026-08-20 11:01:32 -0700 · Steve
Frank (and anyone) is now just a user on the system: the browser Basic-auth
dialog is gone. The app account (/auth/login) is the sole gate via a session-
required middleware that reuses userOf(req). No session -> page navigations 302
to a styled /login.html; data/API calls 401 (fails closed, nothing leaks
public). Adds public/login.html (self-contained, open-redirect-guarded next).
One login end to end: frank / arcstone1998.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A public/login.htmlM scripts/serve.js
Diff
commit 05eae71570ba106be1b081c0a2f5db9c36af6396
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 20 11:01:32 2026 -0700
CRCP: replace whole-site Basic-auth popup with single real-user login
Frank (and anyone) is now just a user on the system: the browser Basic-auth
dialog is gone. The app account (/auth/login) is the sole gate via a session-
required middleware that reuses userOf(req). No session -> page navigations 302
to a styled /login.html; data/API calls 401 (fails closed, nothing leaks
public). Adds public/login.html (self-contained, open-redirect-guarded next).
One login end to end: frank / arcstone1998.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/login.html | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
scripts/serve.js | 65 ++++++++++++++------------------------------
2 files changed, 101 insertions(+), 45 deletions(-)
diff --git a/public/login.html b/public/login.html
new file mode 100644
index 0000000..1643576
--- /dev/null
+++ b/public/login.html
@@ -0,0 +1,81 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex,nofollow">
+<title>Sign in · CRCP</title>
+<style>
+ :root{ --bg:#0b0e13; --panel:#131822; --line:#232c3b; --ink:#eef2f8; --mut:#8b97a8;
+ --gold:#d9b25f; --blue:#3b7dff; --err:#ff6b6b; }
+ *{ box-sizing:border-box; }
+ html,body{ height:100%; }
+ body{ margin:0; background:radial-gradient(1200px 800px at 70% -10%, #1a2333 0%, var(--bg) 55%);
+ color:var(--ink); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+ display:grid; place-items:center; }
+ .card{ width:min(92vw,380px); background:var(--panel); border:1px solid var(--line);
+ border-radius:16px; padding:30px 28px 26px; box-shadow:0 24px 60px rgba(0,0,0,.5); }
+ .brand{ display:flex; align-items:center; gap:10px; margin-bottom:4px; }
+ .brand .dot{ width:26px; height:26px; border-radius:7px; background:linear-gradient(135deg,var(--gold),#a67c2e);
+ display:grid; place-items:center; font-weight:800; color:#1a130a; }
+ .brand b{ font-size:17px; letter-spacing:.3px; }
+ h1{ font-size:20px; margin:16px 0 2px; }
+ p.sub{ margin:0 0 20px; color:var(--mut); font-size:13px; }
+ label{ display:block; font-size:12px; text-transform:uppercase; letter-spacing:.5px; color:var(--mut); margin:14px 0 6px; }
+ input{ width:100%; padding:11px 12px; border-radius:10px; border:1px solid var(--line);
+ background:#0e131c; color:var(--ink); font-size:15px; outline:none; }
+ input:focus{ border-color:var(--blue); box-shadow:0 0 0 3px rgba(59,125,255,.18); }
+ button{ width:100%; margin-top:22px; padding:12px; border:0; border-radius:999px; cursor:pointer;
+ background:var(--blue); color:#fff; font-size:15px; font-weight:600; }
+ button:disabled{ opacity:.6; cursor:default; }
+ .err{ display:none; margin-top:14px; color:var(--err); font-size:13px; }
+ .foot{ margin-top:18px; color:var(--mut); font-size:12px; text-align:center; }
+</style>
+</head>
+<body>
+ <form class="card" id="f" autocomplete="on">
+ <div class="brand"><span class="dot">C</span><b>CRCP</b></div>
+ <h1>Sign in</h1>
+ <p class="sub">crcp.agentabrams.com</p>
+ <label for="u">Username</label>
+ <input id="u" name="username" autocapitalize="none" autocorrect="off" spellcheck="false">
+ <label for="p">Password</label>
+ <input id="p" name="password" type="password">
+ <button id="b" type="submit">Sign In</button>
+ <div class="err" id="e"></div>
+ <div class="foot">Authorized users only.</div>
+ </form>
+<script>
+ // Open-redirect guard: only honor a same-origin, single-slash path; otherwise land on the root.
+ function safeNext(){
+ try{
+ var n = new URLSearchParams(location.search).get('next') || '/';
+ if(!n.startsWith('/') || n.startsWith('//')) return '/';
+ return n;
+ }catch(_){ return '/'; }
+ }
+ var f=document.getElementById('f'), b=document.getElementById('b'), e=document.getElementById('e');
+ document.getElementById('u').focus();
+ f.addEventListener('submit', async function(ev){
+ ev.preventDefault();
+ e.style.display='none';
+ b.disabled=true; b.textContent='Signing in…';
+ try{
+ var r = await fetch('/auth/login', {
+ method:'POST',
+ headers:{ 'Content-Type':'application/json' },
+ body: JSON.stringify({ username: document.getElementById('u').value, password: document.getElementById('p').value })
+ });
+ var j = await r.json().catch(function(){ return {}; });
+ if(r.ok && j.ok){ location.href = safeNext(); return; }
+ e.textContent = j.error || 'Sign-in failed.';
+ e.style.display='block';
+ }catch(_){
+ e.textContent = 'Network error — try again.';
+ e.style.display='block';
+ }
+ b.disabled=false; b.textContent='Sign In';
+ });
+</script>
+</body>
+</html>
diff --git a/scripts/serve.js b/scripts/serve.js
index e88dea6..bd88898 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -36,56 +36,31 @@ const PORT = process.env.PORT || 9911;
const app = express();
app.use(express.json({ limit: '256kb' }));
-// ── Whole-site Basic Auth gate (Steve 2026-07-08) ───────────────────────────────────
-// crcp.agentabrams.com was fully public; now every page, clean-URL route, /data static dir,
-// and /api endpoint sits behind a username/password because this middleware is mounted BEFORE
-// all routes. Credentials are env-overridable (CRCP_USER/CRCP_PASS); default is the house DW login.
-// /healthz stays OPEN so the deploy smoke-test + uptime canaries get a 200 without credentials.
-const AUTH_USER = process.env.CRCP_USER || 'admin';
-const AUTH_PASS = process.env.CRCP_PASS || 'DW2024!';
-// Session-cookie companion to the Basic-auth gate (2026-08-18): when a page is opened with
-// credentials in the URL (http://admin:pass@host/mls.html), the browser sends the Authorization
-// header ONLY on the top-level navigation — it does NOT forward URL creds to the page's fetch()
-// subresource calls, and the browser's HTTP-auth cache is only primed by a real 401 challenge
-// round-trip (which preemptive URL creds skip). Result: the HTML loaded 200 but every
-// fetch('/data/ranked.json') went out unauthenticated -> 401 -> "failed to load" / empty grid.
-// Fix: on a successful Basic auth, set an httpOnly cookie the same-origin fetches carry
-// automatically, and accept that cookie as an alternative to the header. Token is a stable
-// non-reversible hash of the creds (never the plaintext password), so it survives restarts.
-const crypto = require('crypto');
-// Multi-credential gate (2026-08-19): accept the primary CRCP_USER/CRCP_PASS PLUS an optional
-// comma-separated CRCP_EXTRA_USERS list ("steve:jef215,foo:bar") so more than one person can log
-// into the same instance. Backward-compatible: with no extras this is exactly the old single cred.
-const GATE_CREDS = [[AUTH_USER, AUTH_PASS]];
-(process.env.CRCP_EXTRA_USERS || '').split(',').map(s => s.trim()).filter(Boolean).forEach(pair => {
- const i = pair.indexOf(':'); if (i > 0) GATE_CREDS.push([pair.slice(0, i), pair.slice(i + 1)]);
-});
-const tokenOf = (u, p) => crypto.createHash('sha256').update(u + ':' + p).digest('hex');
-const AUTH_TOKEN = tokenOf(AUTH_USER, AUTH_PASS); // primary cred's token (kept for compatibility)
-const AUTH_TOKENS = new Set(GATE_CREDS.map(([u, p]) => tokenOf(u, p))); // every accepted cred's cookie token
+// ── Whole-site auth gate (Steve 2026-08-20): ONE real-user login, no Basic-auth popup ────────
+// Frank (and anyone else) is now just a USER ON THE SYSTEM: the browser Basic-auth dialog is gone,
+// and the app account (crcp-accounts /auth/login, styled /login.html) is the SOLE gate. Every page,
+// the /data static dir, and every /api endpoint requires a valid crcp_sid session. Unauthenticated
+// page NAVIGATIONS are 302-redirected to the styled /login.html?next=…; unauthenticated data/API
+// calls get a 401 — so nothing leaks public and the front-end can prompt sign-in cleanly. This
+// FAILS CLOSED: if the accounts module didn't mount (userOf missing), everyone is bounced to login.
+// Open WITHOUT a session: /healthz (deploy smoke-test + uptime canaries), the login page + its
+// /auth/* routes (login/register/logout/magic-link), /api/me (front-end login-state probe), favicon.
+const acct = require('./crcp-accounts');
+const GATE_OPEN = p =>
+ p === '/healthz' || p === '/login.html' || p === '/api/me' ||
+ p === '/favicon.ico' || p.startsWith('/auth/');
app.get('/healthz', (req, res) => res.type('text').send('ok'));
app.use((req, res, next) => {
- // 1) session cookie set after a prior successful Basic auth (fixes URL-creds subresource 401s)
- const cookies = req.headers.cookie || '';
- const ck = cookies.split(/;\s*/).find(c => c.startsWith('crcp_auth='));
- if (ck && AUTH_TOKENS.has(ck.slice('crcp_auth='.length))) return next();
- // 2) the standard Authorization header (Basic-auth dialog, curl, or the navigation request)
- const hdr = req.headers.authorization || '';
- const [scheme, encoded] = hdr.split(' ');
- if (scheme === 'Basic' && encoded) {
- const [u, ...rest] = Buffer.from(encoded, 'base64').toString().split(':');
- const p = rest.join(':');
- if (GATE_CREDS.some(([cu, cp]) => cu === u && cp === p)) {
- res.cookie('crcp_auth', tokenOf(u, p), { httpOnly: true, sameSite: 'Lax', path: '/', maxAge: 30 * 24 * 3600 * 1000 });
- return next();
- }
- }
- res.set('WWW-Authenticate', 'Basic realm="CRCP (crcp.agentabrams.com)", charset="UTF-8"');
- return res.status(401).send('Authentication required');
+ if (GATE_OPEN(req.path)) return next();
+ const u = (typeof acct.userOf === 'function') ? acct.userOf(req) : null; // valid crcp_sid -> real user
+ if (u) return next();
+ const wantsHtml = req.method === 'GET' && (req.headers.accept || '').includes('text/html');
+ if (wantsHtml) return res.redirect(302, '/login.html?next=' + encodeURIComponent(req.originalUrl || '/'));
+ return res.status(401).json({ error: 'sign in' });
});
// ── P1 subscription layer: accounts + saved searches + watchlist (docs/TOOL-SPEC.md) ──
-try { const acct = require('./crcp-accounts'); acct(app, ROOT); require('./crcp-billing')(app, ROOT, acct.userOf); require('./crcp-leads')(app, ROOT, acct.userOf); require('./crcp-export')(app, ROOT, acct.userOf); require('./crcp-notes')(app, ROOT, acct.userOf); } catch (e) { console.error('[crcp-accounts/billing/leads/export/notes] mount failed:', e.message); }
+try { acct(app, ROOT); require('./crcp-billing')(app, ROOT, acct.userOf); require('./crcp-leads')(app, ROOT, acct.userOf); require('./crcp-export')(app, ROOT, acct.userOf); require('./crcp-notes')(app, ROOT, acct.userOf); } catch (e) { console.error('[crcp-accounts/billing/leads/export/notes] mount failed:', e.message); }
// ── Agent-contact CRM (durable, local JSON) ─────────────────────────────────────────
// One record per listing id: editable {name,phone,email}, a `contacted_at` stamp, and an
← 3fb3f6a nav-agent: sync v1.3 (host left-sidebar collapse on load)
·
back to Commercialrealestate
·
CRCP: top-right signed-in user badge (person icon + name + s 9b4f89b →