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:
type-two 2026-06-26 16:24:34 +10:00
parent f9ab9527f3
commit 9a66f59bfe
7 changed files with 333 additions and 20 deletions

View 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.

View File

@ -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}

View File

@ -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
View 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}

View File

@ -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)

View File

@ -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,'&quot;')})">✏️ 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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
View 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>