← back to Commercialrealestate

public/admin.html

233 lines

<!doctype html>
<html lang="en">
<head>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
<!-- Self-heal: nav-agent grid controls can persist a "removed"/"collapsed" state that hides the
     whole grid with no obvious way back. Clear it before nav-agent runs so a grid can never stay hidden. -->
<script>(function(){try{for(var i=localStorage.length-1;i>=0;i--){var k=localStorage.key(i);if(k&&k.indexOf('navagent:')===0){var v=localStorage.getItem(k);if(v&&v.indexOf('"removed":true')>=0){localStorage.removeItem(k);}}}}catch(e){}})();</script>
<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:var(--card,#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:var(--headbg,#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:var(--card,#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:var(--card,#0b0e13)}
.nlist .n .id{color:var(--blue);font-size:11px}
.nlist .n .tx{white-space:pre-wrap;margin-top:4px;font-size:12px}
</style>
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
  <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
</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>
<script src="/corner-nav.js" defer></script>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
  <script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
</body>
</html>