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
+12 -3
View File
@@ -53,9 +53,9 @@ def _cell(v):
def mirror_entity(entity):
if SH is None:
return 0
spec = db.TABLES[entity]
if SH is None or spec.get("mirror") is False:
return 0
cols = spec["cols"]
order = LIST_ORDER.get(entity, spec["pk"])
with db.connect() as conn, conn.cursor() as cur:
@@ -87,7 +87,7 @@ threading.Thread(target=_mirror_worker, daemon=True).start()
def mirror_async(entity):
if SH is not None:
if SH is not None and db.TABLES[entity].get("mirror") is not False:
_mirror_q.put(entity)
# Static dashboard, with the CRM token injected so the (basic-auth-gated)
@@ -113,6 +113,7 @@ LIST_ORDER = {
"bookings": "start_time DESC NULLS LAST",
"invoices": "issued_date DESC NULLS LAST",
"activity_log": "ts DESC NULLS LAST",
"credentials": "client_id",
}
@@ -143,6 +144,11 @@ def healthz():
def list_entity(entity):
if entity not in db.TABLES:
return jsonify({"error": "unknown entity"}), 404
# Credentials carry secrets, not just business metadata — require the
# mutation-level token even to read, on top of the Caddy basic-auth every
# other entity's (read-only) list relies on alone.
if entity == "credentials" and not authed():
return jsonify({"error": "forbidden"}), 403
order = LIST_ORDER.get(entity, db.TABLES[entity]["pk"])
with db.connect() as conn, conn.cursor() as cur:
cur.execute(f"SELECT * FROM {entity} ORDER BY {order}")
@@ -198,6 +204,9 @@ def add_entity(entity):
if not row.get("renewal_date") and row.get("start_date"):
row["renewal_date"] = compute_renewal(
row["start_date"], row.get("billing_cycle"))
elif entity == "credentials":
row["cred_id"] = row.get("cred_id") or "CR-" + str(int(time.time() * 1000))
row["created_at"] = row.get("created_at") or datetime.now(timezone.utc)
elif not row.get(pk):
return jsonify({"error": f"{pk} required"}), 400
cols = spec["cols"]
+10
View File
@@ -77,6 +77,16 @@ TABLES = {
"numbers": [],
"bools": [],
},
"credentials": {
# No "tab": never mirrored to Sheets (see "mirror" below) — secrets stay in Postgres only.
"pk": "cred_id",
"cols": ["cred_id", "client_id", "label", "username", "secret", "notes", "created_at"],
"dates": [],
"timestamps": ["created_at"],
"numbers": [],
"bools": [],
"mirror": False,
},
}
+2
View File
@@ -44,6 +44,8 @@ def main():
with conn.cursor() as cur:
cur.execute("TRUNCATE activity_log RESTART IDENTITY")
for entity, spec in db.TABLES.items():
if spec.get("mirror") is False:
continue # e.g. credentials — never lived in the Sheet, nothing to import
records = sh.read_records(spec["tab"])
n = upsert(cur, entity, records)
print(f" {entity:<13} <- {spec['tab']:<13} {n} row(s)")
+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;
+16 -1
View File
@@ -86,6 +86,20 @@ CREATE TABLE IF NOT EXISTS invoices (
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Per-client login credentials (e.g. the auto-generated Easy!Appointments
-- provider login). Deliberately NOT mirrored to Sheets — see db.py TABLES
-- ("mirror": False) — so secrets never leave Postgres.
CREATE TABLE IF NOT EXISTS credentials (
cred_id text PRIMARY KEY,
client_id text,
label text,
username text,
secret text,
notes text,
created_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- keep updated_at fresh on row changes
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
@@ -94,7 +108,7 @@ $$ LANGUAGE plpgsql;
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['clients','leads','projects','bookings','invoices'] LOOP
FOREACH t IN ARRAY ARRAY['clients','leads','projects','bookings','invoices','credentials'] LOOP
EXECUTE format(
'CREATE TRIGGER %I_touch BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION touch_updated_at()',
t, t);
@@ -104,3 +118,4 @@ END $$;
CREATE INDEX IF NOT EXISTS leads_received_idx ON leads (received_at DESC);
CREATE INDEX IF NOT EXISTS clients_status_idx ON clients (status);
CREATE INDEX IF NOT EXISTS activity_ts_idx ON activity_log (ts DESC);
CREATE INDEX IF NOT EXISTS credentials_client_idx ON credentials (client_id);