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:
+12
-3
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user