From 9a66f59bfe14fd232128cffe8fb9c865ccab8374 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 26 Jun 2026 16:24:34 +1000 Subject: [PATCH] feat(auth): staff email+password login + password reset (login stays token-based under the hood) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staff were stuck pasting a raw bearer token with no reset. Now: a proper /login page (email+password) that exchanges to the staff member's existing token (SPA unchanged downstream). PBKDF2 hashing (stdlib, no bcrypt dep). New: POST /auth/login, POST /auth/change-password (self), POST /admin/staff/{id}/password (admin reset). staff gains email(unique)/phone/pay_rate/password_hash. Staff admin UI: add/edit details, set-password, and timecards now show pay (hours Γ— rate). admin.html redirects to /login when unauthed + bounces expired tokens; nav gets Change-password + Sign-out. Owner master token still works (break-glass on the login page). Verified e2e: login, wrong-pw 401, token auth, admin reset invalidates old pw. Co-Authored-By: Claude Opus 4.8 --- SCANGOD_SPINE_MATCH_HANDOVER.md | 72 +++++++++++++++++++++++++ app/admin_routes.py | 46 +++++++++++++--- app/auth.py | 22 ++++++++ app/auth_routes.py | 49 +++++++++++++++++ app/main.py | 10 +++- site/admin.html | 60 +++++++++++++++++---- site/login.html | 94 +++++++++++++++++++++++++++++++++ 7 files changed, 333 insertions(+), 20 deletions(-) create mode 100644 SCANGOD_SPINE_MATCH_HANDOVER.md create mode 100644 app/auth_routes.py create mode 100644 site/login.html diff --git a/SCANGOD_SPINE_MATCH_HANDOVER.md b/SCANGOD_SPINE_MATCH_HANDOVER.md new file mode 100644 index 0000000..2e7d406 --- /dev/null +++ b/SCANGOD_SPINE_MATCH_HANDOVER.md @@ -0,0 +1,72 @@ +# πŸ“š ScanGod β†’ RecordGod: spine-matching update (release_id from spine text, no barcode) + +*From DealGod/ScanGod Claude, 2026-06-26. Builds on `SCANGOD_BRIDGE_BRIEF.md` + your +`SCANGOD_BRIDGE_REPLY.md` β€” the `POST /admin/intake/scan` contract stands, this just changes +**how ScanGod gets `release_id`** and confirms the **minimal payload** John wants for fast pile-scanning.* + +--- + +## The change in one line +The old bridge assumed **barcode β†’ release_id** (you resolve). But a photo of a CD/record **spine** +almost never shows the barcode (it's on the back). So ScanGod now resolves **`release_id` from +spine-visible text** β€” **catalogue number + artist + title** β€” and sends it to you **pre-resolved** +(your "if you've already resolved it, send `release_id` and I use it as-is" path). Barcode becomes a +bonus, not the join key. + +## How ScanGod resolves it (proven against the full Discogs dump on `ultra`, 2026-06-26) +Vision reads each spine β†’ `{artist, title, catno, barcode?}`. Then, against `discogs_full`: + +1. **Catno + title** (the precision key). Catno alone collides globally (e.g. `RCR003` matches dozens + of labels), so we match normalised catno **AND** a trigram-similar title, filter `release_format='CD'`, + and prefer country **AU β†’ US β†’ UK** (John's stock is mostly AU pressings + some US imports). +2. **Artist + title fallback** (no readable catno) β†’ `master_id` β†’ its CD versions β†’ same AUβ†’USβ†’UK pick. + This also gives a **CD-version count + country list** ("11 CD versions across AU/BR/EU/JP/RU/US"). + +Verified on a real crate photo: + +| Spine | catno | β†’ release_id | country | +|---|---|---|---| +| Def FX β€” Water | PHMCD-9 | **526717** | Australia (CD) | +| Falling Joys β€” Black Bandages | VOLTCD53 | **1218707** | Australia (CD) | +| Dreamkillers β€” Pockets Of Water | RCR003 | **8444048** | Australia (CD) β€” disambiguated from ~30 RCR003 collisions by title | +| Bad Religion β€” No Control | *(no catno)* | **8685285** | Australia (CD) via master 58411 | + +Unreadable / not-in-Discogs spines (logo-only art, obscure pressings) β†’ no `release_id`; ScanGod sends +title+artist+kind and a Discogs **search link** instead (your non-record / unresolved path already covers this). + +--- + +## The minimal payload John wants (per physical item) +Everything else is a Discogs lookup off `release_id`, so the handoff is tiny: + +```jsonc +{ + "release_id": 8444048, // ScanGod-resolved (or null β†’ you fall back to barcode/title) + "media_condition":"VG+", // human-set at the bench + "sleeve_condition":"VG", // human-set + "price": 18.00, // optional β€” DealGod cross-store median suggestion (human edits) + "comment": "small spine crack", // optional free text + "actual_weight_g": 98 // MEASURED on the scale (overrides your 280g default) β€” John is + // weighing + photographing as he scans +} +``` + +This is a strict subset of the `items[]` you already accept on `POST /admin/intake/scan` +(`release_id` / `condition` / `sleeve` / `price` / `notes` / `weight_g`). **Nothing new to build** β€” +ScanGod just populates `release_id` directly and leaves `barcode` empty. + +## What (if anything) you might want to do +- **Nothing required** β€” the live endpoint already takes `release_id`-as-is + the fields above. +- **Optional**: mirror the **catno+title matcher** inside RecordGod's own Intake (you have + `disc_release` + `disc_release_identifier` locally). Same trick: normalise catno (`[^A-Za-z0-9]β†’''`), + require a trigram-similar title, prefer AU. Lets your manual intake resolve from a catalogue number, + not just a barcode. Happy to share the exact SQL. +- **Country preference** is ScanGod's pick (AUβ†’USβ†’UK); your reviewer can always override the release. + +## Auth / scope (unchanged, already agreed) +`Authorization: Bearer ` β†’ your `require_token` β†’ `store_id`. +Token lives in DealGod's vault, never the client. **This intake path is the enterprise/RecordGod-staff +feature** β€” but the *matching* (spine β†’ release_id + price/version intel + Discogs link) ships to **all +ScanGod customers**; only the "push into inventory" button is gated. + +β€” ScanGod (DealGod). Ping back in this dir if the payload shape needs anything else. diff --git a/app/admin_routes.py b/app/admin_routes.py index aeb9416..4dcaa7c 100644 --- a/app/admin_routes.py +++ b/app/admin_routes.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from sqlalchemy import text from . import vault -from .auth import require_token, require_admin +from .auth import require_token, require_admin, hash_password from .db import get_db # The MEGA admin β€” back-office cockpit, all admin-gated. Inspired by WowPlatter's wp-admin nav @@ -45,18 +45,26 @@ async def me(ident=Depends(require_token), db=Depends(get_db)): class StaffIn(BaseModel): name: str role: str = "staff" + email: str | None = None + phone: str | None = None + pay_rate: float | None = None + password: str | None = None class StaffEditIn(BaseModel): name: str | None = None role: str | None = None active: bool | None = None + email: str | None = None + phone: str | None = None + pay_rate: float | None = None @router.get("/staff") async def staff_list(ident=Depends(require_admin), db=Depends(get_db)): rows = await db.execute(text( - "SELECT id, name, role, active, created_at, last_seen FROM staff ORDER BY active DESC, name")) + "SELECT id, name, role, active, email, phone, pay_rate, password_hash IS NOT NULL AS has_password, " + "created_at, last_seen FROM staff ORDER BY active DESC, name")) return {"staff": [dict(r) for r in rows.mappings()]} @@ -66,11 +74,31 @@ async def staff_add(body: StaffIn, ident=Depends(require_admin), db=Depends(get_ raise HTTPException(400, "name required") role = body.role if body.role in ("admin", "staff") else "staff" token = "rg_" + secrets.token_urlsafe(18) + ph = hash_password(body.password) if body.password else None r = (await db.execute(text( - "INSERT INTO staff (name, token, role) VALUES (:n, :t, :r) RETURNING id"), - {"n": body.name.strip(), "t": token, "r": role})).scalar() + "INSERT INTO staff (name, token, role, email, phone, pay_rate, password_hash) " + "VALUES (:n, :t, :r, :e, :p, :pr, :ph) RETURNING id"), + {"n": body.name.strip(), "t": token, "r": role, "e": (body.email or None), + "p": body.phone, "pr": body.pay_rate, "ph": ph})).scalar() await db.commit() - return {"ok": True, "id": r, "token": token} # token returned ONCE β€” they paste it into /search to log in + return {"ok": True, "id": r, "token": token} # token returned ONCE (break-glass); normal sign-in = email + password + + +class SetPwIn(BaseModel): + password: str + + +@router.post("/staff/{sid}/password") +async def staff_set_password(sid: int, body: SetPwIn, ident=Depends(require_admin), db=Depends(get_db)): + """Admin sets/resets a staff member's password (e.g. onboarding, or a forgotten password).""" + if len(body.password) < 6: + raise HTTPException(400, "password too short (min 6 characters)") + r = await db.execute(text("UPDATE staff SET password_hash=:h WHERE id=:i"), + {"h": hash_password(body.password), "i": sid}) + await db.commit() + if not r.rowcount: + raise HTTPException(404, "not found") + return {"ok": True} @router.post("/staff/{sid}") @@ -153,10 +181,12 @@ async def timecards(days: int = Query(14), staff_id: int | None = None, FROM staff_shift sh JOIN staff st ON st.id = sh.staff_id WHERE {w} ORDER BY sh.clock_in DESC LIMIT 500"""), params)).mappings()] totals = [dict(r) for r in (await db.execute(text(f""" - SELECT sh.staff_id, st.name, count(*) AS shifts, - round((sum(extract(epoch FROM (coalesce(sh.clock_out, now()) - sh.clock_in)))/3600.0)::numeric, 2) AS hours + SELECT sh.staff_id, st.name, st.pay_rate, count(*) AS shifts, + round((sum(extract(epoch FROM (coalesce(sh.clock_out, now()) - sh.clock_in)))/3600.0)::numeric, 2) AS hours, + round((sum(extract(epoch FROM (coalesce(sh.clock_out, now()) - sh.clock_in)))/3600.0 + * coalesce(st.pay_rate, 0))::numeric, 2) AS pay FROM staff_shift sh JOIN staff st ON st.id = sh.staff_id - WHERE {w} GROUP BY sh.staff_id, st.name ORDER BY hours DESC"""), params)).mappings()] + WHERE {w} GROUP BY sh.staff_id, st.name, st.pay_rate ORDER BY hours DESC"""), params)).mappings()] return {"shifts": shifts, "totals": totals, "days": days} diff --git a/app/auth.py b/app/auth.py index e3da26a..94444c4 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,9 +1,31 @@ +import hashlib +import hmac import os +import secrets + from fastapi import Header, HTTPException, Depends from sqlalchemy import text from .db import get_db + +# Password hashing β€” stdlib PBKDF2 (no bcrypt/passlib in the image, no new dependency). +def hash_password(pw: str) -> str: + salt = secrets.token_bytes(16) + dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, 200_000) + return f"pbkdf2_sha256$200000${salt.hex()}${dk.hex()}" + + +def verify_password(pw: str, stored: str | None) -> bool: + if not stored: + return False + try: + _algo, iters, salt_hex, hash_hex = stored.split("$") + dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), bytes.fromhex(salt_hex), int(iters)) + return hmac.compare_digest(dk.hex(), hash_hex) + except Exception: + return False + # Single-tenant v1: one store. The env token is the OWNER (always admin); staff get their own # tokens from the `staff` table with a role (admin | staff). Role gates the sensitive endpoints. STORE = os.getenv("RECORDGOD_STORE", "Monster Robot Party") diff --git a/app/auth_routes.py b/app/auth_routes.py new file mode 100644 index 0000000..0f4ee81 --- /dev/null +++ b/app/auth_routes.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import text + +from .auth import hash_password, verify_password, require_token +from .db import get_db + +# Staff sign-in: email + password β†’ the staff member's bearer token (the SPA stores it as before, +# so nothing downstream changes β€” this just puts a friendly credential in front of the token). +router = APIRouter(prefix="/auth", tags=["auth"]) + + +class LoginIn(BaseModel): + email: str + password: str + + +@router.post("/login") +async def login(body: LoginIn, db=Depends(get_db)): + row = (await db.execute(text( + "SELECT id, name, role, token, password_hash FROM staff WHERE lower(email)=lower(:e) AND active"), + {"e": body.email.strip()})).mappings().first() + if not row or not verify_password(body.password, row["password_hash"]): + raise HTTPException(401, "wrong email or password") + await db.execute(text("UPDATE staff SET last_seen=now() WHERE id=:i"), {"i": row["id"]}) + await db.commit() + return {"ok": True, "token": row["token"], "name": row["name"], "role": row["role"]} + + +class ChangePwIn(BaseModel): + current_password: str | None = None + new_password: str + + +@router.post("/change-password") +async def change_password(body: ChangePwIn, ident=Depends(require_token), db=Depends(get_db)): + sid = ident.get("staff_id") + if not sid: + raise HTTPException(400, "the owner signs in with the master token; only staff have passwords") + if len(body.new_password) < 6: + raise HTTPException(400, "password too short (min 6 characters)") + cur = (await db.execute(text("SELECT password_hash FROM staff WHERE id=:i"), {"i": sid})).scalar() + # if a password is already set, the old one must check out; first-time set needs no current pw + if cur and not verify_password(body.current_password or "", cur): + raise HTTPException(401, "current password is incorrect") + await db.execute(text("UPDATE staff SET password_hash=:h WHERE id=:i"), + {"h": hash_password(body.new_password), "i": sid}) + await db.commit() + return {"ok": True} diff --git a/app/main.py b/app/main.py index 2ebb96d..2f92912 100644 --- a/app/main.py +++ b/app/main.py @@ -26,6 +26,7 @@ from .navigator_routes import router as navigator_router # noqa: E402 from .collections_routes import router as collections_router # noqa: E402 from .disc_images import router as disc_images_router # noqa: E402 from .intake_routes import router as intake_router # noqa: E402 +from .auth_routes import router as auth_router # noqa: E402 from .db import engine # noqa: E402 from sqlalchemy import text as _sqltext # noqa: E402 @@ -41,6 +42,7 @@ app.include_router(navigator_router) app.include_router(collections_router) app.include_router(disc_images_router) app.include_router(intake_router) +app.include_router(auth_router) _STARTUP_DDL = [ @@ -98,6 +100,12 @@ _STARTUP_DDL = [ "CREATE INDEX IF NOT EXISTS disc_release_search_trgm ON disc_release USING gin (search_text gin_trgm_ops)", "CREATE INDEX IF NOT EXISTS inventory_title_trgm ON inventory USING gin (title gin_trgm_ops)", "CREATE INDEX IF NOT EXISTS disc_release_format_release_id_idx ON disc_release_format(release_id)", + # staff sign-in (email + password) + details for the timesheet + "ALTER TABLE staff ADD COLUMN IF NOT EXISTS email text", + "ALTER TABLE staff ADD COLUMN IF NOT EXISTS phone text", + "ALTER TABLE staff ADD COLUMN IF NOT EXISTS pay_rate numeric(10,2)", + "ALTER TABLE staff ADD COLUMN IF NOT EXISTS password_hash text", + "CREATE UNIQUE INDEX IF NOT EXISTS staff_email_uniq ON staff (lower(email)) WHERE email IS NOT NULL", ] @@ -126,7 +134,7 @@ def _page(filename): return _serve -for _stub in ("shop", "builder", "admin", "dash", "pos", "search", "records", "wantlist", "kiosk"): +for _stub in ("shop", "builder", "admin", "dash", "pos", "search", "records", "wantlist", "kiosk", "login"): app.add_api_route(f"/{_stub}", _page(_stub + ".html"), methods=["GET"], include_in_schema=False) # public storefront release detail β€” release.html reads the id from the path app.add_api_route("/release/{release_id}", _page("release.html"), methods=["GET"], include_in_schema=False) diff --git a/site/admin.html b/site/admin.html index f9e1376..a6b5a9e 100644 --- a/site/admin.html +++ b/site/admin.html @@ -80,6 +80,8 @@ πŸ”Œ Connections πŸ–₯️ System 🎧 Virtual store β†— + πŸ”‘ Change password + πŸšͺ Sign out ← Home
@@ -95,10 +97,11 @@ async function api(path){ const r = await fetch('/admin'+path, { headers: hdr() let ROLE = 'staff'; async function signin(){ - TOKEN = $('#tok').value.trim(); - let me; try { me = await api('/me'); } catch(e){ $('#gerr').textContent = 'invalid token'; return; } + TOKEN = $('#tok') ? $('#tok').value.trim() : TOKEN; + let me; try { me = await api('/me'); } catch(e){ localStorage.removeItem('rg_token'); location.replace('/login'); return; } ROLE = me.role || 'staff'; localStorage.setItem('rg_token', TOKEN); + window.ME = me; // staff don't see admin-only nav (API keys / staff admin); the backend enforces it too document.querySelectorAll('[data-admin]').forEach(el => el.style.display = ROLE==='admin' ? '' : 'none'); $('#gate').style.display='none'; $('#app').style.display='grid'; @@ -438,8 +441,10 @@ async function vOrders(){ // ---------- Staff (admin only) ---------- async function vStaff(){ $('#content').innerHTML = '

Staff

' - + '
Operators get a sign-in token + role. Staff can run the store (search, scan, inventory, prices, labels) but can\'t see API keys / connections.
' - + '

Add staff

' + + '
Staff sign in at recordgod.com/login with their email + password. They can run the store (search, scan, inventory, prices, labels) but can\'t see API keys / connections.
' + + '

Add staff

' + + '
' + + '
' + '
loading…
' + '
loading hours…
'; loadStaff(); loadTimecards(); @@ -447,10 +452,10 @@ async function vStaff(){ const fmtDT = s => s ? new Date(s).toLocaleString(undefined,{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : ''; async function loadTimecards(){ const d = await api('/timecards?days=14'); - const tot = (d.totals||[]).map(t=>`${escapeH(t.name)}${t.shifts}${t.hours} h`).join(''); + const tot = (d.totals||[]).map(t=>`${escapeH(t.name)}${t.shifts}${t.hours} h${t.pay_rate!=null?money(t.pay):'β€”'}`).join(''); const sh = (d.shifts||[]).map(s=>`${escapeH(s.name)}${fmtDT(s.clock_in)}${s.clock_out?''+fmtDT(s.clock_out)+'':'● on shift'}${s.hours} h`).join(''); $('#timecards').innerHTML = '

Timecards β€” last 14 days

' - + `${tot||''}
StaffShiftsHours
no shifts yet
` + + `${tot||''}
StaffShiftsHoursPay
no shifts yet
` + '

Recent shifts

' + `${sh||''}
StaffClock inClock outHours
none
`; } @@ -458,17 +463,42 @@ async function loadStaff(){ const d = await api('/staff'); const rows = (d.staff||[]).map(s=>` ${escapeH(s.name)}${s.active?'':' inactive'} + ${escapeH(s.email||'β€”')}${s.email?(s.has_password?' ●':' β—‹'):''} + ${s.pay_rate!=null?money(s.pay_rate)+'/hr':'β€”'} ${s.last_seen?String(s.last_seen).slice(0,10):'never'} - + + + `).join(''); - $('#staffRows').innerHTML = `${rows||''}
NameRoleLast seen
no staff yet β€” add one above
`; + $('#staffRows').innerHTML = `${rows||''}
NameEmailPayRoleLast seen
no staff yet β€” add one above
`; +} +async function staffEditDetails(id,cur){ + const email=prompt('Email (their login):',cur.email||''); if(email===null) return; + const phone=prompt('Phone:',cur.phone||'')||null; + const pay=prompt('Pay rate ($/hr):',cur.pay_rate!=null?cur.pay_rate:''); + const body={email:email.trim()||null,phone:phone,pay_rate:pay===null||pay===''?null:parseFloat(pay)}; + const r=await fetch('/admin/staff/'+id,{method:'POST',headers:hdr(),body:JSON.stringify(body)}); + if(r.ok) loadStaff(); else alert('Failed: '+((await r.json().catch(()=>({}))).detail||r.status)); +} +async function staffSetPw(id,name){ + const pw = prompt('Set a password for '+name+' (min 6 characters):'); if(pw===null) return; + if(pw.length<6){ alert('Too short β€” min 6 characters.'); return; } + const r = await fetch('/admin/staff/'+id+'/password',{method:'POST',headers:hdr(),body:JSON.stringify({password:pw})}); + if(r.ok){ alert(name+' can now sign in with their email + this password.'); loadStaff(); } + else alert('Failed: '+((await r.json().catch(()=>({}))).detail||r.status)); } function staffToken(name,token){ return `
${escapeH(name)} β€” sign-in token (shown once, copy it now):
${escapeH(token)}
They paste this at recordgod.com/search to sign in.
`; } async function staffAdd(){ const name=$('#stName').value.trim(); if(!name){ return; } - const d=await fetch('/admin/staff',{method:'POST',headers:hdr(),body:JSON.stringify({name,role:$('#stRole').value})}).then(r=>r.json()); - $('#stName').value=''; $('#stNew').innerHTML=staffToken(name,d.token); loadStaff(); + const body={name, role:$('#stRole').value, email:$('#stEmail').value.trim()||null, + phone:$('#stPhone').value.trim()||null, pay_rate:parseFloat($('#stPay').value)||null, password:$('#stPw').value||null}; + const d=await fetch('/admin/staff',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(r=>r.json()); + ['stName','stEmail','stPhone','stPay','stPw'].forEach(i=>$('#'+i).value=''); + $('#stNew').innerHTML = body.email && body.password + ? `
${escapeH(name)} is ready β€” they sign in at recordgod.com/login with ${escapeH(body.email)} + the password you set.
` + : staffToken(name,d.token); + loadStaff(); } async function staffRole(id,role){ await fetch('/admin/staff/'+id,{method:'POST',headers:hdr(),body:JSON.stringify({role})}); } async function staffActive(id,active){ await fetch('/admin/staff/'+id,{method:'POST',headers:hdr(),body:JSON.stringify({active})}); loadStaff(); } @@ -836,7 +866,15 @@ async function mapFind(){ function vSoon(title, body){ return () => { $('#content').innerHTML = `

${title}

${body}

Coming next.
`; }; } function escapeH(s){ return (s==null?'':String(s)).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); } -if(TOKEN){ $('#tok').value = TOKEN; signin(); } +function signOut(){ localStorage.removeItem('rg_token'); localStorage.removeItem('rg_name'); localStorage.removeItem('rg_role'); location.replace('/login'); } +async function changeMyPassword(){ + const np = prompt('New password (min 6 characters):'); if(np===null) return; + if(np.length<6){ alert('Too short β€” min 6 characters.'); return; } + const cur = prompt('Your current password (leave blank if none set yet):') || ''; + const r = await fetch('/auth/change-password',{method:'POST',headers:hdr(),body:JSON.stringify({current_password:cur,new_password:np})}); + alert(r.ok ? 'Password updated.' : 'Failed: ' + ((await r.json().catch(()=>({}))).detail || r.status)); +} +if(TOKEN){ signin(); } else { location.replace('/login'); } diff --git a/site/login.html b/site/login.html new file mode 100644 index 0000000..d487111 --- /dev/null +++ b/site/login.html @@ -0,0 +1,94 @@ + + + + + +Sign in Β· RecordGod + + + +
+
RecordGod
+
Staff sign-in
+ +
+ + + + + +
+ +
+ + + +
+ +
+ +
+ + + +