[object Object]

← back to Commercialrealestate

CRCP full login: admin+standard roles, per-user private notes, admin console (TK-10301)

1934c92e27f08fe43c5b5178e805df44ec8a4657 · 2026-08-06 11:55:42 -0700 · Steve Abrams

- crcp-accounts.js: add perm=admin|standard axis (separate from job role); seed
  admin account (env-overridable CRCP_ADMIN_USER/PASS); backfill Frank + legacy
  password accounts to perm=standard; admin user-mgmt endpoints (list/create/
  reset-pw/set-perm/delete) with last-admin + self-delete guards.
- crcp-notes.js (new): per-user private per-listing notes, auto-migrates the old
  global condo-notes.json under Frank; admin ?user= override. Hybrid CRM =
  private notes + shared contact book.
- serve.js: mount crcp-notes; remove the old global notes block.
- admin.html (new): admin console — user table with created date+time chip, add/
  reset-pw/promote/delete, per-user notes viewer, sign-in gate.
- deals-flow.html: Admin nav link for admin users.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 1934c92e27f08fe43c5b5178e805df44ec8a4657
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 6 11:55:42 2026 -0700

    CRCP full login: admin+standard roles, per-user private notes, admin console (TK-10301)
    
    - crcp-accounts.js: add perm=admin|standard axis (separate from job role); seed
      admin account (env-overridable CRCP_ADMIN_USER/PASS); backfill Frank + legacy
      password accounts to perm=standard; admin user-mgmt endpoints (list/create/
      reset-pw/set-perm/delete) with last-admin + self-delete guards.
    - crcp-notes.js (new): per-user private per-listing notes, auto-migrates the old
      global condo-notes.json under Frank; admin ?user= override. Hybrid CRM =
      private notes + shared contact book.
    - serve.js: mount crcp-notes; remove the old global notes block.
    - admin.html (new): admin console — user table with created date+time chip, add/
      reset-pw/promote/delete, per-user notes viewer, sign-in gate.
    - deals-flow.html: Admin nav link for admin users.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/admin.html        | 223 +++++++++++++++++++++++++++++++++++++++++++++++
 public/deals-flow.html   |   2 +-
 scripts/crcp-accounts.js | 154 +++++++++++++++++++++++++++++---
 scripts/crcp-notes.js    |  83 ++++++++++++++++++
 scripts/serve.js         |  26 ++----
 5 files changed, 454 insertions(+), 34 deletions(-)

diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..b3d092c
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,223 @@
+<!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>CRCP · Admin — Users</title>
+<style>
+:root{--bg:#0e1116;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--violet:#bc8cff;--bad:#f85149}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,Helvetica,Arial,sans-serif;font-size:13px}
+a{color:var(--blue);text-decoration:none}a:hover{text-decoration:underline}
+header{display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:10}
+header h1{font-size:15px;margin:0;letter-spacing:.3px}
+header .who{margin-left:auto;font-size:12px;color:var(--mut)}
+.wrap{max-width:1180px;margin:0 auto;padding:18px}
+.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin-bottom:18px}
+.card h2{font-size:13px;margin:0 0 12px;color:var(--mut);text-transform:uppercase;letter-spacing:.6px;font-weight:600}
+label{display:block;font-size:11px;color:var(--mut);margin:8px 0 3px}
+input,select{background:#0b0e13;border:1px solid var(--line);border-radius:7px;color:var(--ink);font-size:13px;padding:7px 9px;width:100%}
+input:focus,select:focus{outline:none;border-color:var(--blue)}
+.row{display:flex;flex-wrap:wrap;gap:12px}
+.row>div{flex:1 1 150px;min-width:130px}
+.btn{background:#1f6feb;border:1px solid #1f6feb;border-radius:7px;color:#fff;cursor:pointer;font-size:12.5px;padding:8px 14px;font-weight:600}
+.btn:hover{filter:brightness(1.1)}
+.btn.ghost{background:transparent;border-color:var(--line);color:var(--ink);font-weight:500}
+.btn.danger{background:transparent;border-color:#5a2327;color:var(--bad)}
+.btn.sm{padding:4px 9px;font-size:11.5px}
+table{width:100%;border-collapse:collapse;font-size:12.5px}
+th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle}
+th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.4px;cursor:pointer;user-select:none}
+tr:hover td{background:#12171f}
+.pill{display:inline-block;font-size:10.5px;padding:2px 8px;border-radius:20px;border:1px solid var(--line)}
+.pill.admin{color:var(--gold);border-color:#4a3a12;background:#1c1607}
+.pill.standard{color:var(--blue);border-color:#173049;background:#0b1622}
+.pill.magic{color:var(--violet);border-color:#33244a;background:#150e22}
+.when{color:var(--mut);font-size:11px}
+.acts{display:flex;gap:6px;flex-wrap:wrap}
+.err{color:var(--bad);font-size:12px;min-height:16px;margin-top:8px}
+.ok{color:var(--acc);font-size:12px;min-height:16px;margin-top:8px}
+.gate{max-width:340px;margin:60px auto}
+.mut{color:var(--mut)}
+.count{color:var(--mut);font-size:11.5px;font-weight:400;text-transform:none;letter-spacing:0}
+.modal{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:50;padding:16px}
+.modal.on{display:flex}
+.modal .box{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px;max-width:560px;width:100%;max-height:80vh;overflow:auto}
+.modal h3{margin:0 0 10px;font-size:14px}
+.nlist .n{border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-top:8px;background:#0b0e13}
+.nlist .n .id{color:var(--blue);font-size:11px}
+.nlist .n .tx{white-space:pre-wrap;margin-top:4px;font-size:12px}
+</style>
+</head>
+<body>
+<header>
+  <h1>CRCP <span class="mut">· Admin</span></h1>
+  <a href="/deals-flow.html">← Deals</a>
+  <span class="who" id="who"></span>
+</header>
+
+<!-- sign-in gate (shown when the viewer is not an admin) -->
+<div class="wrap" id="gateWrap" style="display:none">
+  <div class="card gate">
+    <h2>Admin sign-in</h2>
+    <div id="gateMsg" class="mut" style="margin-bottom:8px"></div>
+    <label for="gu">Username</label><input id="gu" autocomplete="username" placeholder="admin">
+    <label for="gp">Password</label><input id="gp" type="password" autocomplete="current-password" placeholder="••••••••">
+    <div style="margin-top:12px"><button class="btn" id="gBtn">Sign in</button></div>
+    <div class="err" id="gErr"></div>
+  </div>
+</div>
+
+<!-- admin console (shown to admins) -->
+<div class="wrap" id="adminWrap" style="display:none">
+
+  <div class="card">
+    <h2>Add user</h2>
+    <div class="row">
+      <div><label for="nu">Username</label><input id="nu" placeholder="e.g. dana" autocomplete="off"></div>
+      <div><label for="nn">Display name</label><input id="nn" placeholder="Dana Lee" autocomplete="off"></div>
+      <div><label for="np">Password</label><input id="np" type="text" placeholder="min 6 chars" autocomplete="off"></div>
+      <div><label for="nr">Job role</label>
+        <select id="nr">
+          <option value="loan_officer">Loan officer</option>
+          <option value="listing_agent">Listing agent</option>
+          <option value="buyers_agent">Buyer's agent</option>
+          <option value="investor">Investor</option>
+          <option value="appraiser">Appraiser</option>
+          <option value="other">Other</option>
+        </select>
+      </div>
+      <div><label for="nperm">Permission</label>
+        <select id="nperm"><option value="standard">Standard</option><option value="admin">Admin</option></select>
+      </div>
+      <div style="display:flex;align-items:flex-end"><button class="btn" id="createBtn" style="width:100%">Create</button></div>
+    </div>
+    <div class="err" id="cErr"></div><div class="ok" id="cOk"></div>
+  </div>
+
+  <div class="card">
+    <h2>Users <span class="count" id="uCount"></span></h2>
+    <div style="margin-bottom:10px"><input id="search" placeholder="Search username / name / role…" style="max-width:320px"></div>
+    <table id="tbl">
+      <thead><tr>
+        <th data-k="username">User</th>
+        <th data-k="perm">Permission</th>
+        <th data-k="role">Job role</th>
+        <th data-k="tier">Tier</th>
+        <th data-k="created">Created</th>
+        <th data-k="last_login">Last login</th>
+        <th>Auth</th>
+        <th>Actions</th>
+      </tr></thead>
+      <tbody id="rows"></tbody>
+    </table>
+  </div>
+</div>
+
+<div class="modal" id="notesModal"><div class="box">
+  <h3 id="nmTitle">Notes</h3>
+  <div class="nlist" id="nmBody"></div>
+  <div style="margin-top:12px;text-align:right"><button class="btn ghost" onclick="document.getElementById('notesModal').classList.remove('on')">Close</button></div>
+</div></div>
+
+<script>
+const $=s=>document.querySelector(s);
+const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+const api=(u,o)=>fetch(u,Object.assign({headers:{'Content-Type':'application/json'}},o)).then(async r=>{const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||('HTTP '+r.status));return j;});
+// Standing rule: admin cards show created date AND time, in the admin's local zone; full ISO in title.
+const fmtWhen=iso=>{if(!iso)return '<span class="when">—</span>';const d=new Date(iso);const s=d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});return `<span class="when" title="${esc(iso)}">🕓 ${esc(s)}</span>`;};
+const ROLE_LBL={loan_officer:'Loan officer',listing_agent:'Listing agent',buyers_agent:"Buyer's agent",investor:'Investor',appraiser:'Appraiser',other:'Other'};
+
+let ME=null, USERS=[], SORT={k:'created',dir:-1};
+
+async function boot(){
+  try{ ME=await api('/api/me'); }catch(e){ ME=null; }
+  if(ME&&ME.email) $('#who').textContent=(ME.name||ME.username||ME.email)+(ME.perm==='admin'?' · admin':'');
+  if(ME&&ME.perm==='admin'){ $('#adminWrap').style.display=''; $('#gateWrap').style.display='none'; loadUsers(); }
+  else { $('#gateWrap').style.display=''; $('#adminWrap').style.display='none'; $('#gateMsg').textContent = (ME&&ME.email)?('Signed in as '+(ME.username||ME.email)+' — this account is not an admin.'):'Sign in with an admin account to manage users.'; }
+}
+
+async function loadUsers(){
+  try{ const j=await api('/api/admin/users'); USERS=j.users||[]; $('#uCount').textContent=`(${j.count} total · ${j.admins} admin)`; render(); }
+  catch(e){ if(String(e.message).match(/sign in|admin only/i)){ ME=null; boot(); } }
+}
+
+function render(){
+  const q=$('#search').value.trim().toLowerCase();
+  let list=USERS.filter(u=>!q||[u.username,u.name,u.role].some(v=>String(v||'').toLowerCase().includes(q)));
+  list.sort((a,b)=>{const k=SORT.k;let x=a[k],y=b[k];x=x==null?'':x;y=y==null?'':y;return (String(x).localeCompare(String(y)))*SORT.dir;});
+  $('#rows').innerHTML=list.map(u=>{
+    const me = ME && (String(ME.username||ME.email).toLowerCase()===String(u.username).toLowerCase());
+    const permPill=`<span class="pill ${u.perm}">${u.perm}</span>`;
+    const authPill=u.has_password?'<span class="pill">password</span>':'<span class="pill magic">magic-link</span>';
+    const toggleLbl=u.perm==='admin'?'→ standard':'→ admin';
+    return `<tr>
+      <td><b>${esc(u.username)}</b>${u.name&&u.name!==u.username?`<br><span class="mut">${esc(u.name)}</span>`:''}${me?' <span class="mut">(you)</span>':''}${u.seeded?' <span class="mut" title="seeded account">·seed</span>':''}</td>
+      <td>${permPill}</td>
+      <td>${esc(ROLE_LBL[u.role]||u.role||'—')}</td>
+      <td>${esc(u.tier||'free')}</td>
+      <td>${fmtWhen(u.created)}</td>
+      <td>${fmtWhen(u.last_login)}</td>
+      <td>${authPill}</td>
+      <td><div class="acts">
+        <button class="btn ghost sm" data-act="notes" data-k="${esc(u.key)}" data-u="${esc(u.username)}">notes</button>
+        <button class="btn ghost sm" data-act="pw" data-k="${esc(u.key)}" data-u="${esc(u.username)}">reset pw</button>
+        <button class="btn ghost sm" data-act="perm" data-k="${esc(u.key)}" data-u="${esc(u.username)}" data-perm="${u.perm==='admin'?'standard':'admin'}">${toggleLbl}</button>
+        <button class="btn danger sm" data-act="del" data-k="${esc(u.key)}" data-u="${esc(u.username)}" ${me?'disabled':''}>delete</button>
+      </div></td>
+    </tr>`;
+  }).join('') || '<tr><td colspan="8" class="mut">No users match.</td></tr>';
+}
+
+// header sort
+document.querySelectorAll('#tbl th[data-k]').forEach(th=>th.onclick=()=>{const k=th.dataset.k;SORT.dir=(SORT.k===k)?-SORT.dir:1;SORT.k=k;render();});
+$('#search').addEventListener('input',render);
+
+// row actions (event delegation)
+$('#rows').addEventListener('click',async e=>{
+  const b=e.target.closest('button[data-act]'); if(!b)return;
+  const key=b.dataset.k, uname=b.dataset.u, act=b.dataset.act;
+  try{
+    if(act==='pw'){ const pw=prompt(`New password for ${uname} (min 6 chars):`); if(!pw)return; await api(`/api/admin/users/${encodeURIComponent(key)}/password`,{method:'POST',body:JSON.stringify({password:pw})}); alert(`Password reset for ${uname}.`); }
+    else if(act==='perm'){ const perm=b.dataset.perm; if(!confirm(`Set ${uname} to ${perm}?`))return; await api(`/api/admin/users/${encodeURIComponent(key)}/perm`,{method:'POST',body:JSON.stringify({perm})}); loadUsers(); }
+    else if(act==='del'){ if(!confirm(`Delete ${uname}? This removes their saved searches, watchlist, and revokes their sessions. Their private notes remain in storage.`))return; await api(`/api/admin/users/${encodeURIComponent(key)}`,{method:'DELETE'}); loadUsers(); }
+    else if(act==='notes'){ openNotes(key,uname); }
+  }catch(err){ alert(err.message); }
+});
+
+async function openNotes(key,uname){
+  $('#nmTitle').textContent=`Private notes — ${uname}`;
+  $('#nmBody').innerHTML='<span class="mut">Loading…</span>';
+  $('#notesModal').classList.add('on');
+  try{
+    const j=await api('/api/condo-notes?user='+encodeURIComponent(key));
+    const ns=j.notes||{}; const ks=Object.keys(ns);
+    $('#nmBody').innerHTML = ks.length ? ks.map(id=>`<div class="n"><div class="id">${esc(id)} · <span class="when">${esc(new Date(ns[id].updated_at).toLocaleString())}</span></div><div class="tx">${esc(ns[id].note)}</div></div>`).join('')
+      : '<span class="mut">No notes for this user.</span>';
+  }catch(e){ $('#nmBody').innerHTML='<span class="err">'+esc(e.message)+'</span>'; }
+}
+
+// create user
+$('#createBtn').onclick=async()=>{
+  $('#cErr').textContent='';$('#cOk').textContent='';
+  const body={username:$('#nu').value.trim(),name:$('#nn').value.trim(),password:$('#np').value,role:$('#nr').value,perm:$('#nperm').value};
+  if(!body.username||!body.password){$('#cErr').textContent='Username and password required.';return;}
+  try{ const j=await api('/api/admin/users',{method:'POST',body:JSON.stringify(body)}); $('#cOk').textContent=`Created ${j.user.username} (${j.user.perm}).`; $('#nu').value='';$('#nn').value='';$('#np').value=''; loadUsers(); }
+  catch(e){ $('#cErr').textContent=e.message; }
+};
+
+// gate sign-in
+$('#gBtn').onclick=async()=>{
+  $('#gErr').textContent='…';
+  const username=$('#gu').value.trim(),password=$('#gp').value;
+  if(!username||!password){$('#gErr').textContent='Enter username and password.';return;}
+  try{ await api('/auth/login',{method:'POST',body:JSON.stringify({username,password})}); location.reload(); }
+  catch(e){ $('#gErr').textContent=e.message||'Sign-in failed.'; }
+};
+$('#gp').addEventListener('keydown',e=>{if(e.key==='Enter')$('#gBtn').click();});
+
+boot();
+</script>
+</body>
+</html>
diff --git a/public/deals-flow.html b/public/deals-flow.html
index 6ec4641..12d2237 100644
--- a/public/deals-flow.html
+++ b/public/deals-flow.html
@@ -253,7 +253,7 @@ const ROLE_META={
   other:{label:'Member',tip:'',qa:[['📊 Deal flow','/deals-flow.html'],['🏷️ Listings','/mls.html']]}
 };
 const ROLE_OPTS=[['loan_officer','Loan Officer','mortgage / lending'],['listing_agent','Listing Agent','brokerage — listing'],['buyers_agent','Buyer’s Agent','brokerage — buy-side'],['investor','Investor','principal / capital'],['appraiser','Appraiser','valuation'],['other','Something else','']];
-function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);const rl=ME.role&&ROLE_META[ME.role]?ROLE_META[ME.role].label:'';el.innerHTML=`<b style="color:#fff">${who}</b>${rl?` · <span style="color:var(--acc)">${esc(rl)}</span>`:''} · <a style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('a').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=openLogin;}}
+function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);const rl=ME.role&&ROLE_META[ME.role]?ROLE_META[ME.role].label:'';el.innerHTML=`<b style="color:#fff">${who}</b>${rl?` · <span style="color:var(--acc)">${esc(rl)}</span>`:''}${ME.perm==='admin'?` · <a href="/admin.html" style="color:var(--gold)">Admin</a>`:''} · <a id="signOutLink" style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('#signOutLink').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=openLogin;}}
 function renderPersona(){const p=$('#persona');if(!(ME&&ME.email&&ME.role)){p.style.display='none';return;}const m=ROLE_META[ME.role]||ROLE_META.other;const who=esc(ME.name||ME.username||'there');p.style.display='';p.innerHTML=`<h2>Welcome back, ${who}</h2><span class="role">${esc(m.label)}</span><div class="qa">${m.qa.map(([t,h])=>`<a href="${h}">${t}</a>`).join('')}</div>${m.tip?`<p class="tip">${esc(m.tip)}</p>`:''}<span class="edit" id="editRole">change role</span>`;const er=$('#editRole');if(er)er.onclick=openRole;}
 async function loadMe(){try{ME=await api('/api/me');}catch(e){ME=null;}renderAuth();renderPersona();$('#savedSec').style.display=(ME&&ME.email)?'':'none';if(ME&&ME.email){loadSaved();loadWatch();if(!ME.role)openRole();}}
 // ── sign-in modal (username + password) ──
