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>
50 lines
2.1 KiB
Python
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}
|