Add credentials store to the CRM, docs cleanup, deploy pipeline TODO

Adds a `credentials` entity to the back office (never mirrored to
Sheets, gated by the CRM token even to read) so client logins like
the auto-generated Easy!Appointments provider password can be viewed
and copied from the dashboard instead of getting lost — the actual
cause of the happynails password going missing. Onboarding now saves
that generated password instead of discarding it. Also adds
Documentation.md, brings README/TODO in line with the current
Postgres-first architecture, and tidies the backlog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 14:13:34 +02:00
parent 8eba724428
commit 94ae2d578d
9 changed files with 183 additions and 27 deletions
+33 -3
View File
@@ -81,6 +81,7 @@
<div class="tabs">
<button class="tab active" data-e="leads">Leads<span class="n" id="n-leads"></span></button>
<button class="tab" data-e="clients">Clients<span class="n" id="n-clients"></span></button>
<button class="tab" data-e="credentials">Credentials<span class="n" id="n-credentials"></span></button>
</div>
<div class="bar">
<input id="search" placeholder="Filtern …" />
@@ -112,20 +113,25 @@
const COLS = {
leads: ['lead_id','received_at','client_id','source','name','contact','service_interest','message','status','notified'],
clients: ['client_id','business_name','owner_name','email','phone','niche','tier','status','billing_cycle','monthly_fee_eur','renewal_date','created_at'],
credentials: ['cred_id','client_id','label','username','secret','created_at'],
};
const PK = { leads: 'lead_id', clients: 'client_id' };
const PK = { leads: 'lead_id', clients: 'client_id', credentials: 'cred_id' };
let current = 'leads';
let cache = {};
let revealed = new Set(); // cred_ids currently shown in plaintext (resets on tab switch/reload)
const esc = s => (s==null?'':String(s)).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
function showErr(t){ const m=document.getElementById('msg'); m.textContent=t; m.className='msg err'; }
function clearErr(){ document.getElementById('msg').className='msg'; }
function showOk(t){ const m=document.getElementById('msg'); m.textContent=t; m.className='msg'; m.style.cssText='display:block;background:#e3f6ec;color:#0c6b3c;border:1px solid #b8e6cc;padding:10px 14px;border-radius:9px;font-size:.85rem;margin-bottom:12px;'; setTimeout(clearErr, 1800); }
function clearErr(){ const m=document.getElementById('msg'); m.className='msg'; m.style.cssText=''; }
async function load(entity){
clearErr();
revealed.clear();
document.getElementById('table').innerHTML = '<div class="empty">Lädt …</div>';
try {
const r = await fetch(`${API}/${entity}`);
// Credentials require the token to read (see app.py); harmless to send it always.
const r = await fetch(`${API}/${entity}`, { headers: { 'X-CRM-Token': TOKEN } });
if(!r.ok) throw new Error('HTTP '+r.status);
const d = await r.json();
cache[entity] = d.rows;
@@ -150,6 +156,13 @@
let v = r[c];
if(c==='status' || c==='tier') return `<td><span class="pillv">${esc(v)}</span></td>`;
if((c==='received_at'||c==='created_at') && v) v = String(v).replace('T',' ').slice(0,16);
if(c==='secret') {
const shown = revealed.has(id);
const display = shown ? esc(v) : '••••••••';
return `<td><code>${display}</code> ` +
`<button class="act" title="${shown?'Verbergen':'Anzeigen'}" onclick="toggleSecret('${esc(id)}')">${shown?'🙈':'👁'}</button>` +
`<button class="act" title="Kopieren" onclick="copySecret('${esc(id)}')">📋</button></td>`;
}
return `<td title="${esc(v)}">${esc(v)}</td>`;
}).join('');
return `<tr>${cells}<td class="actcol"><button class="act" title="Bearbeiten" onclick="openForm('${esc(id)}')">Bearbeiten</button><button class="del" title="Löschen" onclick="del('${esc(id)}')">Löschen</button></td></tr>`;
@@ -157,6 +170,19 @@
document.getElementById('table').innerHTML = `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
}
function toggleSecret(id){
if(revealed.has(id)) revealed.delete(id); else revealed.add(id);
render();
}
async function copySecret(id){
const row = (cache.credentials||[]).find(r => r.cred_id === id);
if(!row) return;
try {
await navigator.clipboard.writeText(row.secret || '');
showOk('✓ Passwort kopiert (' + (row.label || row.cred_id) + ')');
} catch(e){ showErr('Kopieren fehlgeschlagen: '+e.message); }
}
async function del(id){
if(!confirm(`${current.slice(0,-1).toUpperCase()}${id}" wirklich löschen?`)) return;
clearErr();
@@ -189,6 +215,10 @@
{k:'services',wide:true},{k:'stack_notes',t:'textarea',wide:true},
{k:'vault_ref'},{k:'notes',t:'textarea',wide:true},
],
credentials: [
{k:'client_id'},{k:'label'},{k:'username'},{k:'secret'},
{k:'notes',t:'textarea',wide:true},
],
};
let editId = null;