diff --git a/scripts/crcp-accounts.js b/scripts/crcp-accounts.js
index 070b3e2..8bd4ae1 100644
--- a/scripts/crcp-accounts.js
+++ b/scripts/crcp-accounts.js
@@ -35,9 +35,21 @@ module.exports = function mountAccounts(app, ROOT) {
     if (!s || s.exp < now()) return null;
     const u = db.users[s.email] || {};
     // s.email is the generic user KEY: a real email for magic-link users, a username for password
-    // users. We surface both plus the personalization profile (role/industry/name).
+    // users. We surface both plus the personalization profile (role/industry/name) AND the
+    // permission tier `perm` (admin|standard). `role` is the JOB type (loan_officer, investor, ...)
+    // that drives the persona UI; `perm` is the ACCESS level that gates the admin console — two
+    // orthogonal axes, deliberately not merged (Frank is job=loan_officer AND perm=standard).
     return { email: s.email, tier: u.tier || 'free', username: u.username || null,
-             name: u.name || null, role: u.role || null, industry: u.industry || null };
+             name: u.name || null, role: u.role || null, industry: u.industry || null,
+             perm: u.perm === 'admin' ? 'admin' : 'standard' };
+  }
+  // Require an admin session on an admin-only route. Returns the user object, or null after
+  // having already sent the 401/403 response (caller just `if (!requireAdmin(req,res)) return;`).
+  function requireAdmin(req, res) {
+    const u = userOf(req);
+    if (!u) { res.status(401).json({ error: 'sign in' }); return null; }
+    if (u.perm !== 'admin') { res.status(403).json({ error: 'admin only' }); return null; }
+    return u;
   }
 
   // ── magic-link auth ──────────────────────────────────────────────────────────
