Files
PacketStation/server/packetstation/api/auth.py
T
spencermandClaude Fable 5.1 6b6db0230f
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m6s
ci / image (push) Successful in 38s
ci / e2e (push) Successful in 3m55s
Phase 8: Project Management, find bar, presence and motion
The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target,
Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table
and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as
"report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the
flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and
the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion
honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The
data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates,
search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and
iPad viewports. Docs updated; PLAN.md questions 38 to 42.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 15:08:34 -06:00

247 lines
7.3 KiB
Python

"""Sign-in, sign-out, lock, unlock and the Me payload."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel, Field
from .. import audit, events
from ..auth import devices, signin, stepup
from ..auth import directory as userdir
from ..auth import pin as pins
from .deps import (
Db,
Principal,
can_register_device,
clear_session_cookie,
current_device,
current_principal,
current_principal_any,
directory_of,
get_db,
ip_of,
resolve,
session_key,
set_session_cookie,
settings_of,
user_agent_of,
)
from .errors import ApiError
router = APIRouter(prefix="/api/auth", tags=["auth"])
class PasswordSignin(BaseModel):
login: str = Field(max_length=256)
password: str = Field(max_length=1024)
class PinSignin(BaseModel):
emp_no: str = Field(max_length=64)
pin: str = Field(max_length=64)
class Visibility(BaseModel):
hidden: bool
class Unlock(BaseModel):
factor: str = Field(max_length=16)
secret: str = Field(max_length=1024)
def me_payload(conn: Any, p: Principal) -> dict[str, Any]:
s = p.session
return {
"user": userdir.public_user(p.user),
"roles": p.roles,
"screens": p.screens,
"groups": p.groups,
"session": {
"id": str(s["id"]),
"kind": p.kind,
"created_at": s["created_at"],
"expires_at": s["expires_at"],
"locked": s.get("locked_since") is not None,
"locked_since": s.get("locked_since"),
"device": devices.public(p.device),
},
"pin": pins.state(conn, p.user_id).public(),
"can_register_device": can_register_device(p),
}
def _me_after_signin(request: Request, response: Response, d: Db, result: signin.SigninResult) -> dict[str, Any]:
set_session_cookie(request, response, result.token)
# Resolve the new session the normal way so the payload is exactly what GET /me returns
p = resolve(request, d, allow_locked=True, token=result.token)
return me_payload(d.conn, p)
@router.get("/context")
def context(request: Request, d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
s = settings_of(request)
device = None
if request.cookies.get("ps_device"):
device = devices.public(current_device(request, d.conn))
return {
"device": device,
"stack": s.stack,
"insecure_ldap": s.ldap_allow_insecure,
"pin_digits": {"min": s.pin_min_digits, "max": s.pin_max_digits},
}
@router.post("/signin/password")
def signin_password(
body: PasswordSignin,
request: Request,
response: Response,
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
key = session_key(request)
result = signin.password_signin(
d.conn,
directory_of(request),
settings_of(request),
key,
login=body.login,
password=body.password,
ip=ip_of(request),
user_agent=user_agent_of(request),
)
return _me_after_signin(request, response, d, result)
@router.post("/signin/pin")
def signin_pin(
body: PinSignin,
request: Request,
response: Response,
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
key = session_key(request)
device = current_device(request, d.conn)
if device is None:
raise ApiError(403, "device_required", "PIN sign-in works only on a registered device.")
result = signin.pin_signin(
d.conn,
directory_of(request),
settings_of(request),
key,
device=device,
emp_no=body.emp_no,
pin=body.pin,
ip=ip_of(request),
user_agent=user_agent_of(request),
)
return _me_after_signin(request, response, d, result)
@router.get("/me")
def me(p: Principal = Depends(current_principal_any), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
return me_payload(d.conn, p)
@router.post("/signout", status_code=204)
def signout(
request: Request,
p: Principal = Depends(current_principal_any), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
from ..auth import sessions
conn = d.conn
with conn.transaction():
if sessions.revoke(conn, p.session_id, "signout", notify=False):
audit.append(
conn,
action="auth.signout",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
ip=p.ip,
)
response = Response(status_code=204)
clear_session_cookie(request, response)
return response
def _lock(d: Db, p: Principal, source: str) -> None:
conn = d.conn
with conn.transaction():
row = conn.execute(
"UPDATE sessions SET locked_since = now() WHERE id = %s AND locked_since IS NULL RETURNING id",
(p.session_id,),
).fetchone()
if row is not None:
audit.append(
conn,
action="auth.lock",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
detail={"source": source},
ip=p.ip,
)
events.publish(conn, {"event": "session", "session_id": str(p.session_id), "action": "locked"})
@router.post("/lock", status_code=204)
def lock(p: Principal = Depends(current_principal), d: Db = Depends(get_db)) -> Response: # noqa: B008
_lock(d, p, "manual")
return Response(status_code=204)
@router.post("/visibility", status_code=204)
def visibility(
body: Visibility,
p: Principal = Depends(current_principal_any), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
# Hidden is only recorded. The lock comes from `resolve` once the report is older than
# SESSION_HIDDEN_LOCK_SECONDS (the old build's "tab hidden over 1 min"), so a reload or an
# in-app navigation, which also fire a hidden report, never lock the screen.
if body.hidden:
d.conn.execute(
"UPDATE sessions SET hidden_since = coalesce(hidden_since, now()) WHERE id = %s", (p.session_id,)
)
return Response(status_code=204)
@router.post("/unlock")
def unlock(
body: Unlock,
request: Request,
p: Principal = Depends(current_principal_any), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
if p.locked:
stepup.verify(
conn,
directory_of(request),
settings_of(request),
p.actor,
factor=body.factor,
secret=body.secret,
kind="unlock",
ip=p.ip,
)
with conn.transaction():
conn.execute("UPDATE sessions SET locked_since = NULL, last_seen_at = now() WHERE id = %s", (p.session_id,))
audit.append(
conn,
action="auth.unlock",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
detail={"factor": body.factor},
ip=p.ip,
)
events.publish(conn, {"event": "session", "session_id": str(p.session_id), "action": "unlocked"})
p.session["locked_since"] = None
return me_payload(conn, p)