Files
xenia-pos-cloud/cloud_backend/routers/manager_auth.py
bonamin ac58d150ea feat(sysadmin): Part 3 — Remote Managers section in SiteDetailPage
Backend (manager_auth.py):
- register: upsert instead of 409 — if email exists, just add site access
- GET /api/manager/by-site/{site_id}: list managers linked to a site (admin auth)
- DELETE /api/manager/site-access: remove a manager's access to a site (admin auth)

Sysadmin API client:
- getManagersBySite, addManagerToSite, removeManagerSiteAccess

SiteDetailPage.jsx:
- Remote Managers section: lists all linked managers with email, name, active badge
- Add Manager modal: email + full name + password form (password ignored if account exists)
- Remove access button with optimistic removal from list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:04:28 +03:00

151 lines
5.8 KiB
Python

from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
from passlib.context import CryptContext
from pydantic import BaseModel
from sqlalchemy.orm import Session
from config import settings
from database import get_db
from models.manager_account import ManagerAccount, manager_site_access
from models.site import Site
from auth_utils import get_current_admin
from schemas.manager import (
ManagerRegisterRequest, ManagerLoginRequest,
ManagerTokenOut, ManagerOut,
)
router = APIRouter()
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
_bearer = HTTPBearer()
ALGORITHM = "HS256"
# ── Shared dependency: decode manager JWT ─────────────────────────────────────
def _get_current_manager(credentials: HTTPAuthorizationCredentials = Depends(_bearer), db: Session = Depends(get_db)) -> ManagerAccount:
try:
payload = jwt.decode(credentials.credentials, settings.MANAGER_JWT_SECRET, algorithms=[ALGORITHM])
manager_id = int(payload.get("sub", 0))
except (JWTError, ValueError):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
manager = db.query(ManagerAccount).filter(ManagerAccount.id == manager_id).first()
if not manager or not manager.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Manager not found or inactive")
return manager
def _create_manager_token(manager: ManagerAccount) -> str:
site_ids = [s.id for s in manager.sites]
payload = {
"sub": str(manager.id),
"email": manager.email,
"site_ids": site_ids,
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.MANAGER_JWT_EXPIRE_HOURS),
}
return jwt.encode(payload, settings.MANAGER_JWT_SECRET, algorithm=ALGORITHM)
# ── Admin-only: register a manager ───────────────────────────────────────────
@router.post("/register", response_model=ManagerOut, status_code=status.HTTP_201_CREATED)
def register_manager(
body: ManagerRegisterRequest,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
existing = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
sites = db.query(Site).filter(Site.id.in_(body.site_ids)).all()
if existing:
# Email already exists — just add the new site access links, don't recreate the account
for site in sites:
if site not in existing.sites:
existing.sites.append(site)
db.commit()
db.refresh(existing)
return existing
manager = ManagerAccount(
email=body.email,
password_hash=_pwd.hash(body.password),
full_name=body.full_name,
sites=sites,
)
db.add(manager)
db.commit()
db.refresh(manager)
return manager
# ── Public: manager login ─────────────────────────────────────────────────────
@router.post("/login", response_model=ManagerTokenOut)
def login_manager(body: ManagerLoginRequest, db: Session = Depends(get_db)):
manager = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
if not manager or not _pwd.verify(body.password, manager.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not manager.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")
return ManagerTokenOut(access_token=_create_manager_token(manager))
# ── Public: refresh ───────────────────────────────────────────────────────────
@router.post("/refresh", response_model=ManagerTokenOut)
def refresh_manager_token(manager: ManagerAccount = Depends(_get_current_manager)):
return ManagerTokenOut(access_token=_create_manager_token(manager))
# ── Admin-only: list managers for a site ─────────────────────────────────────
class ManagerBySiteOut(BaseModel):
id: int
email: str
full_name: Optional[str] = None
is_active: int
model_config = {"from_attributes": True}
@router.get("/by-site/{site_id}", response_model=list[ManagerBySiteOut])
def get_managers_by_site(
site_id: int,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
site = db.query(Site).filter(Site.id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
return site.manager_accounts
# ── Admin-only: remove a manager's access to a site ──────────────────────────
class SiteAccessRemoveRequest(BaseModel):
manager_id: int
site_id: int
@router.delete("/site-access", status_code=status.HTTP_204_NO_CONTENT)
def remove_manager_site_access(
body: SiteAccessRemoveRequest,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
manager = db.query(ManagerAccount).filter(ManagerAccount.id == body.manager_id).first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
site = db.query(Site).filter(Site.id == body.site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
if site in manager.sites:
manager.sites.remove(site)
db.commit()