@@ -142,22 +154,58 @@ module.exports = function mountAccounts(app, ROOT) {
 
   app.get('/api/me', (req, res) => res.json(userOf(req) || { email: null }));
 
-  // ── seed Frank the loan officer (idempotent — never overwrites an existing account) ──
-  // Username/password + password can be overridden per deploy via CRCP_SEED_USER/CRCP_SEED_PASS.
-  (function seedFrank() {
+  // ── seed accounts (idempotent — never overwrites an existing account's password/profile) ──
+  // Frank the loan officer is the FIRST user: a STANDARD account (leaves per-listing notes + uses
+  // the shared contact book as his CRM). An ADMIN account is seeded alongside so someone can manage
+  // users from day one. Creds are env-overridable per deploy:
+  //   Frank : CRCP_SEED_USER / CRCP_SEED_PASS   (default frank / arcstone1998)
+  //   Admin : CRCP_ADMIN_USER / CRCP_ADMIN_PASS (default admin / DW2024!)
+  (function seedAccounts() {
     try {
       const db = load(); ['users', 'tokens', 'sessions', 'saved', 'watch'].forEach(k => db[k] = db[k] || blank()[k]);
-      const key = uname(process.env.CRCP_SEED_USER || 'frank');
-      if (!db.users[key]) {
+      let dirty = false;
+
+      // Frank — the first user, a standard-permission loan officer.
+      const fkey = uname(process.env.CRCP_SEED_USER || 'frank');
+      if (!db.users[fkey]) {
         const salt = rid();
-        db.users[key] = {
-          tier: 'pro', username: key, name: 'Frank', role: 'loan_officer', industry: ROLES.loan_officer,
+        db.users[fkey] = {
+          tier: 'pro', perm: 'standard', username: fkey, name: 'Frank', role: 'loan_officer', industry: ROLES.loan_officer,
           salt, passhash: hashPw(process.env.CRCP_SEED_PASS || 'arcstone1998', salt),
           created: new Date().toISOString(), seeded: true,
         };
-        save(db);
-        console.log('[crcp-accounts] seeded loan-officer account: ' + key);
+        dirty = true;
+        console.log('[crcp-accounts] seeded standard loan-officer account: ' + fkey);
+      } else if (!db.users[fkey].perm) {
+        // Backfill: Frank predates the perm model — an account with no perm is a standard user.
+        db.users[fkey].perm = 'standard'; dirty = true;
+      }
+
+      // Admin — the account-management login. Only ever auto-seeded if NO admin exists yet, so a
+      // later manual demotion/reshuffle of admins is never silently re-created behind Steve's back.
+      const hasAdmin = Object.values(db.users).some(u => u && u.perm === 'admin');
+      if (!hasAdmin) {
+        const akey = uname(process.env.CRCP_ADMIN_USER || 'admin');
+        if (!db.users[akey]) {
+          const salt = rid();
+          db.users[akey] = {
+            tier: 'pro', perm: 'admin', username: akey, name: 'Admin', role: 'other', industry: ROLES.other,
+            salt, passhash: hashPw(process.env.CRCP_ADMIN_PASS || 'DW2024!', salt),
+            created: new Date().toISOString(), seeded: true,
+          };
+          dirty = true;
+          console.log('[crcp-accounts] seeded admin account: ' + akey);
+        } else {
+          // A user with that key already exists but isn't admin — promote it rather than clobber.
+          db.users[akey].perm = 'admin'; dirty = true;
+          console.log('[crcp-accounts] promoted existing account to admin: ' + akey);
+        }
       }
+
+      // Backfill any other legacy account that has a password but no perm → standard.
+      for (const [k, u] of Object.entries(db.users)) { if (u && u.passhash && !u.perm) { u.perm = 'standard'; dirty = true; } }
+
+      if (dirty) save(db);
     } catch (e) { console.error('[crcp-accounts] seed failed:', e.message); }
   })();
 
@@ -220,6 +268,88 @@ module.exports = function mountAccounts(app, ROOT) {
     res.json({ ok: true, ids: db.watch[u.email] });
   });
 
+  // ── admin console: user management (admin perm required) ───────────────────────
+  // Redact secrets from every user record we hand to the client — never leak salt/passhash.
+  const pubUser = (key, u) => ({
+    username: u.username || key, key, name: u.name || null, perm: u.perm === 'admin' ? 'admin' : 'standard',
+    tier: u.tier || 'free', role: u.role || null, industry: u.industry || null,
+    created: u.created || null, last_login: u.last_login || null, seeded: !!u.seeded,
+    has_password: !!u.passhash, magic_only: !u.passhash,
+  });
+  const countAdmins = db => Object.values(db.users).filter(u => u && u.perm === 'admin').length;
+
+  // List every account (admin only).
+  app.get('/api/admin/users', (req, res) => {
+    if (!requireAdmin(req, res)) return;
+    const db = load();
+    const users = Object.entries(db.users).map(([k, u]) => pubUser(k, u))
+      .sort((a, b) => String(b.created || '').localeCompare(String(a.created || '')));
+    res.json({ users, count: users.length, admins: countAdmins(db) });
+  });
+
+  // Create a new account (admin only). Defaults to a standard user; perm can be set to admin.
+  app.post('/api/admin/users', (req, res) => {
+    if (!requireAdmin(req, res)) return;
+    const b = req.body || {};
+    const key = uname(b.username);
+    if (!key) return res.status(400).json({ error: 'username required (letters, digits, . _ -)' });
+    if (!b.password || String(b.password).length < 6) return res.status(400).json({ error: 'password required (min 6 chars)' });
+    const db = load(); ['users', 'tokens', 'sessions', 'saved', 'watch'].forEach(k => db[k] = db[k] || blank()[k]);
+    if (db.users[key]) return res.status(409).json({ error: 'username already exists' });
+    const perm = b.perm === 'admin' ? 'admin' : 'standard';
+    const role = ROLES[b.role] ? b.role : 'loan_officer';
+    const salt = rid();
+    db.users[key] = {
+      tier: b.tier === 'pro' ? 'pro' : (perm === 'admin' ? 'pro' : 'free'),
+      perm, username: key, name: b.name ? clean(b.name) : key, role, industry: ROLES[role],
+      salt, passhash: hashPw(String(b.password), salt),
+      created: new Date().toISOString(), created_by: userOf(req).email,
+    };
+    save(db);
+    res.json({ ok: true, user: pubUser(key, db.users[key]) });
+  });
+
+  // Reset a user's password (admin only).
+  app.post('/api/admin/users/:key/password', (req, res) => {
+    if (!requireAdmin(req, res)) return;
+    const key = uname(req.params.key);
+    const pw = String((req.body || {}).password || '');
+    if (pw.length < 6) return res.status(400).json({ error: 'password required (min 6 chars)' });
+    const db = load(); const u = db.users[key]; if (!u) return res.status(404).json({ error: 'no such user' });
+    const salt = rid(); u.salt = salt; u.passhash = hashPw(pw, salt); u.pw_reset_at = new Date().toISOString();
+    save(db);
+    res.json({ ok: true, user: pubUser(key, u) });
+  });
+
+  // Change a user's permission level (admin only). Guarded so the LAST admin can never be demoted
+  // (which would lock everyone out of the console).
+  app.post('/api/admin/users/:key/perm', (req, res) => {
+    const me = requireAdmin(req, res); if (!me) return;
+    const key = uname(req.params.key);
+    const perm = (req.body || {}).perm === 'admin' ? 'admin' : 'standard';
+    const db = load(); const u = db.users[key]; if (!u) return res.status(404).json({ error: 'no such user' });
+    if (u.perm === 'admin' && perm === 'standard' && countAdmins(db) <= 1) {
+      return res.status(409).json({ error: 'cannot demote the last admin' });
+    }
+    u.perm = perm; save(db);
+    res.json({ ok: true, user: pubUser(key, u) });
+  });
+
+  // Delete an account (admin only). Can't delete yourself or the last admin.
+  app.delete('/api/admin/users/:key', (req, res) => {
+    const me = requireAdmin(req, res); if (!me) return;
+    const key = uname(req.params.key);
+    const db = load(); const u = db.users[key]; if (!u) return res.status(404).json({ error: 'no such user' });
+    if (key === uname(me.email)) return res.status(409).json({ error: 'cannot delete your own account' });
+    if (u.perm === 'admin' && countAdmins(db) <= 1) return res.status(409).json({ error: 'cannot delete the last admin' });
+    delete db.users[key]; delete db.saved[key]; delete db.watch[key];
+    // Revoke any live sessions for the deleted user so a signed-in tab loses access immediately.
+    for (const [sid, s] of Object.entries(db.sessions)) if (s && s.email === key) delete db.sessions[sid];
+    save(db);
+    res.json({ ok: true, deleted: key });
+  });
+
   module.exports.userOf = userOf;   // expose for the alert job
-  console.log('[crcp-accounts] P1 subscription layer mounted (accounts + saved-searches + watchlist)');
+  module.exports.requireAdmin = requireAdmin;
+  console.log('[crcp-accounts] P1 subscription layer mounted (accounts + roles + admin console + saved-searches + watchlist)');
 };
diff --git a/scripts/crcp-notes.js b/scripts/crcp-notes.js
new file mode 100644
index 0000000..5a24350
--- /dev/null
+++ b/scripts/crcp-notes.js
@@ -0,0 +1,83 @@
+// crcp-notes.js — PER-USER private per-listing scratch notes for the CRCP deal-flow tool.
+// Hybrid CRM model (Steve 2026-08-06, TK-10301): a loan officer's scratch notes on a specific
+// listing are PRIVATE to that user, while the contact rolodex (agent-contacts in serve.js) stays a
+// shared team asset. This module owns the notes half.
+//
+// Store: data/condo-notes.json. NEW shape is namespaced by user key:
+//   { "_perUser": true, "users": { "<userKey>": { "<listingId>": { note, updated_at } } } }
+// The OLD shape was a flat { "<listingId>": { note, updated_at } } shared by everyone. On first
+// load we auto-migrate that flat map under the FIRST user (Frank) so no note is lost.
+//
+// Auth: notes are attributed to the signed-in account (crcp_sid session, via userOf). Reads with no
+// session return an empty set (page still renders); writes with no session return 401 so the client
+// can prompt a sign-in. Admins may read another user's notes with ?user=<key>.
+//
+// $0, local, reversible. Mount from serve.js:  require('./crcp-notes')(app, ROOT, userOf);
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+module.exports = function mountNotes(app, ROOT, userOf) {
+  const FILE = path.join(ROOT, 'data', 'condo-notes.json');
+  const clip = (s, n) => String(s == null ? '' : s).slice(0, n);
+  // Which user owns migrated legacy notes — the first/only real user at migration time.
+  const FRANK = String(process.env.CRCP_SEED_USER || 'frank').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '');
+
+  // Load + normalize to the per-user shape. Auto-migrates a legacy flat map under FRANK's key.
+  function loadStore() {
+    let raw;
+    try { raw = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { _perUser: true, users: {} }; }
+    if (raw && raw._perUser && raw.users && typeof raw.users === 'object') return raw;
+    // Legacy flat { id: {note, updated_at} } → wrap under FRANK. Guard against an empty/oddball file.
+    const legacy = (raw && typeof raw === 'object') ? raw : {};
+    const hasLegacy = Object.values(legacy).some(v => v && typeof v === 'object' && 'note' in v);
+    return { _perUser: true, users: hasLegacy ? { [FRANK]: legacy } : {} };
+  }
+
+  // Serialized read-modify-write with atomic rename (mirrors the agent-contacts / accounts store).
+  let _q = Promise.resolve();
+  function withStore(mutator) {
+    const run = _q.then(() => {
+      const store = loadStore();
+      const out = mutator(store);
+      const tmp = FILE + '.tmp';
+      fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
+      fs.renameSync(tmp, FILE);
+      return out;
+    });
+    _q = run.then(() => {}, () => {});
+    return run;
+  }
+
+  const notesFor = (store, key) => (store.users && store.users[key]) || {};
+
+  // GET /api/condo-notes  → the signed-in user's notes. Admin may pass ?user=<key>.
+  app.get('/api/condo-notes', (req, res) => {
+    const u = userOf(req);
+    if (!u) return res.json({ notes: {}, signed_in: false });
+    let key = u.email;
+    if (req.query.user && u.perm === 'admin') {
+      key = String(req.query.user).trim().toLowerCase().replace(/[^a-z0-9._@.\-]/g, '');
+    }
+    res.json({ notes: notesFor(loadStore(), key), signed_in: true, user: key });
+  });
+
+  // POST /api/condo-notes/:id { note }  → upsert (empty note deletes). Requires a session.
+  app.post('/api/condo-notes/:id', (req, res) => {
+    const u = userOf(req);
+    if (!u) return res.status(401).json({ error: 'sign in to save notes', signed_in: false });
+    const id = clip(req.params.id, 120); if (!id) return res.status(400).json({ error: 'no id' });
+    const note = clip((req.body || {}).note, 8000);
+    const key = u.email;
+    withStore(store => {
+      store.users = store.users || {};
+      const bucket = store.users[key] = store.users[key] || {};
+      if (note.trim()) bucket[id] = { note, updated_at: new Date().toISOString() };
+      else delete bucket[id];
+      return bucket[id];
+    }).then(rec => res.json({ ok: true, id, note: rec || null }))
+      .catch(e => res.status(500).json({ error: String(e && e.message || e) }));
+  });
+
+  console.log('[crcp-notes] per-user private listing notes mounted (hybrid CRM: private notes + shared contacts)');
+};
diff --git a/scripts/serve.js b/scripts/serve.js
index d6b912c..edceb9d 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -56,7 +56,7 @@ app.use((req, res, next) => {
 });
 
 // ── 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); } catch (e) { console.error('[crcp-accounts/billing/leads/export] mount failed:', e.message); }
+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); }
 
 // ── Agent-contact CRM (durable, local JSON) ─────────────────────────────────────────
 // One record per listing id: editable {name,phone,email}, a `contacted_at` stamp, and an
