RECORDGOD/app/auth_routes.py
type-two 9a66f59bfe 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>
2026-06-26 16:24:34 +10:00

50 lines
2.1 KiB
Python

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}