Files
claudetools/projects/msp-tools/security-assessment/app/index.php
Mike Swanson 260be8c2ad security.azcomputerguru.com: scaffold the client security-assessment intake app
New MSP tool — single-assessor consult intake. Stack: PHP + MySQL on the IX cPanel
host, gated by Cloudflare Access (only mike@azcomputerguru.com; app re-checks the
Cf-Access-Authenticated-User-Email header).

- app/questions.json — risk-ordered question framework (9 sections); each field tagged
  source=syncro/rmm/scan/ask so the consult asks only what a human knows and the
  post-meeting scan fills the technical reality.
- app/index.php — wizard UI: Syncro phone lookup -> prefill, section rail with live
  progress, importance-colored question cards, in-meeting 365/Google consent links,
  review + export.
- app/api.php — Syncro lookup-by-phone, save/load/list, consent-URL generation (reuses
  the read-only Security Investigator app bfbc12a4-...), HTML export.
- app/schema.sql, config.sample.php, DEPLOY.md, README.md.

Consent links let the client approve read-only 365/Google access during the consult so
the audit scan runs afterward. Read-only by design (reads Syncro, generates consent; no
tenant writes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:20:05 -07:00

202 lines
15 KiB
PHP

<?php
// security.azcomputerguru.com — ACG Client Security Assessment intake.
// Access is gated by Cloudflare Access (only mike@azcomputerguru.com). Defense-in-depth:
// re-check the Cf-Access-Authenticated-User-Email header here too.
require __DIR__ . '/config.php';
$email = $_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'] ?? '';
if (ALLOWED_EMAIL !== '' && strcasecmp($email, ALLOWED_EMAIL) !== 0) {
http_response_code(403);
exit('Forbidden — this tool is restricted.');
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ACG Security Assessment</title>
<style>
:root{
--bg:#0f172a; --panel:#111c33; --surface:#ffffff; --ink:#0f172a; --ink-2:#475569; --ink-3:#94a3b8;
--line:#e3e8ee; --accent:#1d4ed8; --accent-soft:#eef4ff; --good:#16a34a; --good-bg:#dcfce7;
--warn:#b45309; --warn-bg:#fef3c7; --crit:#b91c1c; --crit-bg:#fee2e2;
--sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; --r:8px;
}
*{box-sizing:border-box} html,body{height:100%;margin:0}
body{font-family:var(--sans);color:var(--ink);background:#f1f5f9;display:grid;grid-template-columns:260px 1fr;grid-template-rows:56px 1fr;grid-template-areas:"brand top" "rail main";height:100vh;overflow:hidden}
button,input,select,textarea{font-family:inherit}
/* brand + topbar */
.brand{grid-area:brand;background:var(--bg);color:#fff;display:flex;align-items:center;gap:10px;padding:0 18px;font-weight:700;letter-spacing:-.01em}
.brand .sh{width:22px;height:22px;border-radius:5px;background:linear-gradient(135deg,#3b82f6,#1d4ed8);display:inline-block}
.top{grid-area:top;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;padding:0 18px}
.top .client{font-weight:600}.top .muted{color:var(--ink-3)}
.top .sp{flex:1}
.top button{height:34px;padding:0 13px;border:1px solid var(--line);border-radius:var(--r);background:#fff;cursor:pointer;font-size:13px;color:var(--ink)}
.top button:hover{background:#f1f5f9}.top button.pri{background:var(--accent);color:#fff;border-color:var(--accent)}
/* rail */
.rail{grid-area:rail;background:var(--panel);color:#cbd5e1;padding:14px 10px;overflow-y:auto}
.rail .step{display:flex;align-items:center;gap:11px;padding:9px 11px;border-radius:var(--r);cursor:pointer;font-size:13.5px;color:#cbd5e1}
.rail .step:hover{background:#1b2a47}
.rail .step.active{background:#1d4ed8;color:#fff}
.rail .step .n{width:22px;height:22px;border-radius:50%;background:#24344f;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0}
.rail .step.active .n{background:#fff;color:#1d4ed8}
.rail .step.done .n{background:var(--good);color:#fff}
.rail .step .pct{margin-left:auto;font-size:10.5px;color:#64748b}
.rail .step.active .pct{color:#bfdbfe}
/* main */
.main{grid-area:main;overflow-y:auto;padding:26px 34px;display:flex;justify-content:center}
.sheet{width:100%;max-width:760px}
.sec-h{display:flex;align-items:baseline;gap:12px;margin:0 0 4px}
.sec-h h1{font-size:21px;margin:0;letter-spacing:-.01em}
.sec-h .ix{font-size:12px;color:var(--ink-3);font-weight:600}
.intro{color:var(--ink-2);font-size:13.5px;margin:0 0 18px}
.field{margin-bottom:16px;background:#fff;border:1px solid var(--line);border-radius:10px;padding:13px 15px}
.field.crit{border-left:3px solid var(--crit)} .field.high{border-left:3px solid var(--warn)}
.field label{display:block;font-size:13.5px;font-weight:600;margin-bottom:6px}
.field .help{font-size:12px;color:var(--ink-3);margin-top:5px}
.field .tag-src{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:1px 6px;border-radius:4px;margin-left:7px;vertical-align:middle}
.src-syncro{background:#e0e7ff;color:#3730a3}.src-rmm{background:#dcfce7;color:#166534}.src-scan{background:#f1f5f9;color:#64748b}.src-ask{background:#fff7ed;color:#9a3412}
.field input[type=text],.field input[type=email],.field input[type=number],.field input[type=tel],.field input[type=date],.field select,.field textarea{
width:100%;height:38px;border:1px solid #cbd5e1;border-radius:7px;padding:0 11px;font-size:14px;background:#fff;color:var(--ink)}
.field textarea{height:74px;padding:9px 11px;resize:vertical}
.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.opts{display:flex;flex-wrap:wrap;gap:7px}
.opt{font-size:13px;padding:7px 12px;border:1px solid #cbd5e1;border-radius:20px;background:#fff;cursor:pointer;user-select:none}
.opt.on{background:var(--accent);color:#fff;border-color:var(--accent)}
.lookup{display:flex;gap:8px}.lookup input{flex:1}
.lookup button{height:38px;padding:0 16px;border:0;border-radius:7px;background:var(--accent);color:#fff;font-weight:600;cursor:pointer}
.consent{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.consent button{height:36px;padding:0 13px;border:1px solid var(--accent);border-radius:7px;background:var(--accent-soft);color:var(--accent);font-weight:600;cursor:pointer}
.consent .link{font-size:12px;word-break:break-all;background:#f8fafc;border:1px dashed #cbd5e1;border-radius:6px;padding:7px 9px;flex:1;min-width:240px}
.consent .status{font-size:12px;font-weight:700;padding:2px 9px;border-radius:20px}
.consent .status.pending{background:var(--warn-bg);color:var(--warn)} .consent .status.done{background:var(--good-bg);color:var(--good)}
.navbar{display:flex;gap:10px;margin-top:24px;align-items:center}
.navbar .sp{flex:1}
.navbar button{height:40px;padding:0 20px;border-radius:8px;border:1px solid var(--line);background:#fff;cursor:pointer;font-size:14px;font-weight:600}
.navbar button.pri{background:var(--accent);color:#fff;border-color:var(--accent)}
.navbar button:disabled{opacity:.5;cursor:default}
.savedot{font-size:12px;color:var(--ink-3)}
.review .row{display:grid;grid-template-columns:230px 1fr;gap:10px;padding:7px 0;border-bottom:1px solid var(--line);font-size:13.5px}
.review .row .k{color:var(--ink-3)} .review .row .v{font-weight:500}
.review h3{margin:18px 0 4px;font-size:14px}
</style>
</head>
<body>
<div class="brand"><span class="sh"></span> ACG&nbsp;Security</div>
<div class="top">
<span class="client" id="clientName">No client selected</span>
<span class="muted" id="clientSub"></span>
<span class="sp"></span>
<span class="savedot" id="saveDot"></span>
<button id="btnList">Assessments</button>
<button class="pri" id="btnExport">Export</button>
</div>
<nav class="rail" id="rail"></nav>
<main class="main"><div class="sheet" id="sheet"></div></main>
<script>
const ICONS={building:'🏢',target:'🎯',key:'🔑',shield:'🛡️',monitor:'🖥️',globe:'🌐',users:'👥',cloud:'☁️',lock:'🔒'};
const state={q:null,sec:0,data:{},id:null,consent:{}};
const $=s=>document.querySelector(s);
const api=(action,body)=>fetch('api.php?action='+action,{method:body?'POST':'GET',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined}).then(r=>r.json());
async function boot(){
state.q=await fetch('questions.json').then(r=>r.json());
const u=new URLSearchParams(location.search); if(u.get('id')) await load(u.get('id'));
renderRail(); renderSection();
}
function secPct(s){const ask=s.fields.filter(f=>!['hidden','auto'].includes(f.source||'')&&f.type!=='hidden');const done=ask.filter(f=>{const v=state.data[f.id];return Array.isArray(v)?v.length:(v!==undefined&&v!=='')}).length;return ask.length?Math.round(done/ask.length*100):0;}
function renderRail(){
$('#rail').innerHTML=state.q.sections.map((s,i)=>{
const pct=secPct(s);const cls=i===state.sec?'active':(pct===100?'done':'');
return `<div class="step ${cls}" data-i="${i}"><span class="n">${pct===100&&i!==state.sec?'✓':i+1}</span><span>${s.title}</span><span class="pct">${pct}%</span></div>`;
}).join('')+`<div class="step ${state.sec===state.q.sections.length?'active':''}" data-i="${state.q.sections.length}"><span class="n">✓</span><span>Review &amp; finish</span></div>`;
document.querySelectorAll('.rail .step').forEach(el=>el.onclick=()=>{state.sec=+el.dataset.i;renderRail();renderSection();});
}
function field(f){
if(f.type==='hidden')return'';
const v=state.data[f.id];
const src=f.source&&f.source!=='input'&&f.source!=='auto'?`<span class="tag-src src-${f.source}">${f.source}</span>`:'';
const cls='field'+(f.importance==='critical'?' crit':f.importance==='high'?' high':'');
let inner='';
if(f.type==='consent'){inner=consentField(f);}
else if(f.lookup){inner=`<div class="lookup"><input type="tel" id="fld_${f.id}" value="${v||''}" placeholder="(520) 555-1234"><button onclick="doLookup()">Look up</button></div>`;}
else if(f.type==='multiselect'){inner=`<div class="opts">`+f.options.map(o=>`<span class="opt ${(v||[]).includes(o)?'on':''}" data-f="${f.id}" data-o="${o}">${o}</span>`).join('')+`</div>`;}
else if(f.type==='select'){inner=`<select id="fld_${f.id}"><option value="">— select —</option>`+f.options.map(o=>`<option ${v===o?'selected':''}>${o}</option>`).join('')+`</select>`;}
else if(f.type==='boolean'){inner=`<div class="opts">${['Yes','No','Unsure'].map(o=>`<span class="opt ${v===o?'on':''}" data-rf="${f.id}" data-o="${o}">${o}</span>`).join('')}</div>`;}
else if(f.type==='textarea'){inner=`<textarea id="fld_${f.id}">${v||''}</textarea>`;}
else if(f.type==='tags'){inner=`<input type="text" id="fld_${f.id}" value="${Array.isArray(v)?v.join(', '):(v||'')}" placeholder="comma separated">`;}
else {const t=f.type==='number'?'number':f.type==='email'?'email':f.type==='date'?'date':'text';inner=`<input type="${t}" id="fld_${f.id}" value="${v||''}">`;}
return `<div class="${cls}"><label>${f.label}${src}</label>${inner}${f.help?`<div class="help">${f.help}</div>`:''}</div>`;
}
function consentField(f){
const c=state.consent[f.provider]||{};
const status=c.granted?`<span class="status done">✓ consented</span>`:c.url?`<span class="status pending">awaiting click</span>`:'';
return `<div class="consent"><button onclick="genConsent('${f.provider}')">${c.url?'Regenerate':'Generate'} ${f.provider==='m365'?'365':'Google'} link</button>${c.url?`<span class="link">${c.url}</span>`:''}${status}<button onclick="markConsent('${f.provider}')" title="Mark as consented">Mark done</button></div>`;
}
function renderSection(){
const secs=state.q.sections;
if(state.sec>=secs.length){return renderReview();}
const s=secs[state.sec];
$('#sheet').innerHTML=`<div class="sec-h"><span style="font-size:22px">${ICONS[s.icon]||''}</span><h1>${s.title}</h1><span class="ix">${state.sec+1} / ${secs.length}</span></div>${s.intro?`<p class="intro">${s.intro}</p>`:''}<div id="fields">${s.fields.map(field).join('')}</div>${navbar()}`;
wire(s);
}
function navbar(last){
return `<div class="navbar"><button onclick="go(-1)" ${state.sec===0?'disabled':''}>← Back</button><span class="sp"></span><button onclick="saveNow()">Save</button><button class="pri" onclick="go(1)">${last?'Done':'Next →'}</button></div>`;
}
function wire(s){
s.fields.forEach(f=>{
const el=document.getElementById('fld_'+f.id);
if(el)el.onchange=()=>{let v=el.value;if(f.type==='tags')v=v.split(',').map(x=>x.trim()).filter(Boolean);state.data[f.id]=v;debouncedSave();renderRail();};
});
document.querySelectorAll('.opt[data-f]').forEach(el=>el.onclick=()=>{const fid=el.dataset.f,o=el.dataset.o;const a=state.data[fid]||[];const i=a.indexOf(o);i<0?a.push(o):a.splice(i,1);state.data[fid]=a;el.classList.toggle('on');debouncedSave();renderRail();});
document.querySelectorAll('.opt[data-rf]').forEach(el=>el.onclick=()=>{const fid=el.dataset.rf;state.data[fid]=el.dataset.o;document.querySelectorAll(`.opt[data-rf="${fid}"]`).forEach(x=>x.classList.toggle('on',x===el));debouncedSave();renderRail();});
}
function go(d){state.sec=Math.max(0,Math.min(state.q.sections.length,state.sec+d));saveNow();renderRail();renderSection();window.scrollTo(0,0);}
async function doLookup(){
const phone=$('#fld_syncro_phone').value.trim(); if(!phone)return;
$('#clientSub').textContent='looking up…';
const r=await api('lookup',{phone});
if(r.error){$('#clientSub').textContent='not found: '+r.error;return;}
// prefill syncro-sourced fields
Object.assign(state.data,r.prefill||{});
state.data.syncro_phone=phone;
$('#clientName').textContent=r.prefill.business_name||'Client';
$('#clientSub').textContent=(r.prefill.address||'')+(r.rmm?` · ${r.rmm.workstations||0} ws / ${r.rmm.servers||0} srv`:'');
if(r.rmm){state.data.workstation_count=r.rmm.workstations;state.data.server_count=r.rmm.servers;}
saveNow();renderSection();renderRail();
}
async function genConsent(provider){
const r=await api('consent',{provider,domain:state.data.tenant_domain||(state.data.email_domains||[])[0]||'',customer_id:state.data.syncro_customer_id});
if(r.error){alert('Consent link failed: '+r.error);return;}
state.consent[provider]={url:r.url,granted:false};saveNow();renderSection();
}
function markConsent(provider){state.consent[provider]=Object.assign(state.consent[provider]||{},{granted:true});saveNow();renderSection();}
function renderReview(){
let html=`<div class="sec-h"><span style="font-size:22px">✅</span><h1>Review &amp; finish</h1></div><p class="intro">Confirm the captured intake. Export hands the audit work-list to the post-meeting scan.</p><div class="review">`;
for(const s of state.q.sections){
const rows=s.fields.filter(f=>f.type!=='hidden').map(f=>{let v=state.data[f.id];if(f.type==='consent'){const c=state.consent[f.provider]||{};v=c.granted?'✓ consented':(c.url?'link sent':'—');}if(Array.isArray(v))v=v.join(', ');return (v&&v!=='')?`<div class="row"><div class="k">${f.label}</div><div class="v">${v}</div></div>`:'';}).filter(Boolean).join('');
if(rows)html+=`<h3>${ICONS[s.icon]||''} ${s.title}</h3>${rows}`;
}
html+=`</div>${navbar(true)}`;
$('#sheet').innerHTML=html;
}
let saveT=null;
function debouncedSave(){clearTimeout(saveT);saveT=setTimeout(saveNow,800);}
async function saveNow(){
$('#saveDot').textContent='saving…';
const payload={id:state.id,phone:state.data.syncro_phone||'',business_name:state.data.business_name||'',data:state.data,consent:state.consent};
const r=await api('save',payload); if(r.id)state.id=r.id;
history.replaceState(null,'','?id='+state.id);
$('#saveDot').textContent='saved ✓';setTimeout(()=>$('#saveDot').textContent='',1500);
}
async function load(id){const r=await api('load&id='+id);if(r&&r.data){state.id=id;state.data=r.data;state.consent=r.consent||{};$('#clientName').textContent=state.data.business_name||'Client';}}
$('#btnExport').onclick=()=>{window.open('api.php?action=export&id='+(state.id||''),'_blank');};
$('#btnList').onclick=async()=>{const r=await api('list');alert((r.items||[]).map(x=>`#${x.id} ${x.business_name||x.phone} (${x.updated})`).join('\n')||'No assessments yet');};
boot();
</script>
</body>
</html>