feat(auth): staff accounts + roles — operators run the store, admins keep the keys

Token resolver now checks the env owner-token (admin) then a staff table (name/token/
role). New require_admin gates /settings/* (API keys/connections) + staff CRUD; everything
operational (search/scan/inventory/labels/intake) stays on require_token so staff can do it.
admin.html: /admin/me drives ROLE; data-admin nav (Staff + Connections) hidden for staff +
go() blocks the views; new Staff tab to add operators, set role, reset/show token once,
deactivate. Tokens shown once on create.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-23 15:33:19 +10:00
parent 13e5200b8d
commit 5e2a9e7ab1
5 changed files with 139 additions and 23 deletions

View File

@ -6,7 +6,7 @@ from pydantic import BaseModel
from sqlalchemy import text
from . import vault
from .auth import require_token
from .auth import require_token, require_admin
from .db import get_db
# The MEGA admin — back-office cockpit, all admin-gated. Inspired by WowPlatter's wp-admin nav
@ -32,6 +32,72 @@ async def stats(ident=Depends(require_token), db=Depends(get_db)):
return {"ok": True, **dict(row)}
@router.get("/me")
async def me(ident=Depends(require_token), db=Depends(get_db)):
"""Who am I + role — the dash hides admin-only tabs for staff (the backend still enforces)."""
if ident.get("staff_id"):
await db.execute(text("UPDATE staff SET last_seen=now() WHERE id=:i"), {"i": ident["staff_id"]})
await db.commit()
return {"name": ident.get("name"), "role": ident.get("role"), "staff_id": ident.get("staff_id")}
# ── Staff accounts (ADMIN ONLY) — operators get a token + role; tokens shown once on create/reset ──
class StaffIn(BaseModel):
name: str
role: str = "staff"
class StaffEditIn(BaseModel):
name: str | None = None
role: str | None = None
active: bool | 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"))
return {"staff": [dict(r) for r in rows.mappings()]}
@router.post("/staff")
async def staff_add(body: StaffIn, ident=Depends(require_admin), db=Depends(get_db)):
if not body.name.strip():
raise HTTPException(400, "name required")
role = body.role if body.role in ("admin", "staff") else "staff"
token = "rg_" + secrets.token_urlsafe(18)
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()
await db.commit()
return {"ok": True, "id": r, "token": token} # token returned ONCE — they paste it into /search to log in
@router.post("/staff/{sid}")
async def staff_edit(sid: int, body: StaffEditIn, ident=Depends(require_admin), db=Depends(get_db)):
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if "role" in fields and fields["role"] not in ("admin", "staff"):
raise HTTPException(400, "bad role")
if not fields:
return {"ok": True, "unchanged": True}
sets = ", ".join(f"{k} = :{k}" for k in fields)
r = await db.execute(text(f"UPDATE staff SET {sets} WHERE id = :i"), {**fields, "i": sid})
await db.commit()
if not r.rowcount:
raise HTTPException(404, "not found")
return {"ok": True}
@router.post("/staff/{sid}/token")
async def staff_reset_token(sid: int, ident=Depends(require_admin), db=Depends(get_db)):
token = "rg_" + secrets.token_urlsafe(18)
r = await db.execute(text("UPDATE staff SET token = :t WHERE id = :i"), {"t": token, "i": sid})
await db.commit()
if not r.rowcount:
raise HTTPException(404, "not found")
return {"ok": True, "token": token}
@router.get("/inventory")
async def inventory(q: str = Query(""), kind: str = Query(""), crate_id: int | None = Query(None),
page: int = Query(1, ge=1), ident=Depends(require_token), db=Depends(get_db)):

View File

@ -1,22 +1,34 @@
import os
from fastapi import Header, HTTPException
from fastapi import Header, HTTPException, Depends
from sqlalchemy import text
# Single-tenant v1: one store, one token from the environment.
from .db import get_db
# 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")
PLAN = os.getenv("RECORDGOD_PLAN", "enterprise")
TOKEN = os.getenv("RECORDGOD_TOKEN") # no default — unset means "deny everything", not "open"
async def require_token(authorization: str | None = Header(None)):
# ponytail: Bearer per WOWPLATTER_BRIEF. The auth scheme is still an OPEN decision
# (DealGod's X-API-Key vs this Bearer) — keep the resolver in this one function so
# flipping it, or swapping the env token for a tokens table / shared DealGod session,
# is a single-file change.
if not TOKEN:
raise HTTPException(503, "RECORDGOD_TOKEN not configured")
async def require_token(authorization: str | None = Header(None), db=Depends(get_db)):
# Bearer per WOWPLATTER_BRIEF. Owner env-token first (no DB hit); then the staff table.
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "missing bearer token")
if authorization[7:].strip() != TOKEN:
raise HTTPException(401, "invalid token")
# ponytail: store_id is always 1 for now — the seam for multi-shop, not the room.
return {"store": STORE, "plan": PLAN, "store_id": 1}
tok = authorization[7:].strip()
base = {"store": STORE, "plan": PLAN, "store_id": 1}
if TOKEN and tok == TOKEN:
return {**base, "role": "admin", "name": "Owner", "staff_id": None}
row = (await db.execute(
text("SELECT id, name, role FROM staff WHERE token = :t AND active"), {"t": tok}
)).mappings().first()
if row:
return {**base, "role": row["role"], "name": row["name"], "staff_id": row["id"]}
raise HTTPException(401, "invalid token")
async def require_admin(ident=Depends(require_token)):
"""Gate the sensitive surface (API keys / connections / staff admin) to admins only."""
if ident.get("role") != "admin":
raise HTTPException(403, "admin only")
return ident

View File

@ -43,6 +43,8 @@ app.include_router(disc_images_router)
_STARTUP_DDL = [
"CREATE TABLE IF NOT EXISTS store_config (store_id int PRIMARY KEY, config jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())",
# staff accounts — each has a bearer token + role (admin sees API keys/connections, staff don't)
"CREATE TABLE IF NOT EXISTS staff (id bigserial PRIMARY KEY, name text NOT NULL, token text UNIQUE NOT NULL, role text NOT NULL DEFAULT 'staff', active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), last_seen timestamptz)",
# public 'request a record we don't stock' intake (storefront wantlist; email-keyed, guest or customer)
"CREATE TABLE IF NOT EXISTS wantlist (id bigserial PRIMARY KEY, release_id int, artist text NOT NULL DEFAULT '', title text NOT NULL DEFAULT '', name text, phone text, email text NOT NULL, format text, max_price numeric(10,2), delivery_preference text DEFAULT 'either', postcode text, notes text, status text NOT NULL DEFAULT 'pending', created_at timestamptz NOT NULL DEFAULT now(), actioned_at timestamptz)",
"CREATE INDEX IF NOT EXISTS idx_wantlist_status ON wantlist (status, created_at DESC)",

View File

@ -5,10 +5,11 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from . import vault
from .auth import require_token
from .auth import require_admin
from .db import get_db
# Admin-gated. The dash POSTs credentials in; values are encrypted at rest and NEVER returned.
# ADMIN-ONLY (require_admin): API keys / connections. Staff tokens get 403 here.
# The dash POSTs credentials in; values are encrypted at rest and NEVER returned.
router = APIRouter(prefix="/settings", tags=["settings"])
# The credentials the dash knows how to collect (name → human label).
@ -30,7 +31,7 @@ class SecretIn(BaseModel):
@router.get("/secrets")
async def list_secrets(ident=Depends(require_token), db=Depends(get_db)):
async def list_secrets(ident=Depends(require_admin), db=Depends(get_db)):
have = {s["name"]: s["updated_at"] for s in await vault.list_secret_names(db)}
return {"ok": True, "fields": [
{"name": n, "label": label, "set": n in have, "updated_at": have.get(n)}
@ -39,7 +40,7 @@ async def list_secrets(ident=Depends(require_token), db=Depends(get_db)):
@router.post("/secrets")
async def save_secret(body: SecretIn, ident=Depends(require_token), db=Depends(get_db)):
async def save_secret(body: SecretIn, ident=Depends(require_admin), db=Depends(get_db)):
if body.name not in KNOWN:
raise HTTPException(400, "unknown secret name")
if not body.value.strip():
@ -52,7 +53,7 @@ async def save_secret(body: SecretIn, ident=Depends(require_token), db=Depends(g
@router.post("/test/{service}")
async def test_connection(service: str, ident=Depends(require_token), db=Depends(get_db)):
async def test_connection(service: str, ident=Depends(require_admin), db=Depends(get_db)):
"""Validate stored credentials against the live service — so 'connected' means connected."""
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
if service == "discogs":

View File

@ -73,8 +73,9 @@
<a data-v="storemap">🗺️ Store map</a>
<a data-v="crates">🗄️ Crates</a>
<a data-v="reports">📈 Reports</a>
<div class="sec">System</div>
<a data-v="connections">🔌 Connections</a>
<div class="sec" data-admin>System</div>
<a data-v="staff" data-admin>🧑‍💼 Staff</a>
<a data-v="connections" data-admin>🔌 Connections</a>
<a href="/store/">🎧 Virtual store ↗</a>
<a href="/">← Home</a>
</nav>
@ -89,18 +90,23 @@ const money = n => n==null ? '—' : '$' + Number(n).toLocaleString(undefined,{m
async function api(path){ const r = await fetch('/admin'+path, { headers: hdr() }); if(!r.ok) throw new Error(r.status); return r.json(); }
let ROLE = 'staff';
async function signin(){
TOKEN = $('#tok').value.trim();
try { await api('/stats'); } catch(e){ $('#gerr').textContent = 'invalid token'; return; }
let me; try { me = await api('/me'); } catch(e){ $('#gerr').textContent = 'invalid token'; return; }
ROLE = me.role || 'staff';
localStorage.setItem('rg_token', TOKEN);
// 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';
go('dashboard');
}
document.querySelectorAll('nav a[data-v]').forEach(a => a.onclick = () => go(a.dataset.v));
function go(v){
if(ROLE!=='admin' && (v==='connections'||v==='staff')) v='dashboard'; // staff can't reach admin views
document.querySelectorAll('nav a[data-v]').forEach(a => a.classList.toggle('on', a.dataset.v===v));
({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn}[v])();
({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn, staff:vStaff}[v])();
}
// ---------- Dashboard ----------
@ -415,6 +421,35 @@ async function vOrders(){
+ `<table><thead><tr><th>Order</th><th>Date</th><th>Customer</th><th>Items</th><th>Total</th><th>Status</th></tr></thead><tbody>${rows||'<tr><td colspan=6 class=muted>no orders</td></tr>'}</tbody></table>`;
}
// ---------- Staff (admin only) ----------
async function vStaff(){
$('#content').innerHTML = '<h2>Staff</h2>'
+ '<div class="muted">Operators get a sign-in token + role. Staff can run the store (search, scan, inventory, prices, labels) but can\'t see API keys / connections.</div>'
+ '<div class="card" style="margin-top:12px"><h3>Add staff</h3><div class="bar"><input id="stName" placeholder="name"><select id="stRole"><option value="staff">Staff</option><option value="admin">Admin</option></select><button onclick="staffAdd()">Add</button></div><div id="stNew" style="margin-top:10px"></div></div>'
+ '<div id="staffRows" class="muted" style="margin-top:12px">loading…</div>';
loadStaff();
}
async function loadStaff(){
const d = await api('/staff');
const rows = (d.staff||[]).map(s=>`<tr>
<td>${escapeH(s.name)}${s.active?'':' <span class="pill out">inactive</span>'}</td>
<td><select onchange="staffRole(${s.id},this.value)"><option value="staff"${s.role==='staff'?' selected':''}>Staff</option><option value="admin"${s.role==='admin'?' selected':''}>Admin</option></select></td>
<td class="muted">${s.last_seen?String(s.last_seen).slice(0,10):'never'}</td>
<td style="white-space:nowrap"><button class="ghost" onclick="staffReset(${s.id})">🔑 reset token</button>
<button class="ghost" onclick="staffActive(${s.id},${s.active?'false':'true'})">${s.active?'Deactivate':'Reactivate'}</button></td></tr>`).join('');
$('#staffRows').innerHTML = `<table><thead><tr><th>Name</th><th>Role</th><th>Last seen</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=4 class=muted>no staff yet — add one above</td></tr>'}</tbody></table>`;
}
function staffToken(name,token){ return `<div style="background:#fdeef6;border:1px solid var(--pink);border-radius:8px;padding:11px"><b>${escapeH(name)}</b> — sign-in token (shown once, copy it now):<div style="margin:6px 0"><code style="font-size:14px;user-select:all;word-break:break-all">${escapeH(token)}</code></div><div class="muted">They paste this at <b>recordgod.com/search</b> to sign in.</div></div>`; }
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();
}
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(); }
async function staffReset(id){ if(!confirm('Reset this token? Their current one stops working immediately.')) return;
const d=await fetch('/admin/staff/'+id+'/token',{method:'POST',headers:hdr()}).then(r=>r.json()); $('#stNew').innerHTML=staffToken('Reset',d.token); }
// ---------- Connections ----------
async function vConn(){
const m = $('#content');