@@ -779,26 +779,10 @@ function overlayBroker(row) {
   return o;
 }
 
-// ── Per-listing scratch NOTES (data/condo-notes.json) — Steve 2026-08-03: "provide a place for the
-// user to enter notes". One free-text note per listing id, durable JSON, atomic write + serialized
-// RMW (mirrors the agent-contacts store). Interactive counterpart to the printable Notes line in the
-// email. Behind the same basic-auth as the rest of the viewer.
-const CONDO_NOTES_FILE = path.join(ROOT, 'data', 'condo-notes.json');
-const readNotes = () => { try { return JSON.parse(fs.readFileSync(CONDO_NOTES_FILE, 'utf8')); } catch (_) { return {}; } };
-let _nq = Promise.resolve();
-function withNotes(mutator) {
-  const run = _nq.then(() => { const map = readNotes(); const out = mutator(map);
-    const tmp = CONDO_NOTES_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(map, null, 2)); fs.renameSync(tmp, CONDO_NOTES_FILE); return out; });
-  _nq = run.then(() => {}, () => {});
-  return run;
-}
-app.get('/api/condo-notes', (req, res) => res.json({ notes: readNotes() }));
-app.post('/api/condo-notes/:id', (req, res) => {
-  const id = clip(req.params.id, 120); if (!id) return res.status(400).json({ error: 'no id' });
-  const note = clip((req.body || {}).note, 8000);
-  withNotes(map => { if (note.trim()) map[id] = { note, updated_at: new Date().toISOString() }; else delete map[id]; return map[id]; })
-    .then(rec => res.json({ ok: true, id, note: rec || null })).catch(jsonErr(res));
-});
+// ── Per-listing scratch NOTES — now PER-USER (Steve 2026-08-06, TK-10301). The store + the
+// /api/condo-notes routes moved to scripts/crcp-notes.js (mounted above via require('./crcp-notes')),
+// which scopes each user's notes to their own account (hybrid CRM: private notes + shared contacts)
+// and auto-migrates the legacy global condo-notes.json under Frank. Left intentionally blank here.
 
 const CONDO_LABEL = 'FHA/VA-approval-based proxy, NOT lender-verified Fannie/Freddie warrantability.';
 app.get('/api/condos', async (req, res) => {

← 53717bd auto-data-snapshot: 2026-08-06T11:54:28 (2 data files) — dat  ·  back to Commercialrealestate  ·  CRCP auth hardening from security review (TK-10301) c95386d →