[object Object]

← back to Commercialrealestate

CRCP listings: per-user saved settings — self-serve account (create with your own name/password), view saves to your account, auto-saves on exit (never wipes), shows 'last saved'

2bae93caaa093db896d22fe59ac2aefb7889adb1 · 2026-08-17 17:03:02 -0700 · steve

Files touched

Diff

commit 2bae93caaa093db896d22fe59ac2aefb7889adb1
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Aug 17 17:03:02 2026 -0700

    CRCP listings: per-user saved settings — self-serve account (create with your own name/password), view saves to your account, auto-saves on exit (never wipes), shows 'last saved'
---
 public/mls.html          | 53 ++++++++++++++++++++++++++++++++++++++++++++++++
 scripts/crcp-accounts.js | 40 +++++++++++++++++++++++++++++++++++-
 2 files changed, 92 insertions(+), 1 deletion(-)

diff --git a/public/mls.html b/public/mls.html
index b6b727c..db037fe 100644
--- a/public/mls.html
+++ b/public/mls.html
@@ -167,6 +167,7 @@
   <div class="tblbar" id="tblbar">
     <button class="csvbtn" id="csv2" title="Download the current filtered rows as a CSV spreadsheet">⬇ Download CSV</button>
     <span class="mut" style="font-size:11px">Drag the vertical bar between the filters and the records to widen the table → · double-click it to collapse/restore the filters.</span>
+    <span id="acctbar" style="margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px"></span>
   </div>
   <div class="statsbar" id="statsbar"></div>
   <div id="tableView" class="tblwrap"><table class="mls" data-no-sort><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
@@ -577,6 +578,58 @@ if($('#csv2')) $('#csv2').addEventListener('click',exportCSV);
   window.addEventListener('mouseup',()=>{ if(!dragging) return; dragging=false; div.classList.remove('dragging'); document.body.classList.remove('raildrag'); try{localStorage.setItem(KEY,String(cur()));}catch(e){} });
   div.addEventListener('dblclick',()=>{ const c=cur(); if(c>10){ lastW=c; setW(0); } else setW(lastW>10?lastW:DEF); try{localStorage.setItem(KEY,String(cur()));}catch(e){} });
 })();
