[object Object]

← back to Rentv

PR intel: self-service login + credentials pages (Gmail integration under the account)

0e3b12bc78d3d5f83824dedceafdaabec49de34d · 2026-08-06 08:59:46 -0700 · Steve

- login.html: email/password → pr_session cookie → CRM.
- credentials.html: per-account integration page — Gmail OAuth form with a
  3-minute step-by-step guide (Cloud Console → Gmail API → OAuth client →
  Playground refresh token), Save + Test-connection. Secrets write-only
  (never returned; only connection status shown).
- backend: GET /api/pr/tenant (status), POST /tenant/gmail (save under tenant,
  integrations cap), POST /tenant/gmail/test (validates refresh token via
  Google token endpoint). Tested end-to-end on local :9704.

Files touched

Diff

commit 0e3b12bc78d3d5f83824dedceafdaabec49de34d
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 08:59:46 2026 -0700

    PR intel: self-service login + credentials pages (Gmail integration under the account)
    
    - login.html: email/password → pr_session cookie → CRM.
    - credentials.html: per-account integration page — Gmail OAuth form with a
      3-minute step-by-step guide (Cloud Console → Gmail API → OAuth client →
      Playground refresh token), Save + Test-connection. Secrets write-only
      (never returned; only connection status shown).
    - backend: GET /api/pr/tenant (status), POST /tenant/gmail (save under tenant,
      integrations cap), POST /tenant/gmail/test (validates refresh token via
      Google token endpoint). Tested end-to-end on local :9704.
---
 public/admin/pr-intelligence/credentials.html | 104 ++++++++++++++++++++++++++
 public/admin/pr-intelligence/login.html       |  45 +++++++++++
 src/pr/index.js                               |  32 ++++++++
 3 files changed, 181 insertions(+)

