← 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
M public/mls.htmlM scripts/crcp-accounts.js
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 →