+// ── per-user settings: sign in with your own un/pw → your view (columns, order, rail width, theme,
+//    heat, sort) follows you across browsers; auto-saves on exit; shows "last saved". Layered on the
+//    existing CRCP accounts system (scrypt-hashed passwords, session cookie). ──
+(function(){
+  const bar=$('#acctbar'); if(!bar) return;
+  const SKEYS=/^mls|^cre_mls|^crcp-theme2$/;   // the localStorage keys that make up a user's view
+  const collect=()=>{ const o={}; for(let i=0;i<localStorage.length;i++){ const k=localStorage.key(i); if(SKEYS.test(k)) o[k]=localStorage.getItem(k); } return o; };
+  const applyLocal=o=>{ Object.keys(o||{}).forEach(k=>{ try{localStorage.setItem(k,o[k]);}catch(e){} }); };
+  const fmt=ts=>{ if(!ts) return 'never'; try{ return new Date(ts).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return '—'; } };
+  const api=(u,o)=>fetch(u,Object.assign({headers:{'Content-Type':'application/json'}},o||{})).then(r=>r.json().then(j=>r.ok?j:Promise.reject(j)));
+  const inp='background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;width:120px';
+  let ME=null, SAVED_AT=null;
+  async function save(silent){ if(!ME||!ME.email) return; const s=collect(); if(!Object.keys(s).length) return; try{ const r=await api('/api/settings',{method:'POST',body:JSON.stringify({settings:s})}); SAVED_AT=r.savedAt; if(!silent) render(); }catch(e){} }
+  // save-on-exit: NEVER write an empty blob (would wipe the account's saved view), and only after the
+  // initial load/sync has completed this session, so a fast navigate can't clobber a not-yet-loaded view.
+  function saveBeacon(){ if(!ME||!ME.email) return; if(!sessionStorage.getItem('mls_settings_synced')) return; const s=collect(); if(!Object.keys(s).length) return; try{ navigator.sendBeacon('/api/settings', new Blob([JSON.stringify({settings:s})],{type:'application/json'})); }catch(e){} }
+  function render(){
+    if(ME&&ME.email){
+      bar.innerHTML='☁ <b style="color:var(--ink)">'+esc(ME.username||ME.email)+'</b>'
+        +' <span class="mut" id="lastSaved">· last saved '+esc(fmt(SAVED_AT))+'</span>'
+        +' <a class="csvbtn" id="saveNow" style="cursor:pointer;padding:4px 9px">Save now</a>'
+        +' <a id="signOut" class="mut" style="cursor:pointer;text-decoration:underline">sign out</a>';
+      $('#saveNow').onclick=()=>save(false);
+      $('#signOut').onclick=async()=>{ try{await fetch('/auth/logout',{method:'POST'});}catch(e){} sessionStorage.removeItem('mls_settings_synced'); location.reload(); };
+    } else {
+      bar.innerHTML='<span class="mut">Save your view →</span>'
+        +'<input id="liUser" placeholder="your name" autocomplete="username" style="'+inp+'">'
+        +'<input id="liPass" type="password" placeholder="password" autocomplete="current-password" style="'+inp+'">'
+        +'<button class="csvbtn" id="liBtn">Sign in</button>'
+        +'<button class="csvbtn" id="regBtn" title="Create a new account with this name + password">Create account</button>'
+        +'<span class="mut" id="liErr"></span>';
+      const auth=async(url)=>{ const username=$('#liUser').value.trim(), password=$('#liPass').value; if(!username||!password){ $('#liErr').textContent='enter a name + password'; return; } $('#liErr').textContent='…'; try{ await api(url,{method:'POST',body:JSON.stringify({username,password,name:username})}); sessionStorage.removeItem('mls_settings_synced'); location.reload(); }catch(e){ $('#liErr').textContent=(e&&e.error)||'failed'; } };
+      $('#liBtn').onclick=()=>auth('/auth/login'); $('#regBtn').onclick=()=>auth('/auth/register'); $('#liPass').onkeydown=e=>{ if(e.key==='Enter') auth('/auth/login'); };
+    }
+  }
+  async function boot(){
+    try{ ME=await api('/api/me'); }catch(e){ ME=null; }
+    if(ME&&ME.email){
+      try{ const r=await api('/api/settings'); SAVED_AT=r.savedAt;
+        if(r.settings && !sessionStorage.getItem('mls_settings_synced')){
+          applyLocal(r.settings); sessionStorage.setItem('mls_settings_synced','1'); location.reload(); return;
+        }
+      }catch(e){}
+      sessionStorage.setItem('mls_settings_synced','1');   // load complete → arm save-on-exit (even with no prior saved view)
+    }
+    render();
+    document.addEventListener('visibilitychange',()=>{ if(document.visibilityState==='hidden') saveBeacon(); });
+    window.addEventListener('pagehide', saveBeacon);
+    window.addEventListener('beforeunload', saveBeacon);
+  }
+  boot();
+})();
 $('#shortbtn').addEventListener('click',()=>{ F.shortlist=!F.shortlist; render(); });
 $('#moreWrap').addEventListener('click',e=>{ if(e.target.closest('#showmore')){ _shown=Math.min(_shown+PAGE(), _rows.length); paintBody(); } });
 $('#heat').value=heat; $('#heat').addEventListener('change',()=>{ heat=$('#heat').value; localStorage.setItem('cre_heat_mls',heat); render(); });
diff --git a/scripts/crcp-accounts.js b/scripts/crcp-accounts.js
index 843d43b..61b501a 100644
--- a/scripts/crcp-accounts.js
+++ b/scripts/crcp-accounts.js
@@ -135,6 +135,28 @@ module.exports = function mountAccounts(app, ROOT) {
     res.json({ ok: true, me: userOf({ headers: { cookie: `crcp_sid=${sid}` } }) });
   });
 