diff --git a/public/admin/pr-intelligence/credentials.html b/public/admin/pr-intelligence/credentials.html
new file mode 100644
index 00000000..ec5b6aef
--- /dev/null
+++ b/public/admin/pr-intelligence/credentials.html
@@ -0,0 +1,104 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Account Credentials — PR Intelligence</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+<style>
+  :root{--ink:#1a2230;--mut:#6b7686;--line:#e4e8ee;--brand:#0b6cff;--ok:#0a8a4a;--bad:#c0392b;--bg:#f6f8fb}
+  *{box-sizing:border-box} body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:var(--ink);background:var(--bg);margin:0}
+  .wrap{max-width:820px;margin:0 auto;padding:28px 20px 80px}
+  h1{font-size:22px;margin:0 0 4px} .sub{color:var(--mut);margin:0 0 24px}
+  .card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:22px 24px;margin:0 0 20px;box-shadow:0 1px 2px rgba(20,30,50,.04)}
+  .card h2{font-size:17px;margin:0 0 2px;display:flex;align-items:center;gap:8px}
+  .card .hint{color:var(--mut);font-size:13px;margin:0 0 18px}
+  label{display:block;font-weight:600;font-size:13px;margin:14px 0 5px}
+  input{width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:8px;font:14px inherit;background:#fbfcfe}
+  input:focus{outline:none;border-color:var(--brand);background:#fff}
+  .row{display:grid;grid-template-columns:1fr 1fr;gap:14px}
+  .btn{appearance:none;border:0;border-radius:8px;padding:10px 18px;font:600 14px inherit;cursor:pointer}
+  .btn.primary{background:var(--brand);color:#fff} .btn.ghost{background:#eef2f8;color:var(--ink)}
+  .btns{display:flex;gap:10px;margin-top:20px;align-items:center}
+  .status{margin-left:auto;font-weight:600;font-size:13px}
+  .status.ok{color:var(--ok)} .status.bad{color:var(--bad)} .status.mut{color:var(--mut)}
+  ol.steps{margin:0 0 6px;padding-left:20px;color:#3a4452;font-size:13.5px} ol.steps li{margin:7px 0}
+  ol.steps a{color:var(--brand)} code{background:#eef2f8;padding:1px 6px;border-radius:5px;font-size:12.5px}
+  .pill{font-size:11px;font-weight:700;padding:2px 9px;border-radius:20px;background:#eef2f8;color:var(--mut)}
+  .pill.on{background:#e4f6ec;color:var(--ok)}
+  details{border-top:1px solid var(--line);margin-top:16px;padding-top:12px} summary{cursor:pointer;font-weight:600;font-size:13px;color:var(--brand)}
+</style>
+</head>
+<body>
+<div class="wrap">
+  <h1>Account Credentials</h1>
+  <p class="sub">Connect your integrations. Everything you enter is saved securely under your account — <b id="who">your tenant</b>.</p>
+
+  <div class="card">
+    <h2>📧 Gmail integration <span class="pill" id="gmailPill">not connected</span></h2>
+    <p class="hint">Sync your email so every message to/from a contact is recorded on their timeline (dates &amp; times of all correspondence). Read-only — we never send from your account without your action.</p>
+
+    <details open>
+      <summary>How to get these 4 values (about 3 minutes)</summary>
+      <ol class="steps">
+        <li>Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener">Google Cloud Console → Credentials</a>. Create (or pick) a project.</li>
+        <li><b>Enable the Gmail API:</b> APIs &amp; Services → Library → search “Gmail API” → Enable.</li>
+        <li>Credentials → <b>Create Credentials → OAuth client ID</b> → type <b>Web application</b>. Under “Authorized redirect URIs” add <code>https://developers.google.com/oauthplayground</code>. Copy the <b>Client ID</b> and <b>Client Secret</b> below.</li>
+        <li>Open the <a href="https://developers.google.com/oauthplayground" target="_blank" rel="noopener">OAuth 2.0 Playground</a> → gear ⚙ (top right) → check <b>“Use your own OAuth credentials”</b> → paste your Client ID + Secret.</li>
+        <li>In the left list, under <b>Gmail API v1</b> select the scope <code>https://www.googleapis.com/auth/gmail.readonly</code> → <b>Authorize APIs</b> → sign in with the Gmail account you want to sync → <b>Exchange authorization code for tokens</b> → copy the <b>Refresh token</b> below.</li>
+      </ol>
+    </details>
+
+    <label>Gmail address to sync</label>
+    <input id="gmail_user" type="email" placeholder="steve.bloom@gmail.com" autocomplete="off">
+    <div class="row">
+      <div><label>Client ID</label><input id="client_id" placeholder="…apps.googleusercontent.com" autocomplete="off"></div>
+      <div><label>Client Secret</label><input id="client_secret" type="password" placeholder="GOCSPX-…" autocomplete="off"></div>
+    </div>
+    <label>Refresh Token</label>
+    <input id="refresh_token" type="password" placeholder="1//0g…" autocomplete="off">
+
+    <div class="btns">
+      <button class="btn primary" id="save">Save credentials</button>
+      <button class="btn ghost" id="test">Test connection</button>
+      <span class="status mut" id="msg"></span>
+    </div>
+  </div>
+
+  <p class="sub" style="font-size:12.5px">Secrets are write-only from this page — once saved they are never shown again, only their connection status. To change them, re-enter and save.</p>
+</div>
+
+<script>
+const $=id=>document.getElementById(id);
+const J=(u,o={})=>fetch(u,{credentials:'same-origin',headers:{'Content-Type':'application/json'},...o}).then(r=>r.json());
+function setPill(on){const p=$('gmailPill');p.textContent=on?'connected':'not connected';p.className='pill'+(on?' on':'');}
+function msg(t,cls){const m=$('msg');m.textContent=t;m.className='status '+(cls||'mut');}
+
+async function load(){
+  try{
+    const t=await J('/api/pr/tenant');
+    if(t&&t.name) $('who').textContent=t.name;
+    if(t&&t.gmail_user) $('gmail_user').value=t.gmail_user;
+    setPill(!!(t&&t.gmail_configured));
+  }catch(e){}
+}
+$('save').onclick=async()=>{
+  const body={gmail_user:$('gmail_user').value,client_id:$('client_id').value,client_secret:$('client_secret').value,refresh_token:$('refresh_token').value};
+  if(!body.gmail_user||!body.client_id||!body.client_secret||!body.refresh_token){msg('Fill in all four fields.','bad');return;}
+  msg('Saving…','mut');
+  try{const r=await J('/api/pr/tenant/gmail',{method:'POST',body:JSON.stringify(body)});
+    if(r.ok){setPill(true);$('client_secret').value='';$('refresh_token').value='';msg('Saved ✓ — now test the connection.','ok');}
+    else msg(r.error||'Save failed','bad');
+  }catch(e){msg('Save failed: '+e.message,'bad');}
+};
+$('test').onclick=async()=>{
+  msg('Testing…','mut');
+  try{const r=await J('/api/pr/tenant/gmail/test',{method:'POST'});
+    if(r.ok&&r.connected){setPill(true);msg('Connected ✓ '+(r.gmail_user||''),'ok');}
+    else msg('Not connected: '+(r.error||'unknown'),'bad');
+  }catch(e){msg('Test failed: '+e.message,'bad');}
+};
+load();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/login.html b/public/admin/pr-intelligence/login.html
new file mode 100644
index 00000000..85aa60b4
--- /dev/null
+++ b/public/admin/pr-intelligence/login.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Sign in — PR Intelligence</title>
+<style>
+  :root{--ink:#1a2230;--mut:#6b7686;--line:#e4e8ee;--brand:#0b6cff;--bad:#c0392b}
+  *{box-sizing:border-box} body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:var(--ink);background:linear-gradient(160deg,#eef3fb,#f6f8fb);margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center}
+  .card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:30px 30px 26px;width:360px;box-shadow:0 10px 40px rgba(20,30,60,.10)}
+  .brand{font-weight:800;font-size:15px;letter-spacing:.02em;color:var(--brand);margin:0 0 2px}
+  h1{font-size:20px;margin:0 0 20px}
+  label{display:block;font-weight:600;font-size:13px;margin:12px 0 5px}
+  input{width:100%;padding:11px 12px;border:1px solid var(--line);border-radius:9px;font:14px inherit;background:#fbfcfe}
+  input:focus{outline:none;border-color:var(--brand);background:#fff}
+  .btn{width:100%;margin-top:20px;border:0;border-radius:9px;padding:12px;font:700 15px inherit;background:var(--brand);color:#fff;cursor:pointer}
+  .msg{color:var(--bad);font-size:13px;min-height:18px;margin-top:12px;font-weight:600}
+</style>
+</head>
+<body>
+  <form class="card" id="f">
+    <p class="brand">PR INTELLIGENCE</p>
+    <h1>Sign in to your account</h1>
+    <label>Email</label>
+    <input id="email" type="email" autocomplete="username" placeholder="you@company.com" autofocus>
+    <label>Password</label>
+    <input id="password" type="password" autocomplete="current-password" placeholder="••••••••">
+    <button class="btn" type="submit">Sign in</button>
+    <div class="msg" id="msg"></div>
+  </form>
+<script>
+document.getElementById('f').onsubmit=async(e)=>{
+  e.preventDefault();
+  const msg=document.getElementById('msg'); msg.textContent='';
+  try{
+    const r=await fetch('/api/pr/auth/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({email:document.getElementById('email').value,password:document.getElementById('password').value})});
+    const j=await r.json();
+    if(j.ok){ location.href='/admin/pr-intelligence/'; }
+    else msg.textContent=j.error||'Sign in failed';
+  }catch(err){ msg.textContent='Sign in failed: '+err.message; }
+};
+</script>
+</body>
+</html>
diff --git a/src/pr/index.js b/src/pr/index.js
index ce7f0804..e10326b7 100644
--- a/src/pr/index.js
+++ b/src/pr/index.js
@@ -105,6 +105,38 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
     res.json({ ok: true });
   }));
 
+  // ── Tenant integration credentials (saved under the account; secrets never returned) ──
+  const auditSvc = require('./services/audit');
+  app.get('/api/pr/tenant', adminOnly, requireCap('read'), h(async (req, res) => {
+    const t = await db.one('SELECT id, name, slug, status, gmail_user, gmail_configured FROM pr_tenants WHERE id=$1', [req.prAuth.tenant.id]);
+    res.json(t || {});
+  }));
+  app.post('/api/pr/tenant/gmail', adminOnly, requireCap('integrations'), h(async (req, res) => {
+    const b = req.body || {}; const tid = req.prAuth.tenant.id;
+    const gmail = { client_id: String(b.client_id || '').trim(), client_secret: String(b.client_secret || '').trim(), refresh_token: String(b.refresh_token || '').trim() };
+    const gmail_user = String(b.gmail_user || '').trim();
+    if (!gmail.client_id || !gmail.client_secret || !gmail.refresh_token || !gmail_user) throw new Error('gmail_user + client_id + client_secret + refresh_token are all required');
+    await db.query(`UPDATE pr_tenants SET settings = jsonb_set(coalesce(settings,'{}'::jsonb), '{gmail}', $2::jsonb, true), gmail_user=$3, gmail_configured=true WHERE id=$1`,
+      [tid, JSON.stringify(gmail), gmail_user]);
+    await auditSvc.log({ actor: req.prAuth.user.email, action: 'tenant.gmail_saved', entity_type: 'tenant', entity_id: tid, detail: { gmail_user, client_id_last4: gmail.client_id.slice(-4) } });
+    res.json({ ok: true, gmail_user, gmail_configured: true });
+  }));
+  // Validate the saved Gmail creds by exchanging the refresh token for an access token.
+  app.post('/api/pr/tenant/gmail/test', adminOnly, requireCap('integrations'), h(async (req, res) => {
+    const t = await db.one(`SELECT gmail_user, settings->'gmail' AS g FROM pr_tenants WHERE id=$1`, [req.prAuth.tenant.id]);
+    const g = t && t.g; if (!g || !g.refresh_token) return res.json({ ok: false, error: 'no Gmail credentials saved yet' });
+    try {
+      const r = await fetch('https://oauth2.googleapis.com/token', {
+        method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+        body: new URLSearchParams({ client_id: g.client_id, client_secret: g.client_secret, refresh_token: g.refresh_token, grant_type: 'refresh_token' }),
+        signal: AbortSignal.timeout(15000),
+      });
+      const j = await r.json();
+      if (r.ok && j.access_token) return res.json({ ok: true, connected: true, gmail_user: t.gmail_user, scope: j.scope || null });
+      res.json({ ok: false, connected: false, error: j.error_description || j.error || ('HTTP ' + r.status) });
+    } catch (e) { res.json({ ok: false, connected: false, error: e.message }); }
+  }));
+
   // ── Health & meta ──────────────────────────────────────────────────────────
   app.get('/api/pr/health', adminOnly, async (_q, res) => res.json(await db.health()));
   app.get('/api/pr/meta', adminOnly, h(async (_q, res) => {

← 5707ef5f Add 'News Map' to sitewide nav + resolve straggler cities to  ·  back to Rentv  ·  harden(deals/closings): Cody gate — word-boundary match for 10ac233d →