feat(auth): staff email+password login + password reset (login stays token-based under the hood)
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 <noreply@anthropic.com>
This commit is contained in:
parent
f9ab9527f3
commit
9a66f59bfe
72
SCANGOD_SPINE_MATCH_HANDOVER.md
Normal file
72
SCANGOD_SPINE_MATCH_HANDOVER.md
Normal file
@ -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 <Monster Robot's RecordGod store token>` → 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.
|
||||
@ -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}
|
||||
|
||||
|
||||
|
||||
22
app/auth.py
22
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")
|
||||
|
||||
49
app/auth_routes.py
Normal file
49
app/auth_routes.py
Normal file
@ -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}
|
||||
10
app/main.py
10
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)
|
||||
|
||||
@ -80,6 +80,8 @@
|
||||
<a data-v="connections" data-admin>🔌 Connections</a>
|
||||
<a data-v="system" data-admin>🖥️ System</a>
|
||||
<a href="/store/">🎧 Virtual store ↗</a>
|
||||
<a onclick="changeMyPassword()" style="cursor:pointer">🔑 Change password</a>
|
||||
<a onclick="signOut()" style="cursor:pointer">🚪 Sign out</a>
|
||||
<a href="/">← Home</a>
|
||||
</nav>
|
||||
<main id="content"></main>
|
||||
@ -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 = '<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 class="muted">Staff sign in at <b>recordgod.com/login</b> with their email + password. They 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" style="flex-wrap:wrap;gap:8px"><input id="stName" placeholder="name"><input id="stEmail" type="email" placeholder="email (their login)"><input id="stPhone" placeholder="phone"><input id="stPay" type="number" step="0.01" placeholder="$/hr" style="width:90px"><input id="stPw" type="text" placeholder="set password" style="width:130px"><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>'
|
||||
+ '<div id="timecards" class="muted" style="margin-top:18px">loading hours…</div>';
|
||||
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=>`<tr><td>${escapeH(t.name)}</td><td>${t.shifts}</td><td class="price"><b>${t.hours}</b> h</td></tr>`).join('');
|
||||
const tot = (d.totals||[]).map(t=>`<tr><td>${escapeH(t.name)}</td><td>${t.shifts}</td><td class="price"><b>${t.hours}</b> h</td><td class="price">${t.pay_rate!=null?money(t.pay):'<span class=muted>—</span>'}</td></tr>`).join('');
|
||||
const sh = (d.shifts||[]).map(s=>`<tr><td>${escapeH(s.name)}</td><td class="muted">${fmtDT(s.clock_in)}</td><td>${s.clock_out?'<span class="muted">'+fmtDT(s.clock_out)+'</span>':'<span style="color:var(--ok)">● on shift</span>'}</td><td>${s.hours} h</td></tr>`).join('');
|
||||
$('#timecards').innerHTML = '<h3>Timecards — last 14 days</h3>'
|
||||
+ `<table><thead><tr><th>Staff</th><th>Shifts</th><th>Hours</th></tr></thead><tbody>${tot||'<tr><td colspan=3 class=muted>no shifts yet</td></tr>'}</tbody></table>`
|
||||
+ `<table><thead><tr><th>Staff</th><th>Shifts</th><th>Hours</th><th>Pay</th></tr></thead><tbody>${tot||'<tr><td colspan=4 class=muted>no shifts yet</td></tr>'}</tbody></table>`
|
||||
+ '<h3 style="margin-top:14px">Recent shifts</h3>'
|
||||
+ `<table><thead><tr><th>Staff</th><th>Clock in</th><th>Clock out</th><th>Hours</th></tr></thead><tbody>${sh||'<tr><td colspan=4 class=muted>none</td></tr>'}</tbody></table>`;
|
||||
}
|
||||
@ -458,17 +463,42 @@ 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 class="muted">${escapeH(s.email||'—')}${s.email?(s.has_password?' <span style="color:var(--ok)">●</span>':' <span style="color:var(--warn)" title="no password set">○</span>'):''}</td>
|
||||
<td>${s.pay_rate!=null?money(s.pay_rate)+'/hr':'<span class=muted>—</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>
|
||||
<td style="white-space:nowrap"><button class="ghost" onclick="staffEditDetails(${s.id},${JSON.stringify({email:s.email||'',phone:s.phone||'',pay_rate:s.pay_rate}).replace(/"/g,'"')})">✏️ details</button>
|
||||
<button class="ghost" onclick="staffSetPw(${s.id},'${escapeH(s.name)}')">🔒 set password</button>
|
||||
<button class="ghost" onclick="staffReset(${s.id})">🔑 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>`;
|
||||
$('#staffRows').innerHTML = `<table><thead><tr><th>Name</th><th>Email</th><th>Pay</th><th>Role</th><th>Last seen</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=6 class=muted>no staff yet — add one above</td></tr>'}</tbody></table>`;
|
||||
}
|
||||
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 `<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();
|
||||
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
|
||||
? `<div style="background:#e9f9ee;border:1px solid var(--ok);border-radius:8px;padding:11px"><b>${escapeH(name)}</b> is ready — they sign in at <b>recordgod.com/login</b> with <b>${escapeH(body.email)}</b> + the password you set.</div>`
|
||||
: 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 = `<h2>${title}</h2><div class="card soon">${body}<br><br>Coming next.</div>`; }; }
|
||||
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'); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
94
site/login.html
Normal file
94
site/login.html
Normal file
@ -0,0 +1,94 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · RecordGod</title>
|
||||
<style>
|
||||
:root{ --pink:#c2185b; --line:#e6e6ea; --ink:#16161d; --mut:#7a7a85; }
|
||||
*{ box-sizing:border-box }
|
||||
body{ margin:0; min-height:100vh; display:grid; place-items:center;
|
||||
background:#f4f4f6; color:var(--ink); font:15px/1.45 system-ui,-apple-system,sans-serif }
|
||||
.card{ width:340px; max-width:92vw; background:#fff; border:1px solid var(--line);
|
||||
border-radius:16px; padding:30px 26px; box-shadow:0 10px 40px rgba(0,0,0,.07) }
|
||||
.brand{ font-size:26px; font-weight:800; letter-spacing:-.5px; margin:0 0 2px }
|
||||
.brand b{ color:var(--pink) }
|
||||
.sub{ color:var(--mut); font-size:13px; margin:0 0 22px }
|
||||
label{ display:block; font-size:12px; color:var(--mut); margin:14px 0 4px; text-transform:uppercase; letter-spacing:.4px }
|
||||
input{ width:100%; padding:11px 12px; border:1px solid var(--line); border-radius:9px; font-size:15px; background:#fafafb }
|
||||
input:focus{ outline:none; border-color:var(--pink); background:#fff }
|
||||
button{ width:100%; margin-top:20px; padding:12px; border:none; border-radius:9px; background:var(--pink);
|
||||
color:#fff; font-size:15px; font-weight:700; cursor:pointer }
|
||||
button:hover{ filter:brightness(1.06) } button:disabled{ opacity:.6; cursor:default }
|
||||
.err{ color:#c0392b; font-size:13px; min-height:18px; margin-top:12px; text-align:center }
|
||||
.alt{ margin-top:18px; text-align:center; font-size:12px }
|
||||
.alt a{ color:var(--mut); cursor:pointer; text-decoration:underline }
|
||||
.tokrow{ display:none; margin-top:14px }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="card" id="form" onsubmit="return doLogin(event)">
|
||||
<div class="brand">Record<b>God</b></div>
|
||||
<div class="sub">Staff sign-in</div>
|
||||
|
||||
<div id="pwbox">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" autocomplete="username" placeholder="you@monsterrobot.party" autofocus>
|
||||
<label for="pw">Password</label>
|
||||
<input id="pw" type="password" autocomplete="current-password" placeholder="••••••••">
|
||||
<button type="submit" id="go">Sign in</button>
|
||||
</div>
|
||||
|
||||
<div class="tokrow" id="tokbox">
|
||||
<label for="tok">Access token</label>
|
||||
<input id="tok" type="password" placeholder="rg_… (owner / break-glass)">
|
||||
<button type="button" onclick="tokenLogin()">Sign in with token</button>
|
||||
</div>
|
||||
|
||||
<div class="err" id="err"></div>
|
||||
<div class="alt"><a id="toggle" onclick="toggleMode()">Use an access token instead</a></div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// already signed in? straight through
|
||||
if (localStorage.getItem('rg_token')) location.replace('/admin');
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
function finish(token, name, role){
|
||||
localStorage.setItem('rg_token', token);
|
||||
if (name) localStorage.setItem('rg_name', name);
|
||||
if (role) localStorage.setItem('rg_role', role);
|
||||
location.replace('/admin');
|
||||
}
|
||||
async function doLogin(e){
|
||||
e.preventDefault();
|
||||
const email = $('#email').value.trim(), password = $('#pw').value;
|
||||
if (!email || !password){ $('#err').textContent = 'enter your email and password'; return false; }
|
||||
$('#go').disabled = true; $('#err').textContent = '';
|
||||
try {
|
||||
const r = await fetch('/auth/login', { method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ email, password }) });
|
||||
const d = await r.json();
|
||||
if (r.ok && d.token) finish(d.token, d.name, d.role);
|
||||
else { $('#err').textContent = d.detail || 'wrong email or password'; $('#go').disabled = false; }
|
||||
} catch(_) { $('#err').textContent = 'network error'; $('#go').disabled = false; }
|
||||
return false;
|
||||
}
|
||||
async function tokenLogin(){
|
||||
const tok = $('#tok').value.trim(); if (!tok) return;
|
||||
try {
|
||||
const r = await fetch('/admin/me', { headers:{ 'Authorization':'Bearer '+tok } });
|
||||
if (!r.ok) throw 0;
|
||||
const me = await r.json(); finish(tok, me.name, me.role);
|
||||
} catch(_) { $('#err').textContent = 'invalid token'; }
|
||||
}
|
||||
function toggleMode(){
|
||||
const t = $('#tokbox'), p = $('#pwbox');
|
||||
const showTok = t.style.display !== 'block';
|
||||
t.style.display = showTok ? 'block' : 'none';
|
||||
p.style.display = showTok ? 'none' : 'block';
|
||||
$('#toggle').textContent = showTok ? 'Use email and password instead' : 'Use an access token instead';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user