+  // ── self-serve registration: any visitor can create their OWN account (their name + password) so
+  //    their saved settings follow them. Standard perm only (admin is never self-granted). Auto-logs in. ──
+  app.post('/auth/register', (req, res) => {
+    const b = req.body || {};
+    const username = uname(b.username);
+    if (!username || !b.password) return res.status(400).json({ error: 'name and password required' });
+    if (username.length < 3) return res.status(400).json({ error: 'name must be at least 3 characters' });
+    if (String(b.password).length < 6) return res.status(400).json({ error: 'password must be at least 6 characters' });
+    const db = load(); ['users', 'tokens', 'sessions', 'saved', 'watch'].forEach(k => db[k] = db[k] || blank()[k]);
+    if (db.users[username] && db.users[username].passhash) return res.status(409).json({ error: 'that name is taken — sign in instead' });
+    const salt = rid();
+    const disp = String(b.name != null ? b.name : b.username).replace(/[<>]/g, '').replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 80) || username;
+    db.users[username] = Object.assign(db.users[username] || {}, {
+      tier: 'free', username, name: disp, perm: 'standard',
+      salt, passhash: hashPw(b.password, salt), created: new Date().toISOString(), self_registered: true
+    });
+    const sid = rid(); db.sessions[sid] = { email: username, exp: now() + 30 * 86400000 };
+    db.users[username].last_login = new Date().toISOString(); save(db);
+    setSession(res, sid);
+    res.json({ ok: true, me: userOf({ headers: { cookie: `crcp_sid=${sid}` } }) });
+  });
+
   // Set the signed-in user's personalization profile (role + industry + display name). This is
   // what the first-run "what industry are you?" picker posts to.
   const ROLES = { loan_officer: 'Mortgage / Lending', listing_agent: 'Brokerage — Listing', buyers_agent: 'Brokerage — Buy-side', investor: 'Investment / Principal', appraiser: 'Appraisal / Valuation', other: 'Other' };
@@ -282,6 +304,22 @@ module.exports = function mountAccounts(app, ROOT) {
     res.json({ ok: true, ids: db.watch[u.email] });
   });
 
+  // ── per-user UI settings (column visibility/order, rail width, theme, sort, …) ──
+  // A single JSON blob per user so a signed-in user's view follows them across browsers/machines.
+  // Saved on exit by the client; "last saved" is the savedAt stamp returned here.
+  app.get('/api/settings', (req, res) => {
+    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
+    const s = (load().settings || {})[u.email] || null;
+    res.json({ settings: s ? s.data : null, savedAt: s ? s.savedAt : null });
+  });
+  app.post('/api/settings', (req, res) => {
+    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
+    const data = (req.body || {}).settings; if (!data || typeof data !== 'object') return res.status(400).json({ error: 'settings object required' });
+    const db = load(); db.settings = db.settings || {};
+    db.settings[u.email] = { data, savedAt: now() }; save(db);
+    res.json({ ok: true, savedAt: db.settings[u.email].savedAt });
+  });
+
   // ── 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) => ({
@@ -390,7 +428,7 @@ module.exports = function mountAccounts(app, ROOT) {
       const u = db.users[key]; if (!u) throw { status: 404, error: 'no such user' };
       if (key === me.email) throw { status: 409, error: 'cannot delete your own account' };   // literal-key compare
       if (u.perm === 'admin' && countAdmins(db) <= 1) throw { status: 409, error: 'cannot delete the last admin' };
-      delete db.users[key]; delete db.saved[key]; delete db.watch[key];
+      delete db.users[key]; delete db.saved[key]; delete db.watch[key]; if (db.settings) delete db.settings[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];
       return { status: 200, body: { ok: true, deleted: key } };

← 6b78658 CRCP listings: records body now fills to the right screen ed  ·  back to Commercialrealestate  ·  CRCP listings: records table fills the full panel width and 5a913c4 →