diff --git a/app/admin_routes.py b/app/admin_routes.py
index d134dfc..f41e58c 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
+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)):
diff --git a/app/auth.py b/app/auth.py
index cc4c45f..e3da26a 100644
--- a/app/auth.py
+++ b/app/auth.py
@@ -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
diff --git a/app/main.py b/app/main.py
index 34ba088..533f5c1 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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)",
diff --git a/app/settings_routes.py b/app/settings_routes.py
index 707dce8..0b4f708 100644
--- a/app/settings_routes.py
+++ b/app/settings_routes.py
@@ -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":
diff --git a/site/admin.html b/site/admin.html
index 1d02e9b..0935215 100644
--- a/site/admin.html
+++ b/site/admin.html
@@ -73,8 +73,9 @@
🗺️ Store map🗄️ Crates📈 Reports
-