Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
219 lines
8.8 KiB
Python
219 lines
8.8 KiB
Python
import bcrypt
|
|
import logging
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from database import get_db
|
|
from config import settings
|
|
from models.user import User
|
|
from schemas.auth import LoginRequest, TokenResponse, UpdateMeRequest
|
|
from pydantic import BaseModel as _PydanticBase
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
class LoginByIdRequest(_PydanticBase):
|
|
waiter_id: int
|
|
pin: str
|
|
from schemas.user import UserOut
|
|
from routers.deps import get_current_user, make_token, decode_token, blacklist_token, _make_ghost_superadmin
|
|
from routers.recovery import try_recovery_code
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class NoAuthLoginRequest(_PydanticBase):
|
|
username: str
|
|
|
|
|
|
@router.post("/login-no-auth", response_model=TokenResponse)
|
|
def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)):
|
|
"""Login with no credentials — only works when security.login_method = 'none'."""
|
|
from models.settings import PosSettings
|
|
setting = db.query(PosSettings).filter(PosSettings.key == "security.login_method").first()
|
|
if not setting or setting.value != "none":
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No-auth login is not enabled.")
|
|
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
|
if not user or not user.perm_access_dashboard:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
token = make_token(user)
|
|
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
|
|
|
|
|
@router.post("/login", response_model=TokenResponse)
|
|
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
|
# Break-glass master account — checked before DB, never stored as a real user
|
|
if (
|
|
settings.MASTER_USERNAME
|
|
and settings.MASTER_PASSWORD
|
|
and body.username == settings.MASTER_USERNAME
|
|
and body.password == settings.MASTER_PASSWORD
|
|
):
|
|
_logger.warning("MASTER LOGIN USED from username=%s", body.username)
|
|
ghost = _make_ghost_superadmin()
|
|
token = make_token(ghost)
|
|
return TokenResponse(access_token=token, user=UserOut.model_validate(ghost))
|
|
|
|
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
|
|
authenticated = False
|
|
if body.password and user.password_hash:
|
|
authenticated = bcrypt.checkpw(body.password.encode(), user.password_hash.encode())
|
|
elif body.pin and user.pin_hash:
|
|
authenticated = bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode())
|
|
|
|
# If normal auth failed, check if the submitted value is a recovery code
|
|
if not authenticated and body.password:
|
|
authenticated = try_recovery_code(body.password, user, db)
|
|
|
|
if not authenticated:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
|
|
token = make_token(user)
|
|
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
|
|
|
|
|
@router.post("/login-by-id", response_model=TokenResponse)
|
|
def login_by_id(body: LoginByIdRequest, db: Session = Depends(get_db)):
|
|
"""Login using waiter id + PIN (used by the waiter-picker login screen)."""
|
|
user = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
|
|
if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Λανθασμένο PIN")
|
|
token = make_token(user)
|
|
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
def refresh(token: str, db: Session = Depends(get_db)):
|
|
payload = decode_token(token)
|
|
user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first()
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
|
blacklist_token(token)
|
|
new_token = make_token(user)
|
|
return TokenResponse(access_token=new_token, user=UserOut.model_validate(user))
|
|
|
|
|
|
@router.post("/logout")
|
|
def logout(token: str):
|
|
blacklist_token(token)
|
|
return {"status": "logged out"}
|
|
|
|
|
|
@router.get("/me", response_model=UserOut)
|
|
def me(user: User = Depends(get_current_user)):
|
|
return user
|
|
|
|
|
|
# ─── Public manager list (login screen — no auth required) ───────────────────
|
|
|
|
class PublicManagerOut(_PydanticBase):
|
|
id: int
|
|
username: str
|
|
full_name: str | None
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
@router.get("/managers", response_model=list[PublicManagerOut])
|
|
def public_manager_list(db: Session = Depends(get_db)):
|
|
"""Public endpoint — returns accounts with dashboard access for login screen."""
|
|
managers = db.query(User).filter(
|
|
User.perm_access_dashboard == True,
|
|
User.is_active == True,
|
|
).all()
|
|
return [PublicManagerOut(id=m.id, username=m.username, full_name=m.full_name) for m in managers]
|
|
|
|
|
|
# ─── Public waiter list (login screen — no auth required) ────────────────────
|
|
|
|
from pydantic import BaseModel as _BaseModel
|
|
|
|
class PublicWaiterOut(_BaseModel):
|
|
id: int
|
|
full_name: str | None
|
|
nickname: str | None
|
|
avatar_url: str | None
|
|
on_shift: bool
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
@router.get("/waiters", response_model=list[PublicWaiterOut])
|
|
def public_waiter_list(db: Session = Depends(get_db)):
|
|
"""Public endpoint — returns active waiters with on-shift flag. No auth required."""
|
|
from models.shift import WaiterShift
|
|
waiters = db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()
|
|
on_shift_ids = {
|
|
row.waiter_id
|
|
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
|
|
}
|
|
return [
|
|
PublicWaiterOut(
|
|
id=w.id,
|
|
full_name=w.full_name,
|
|
nickname=w.nickname,
|
|
avatar_url=w.avatar_url,
|
|
on_shift=w.id in on_shift_ids,
|
|
)
|
|
for w in waiters
|
|
]
|
|
|
|
|
|
@router.patch("/me", response_model=UserOut)
|
|
def update_me(body: UpdateMeRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
|
# Password change — requires current_password verification
|
|
if body.new_password is not None:
|
|
if not body.current_password:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="current_password is required to set a new password")
|
|
if not user.password_hash:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Account has no password set")
|
|
if not bcrypt.checkpw(body.current_password.encode(), user.password_hash.encode()):
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect")
|
|
if len(body.new_password) < 6:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="New password must be at least 6 characters")
|
|
user.password_hash = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode()
|
|
|
|
# PIN change — no current PIN required (already authenticated)
|
|
if body.new_pin is not None:
|
|
if len(body.new_pin) < 4:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="PIN must be at least 4 digits")
|
|
user.pin_hash = bcrypt.hashpw(body.new_pin.encode(), bcrypt.gensalt()).decode()
|
|
|
|
# Username change — check uniqueness
|
|
if body.username is not None and body.username != user.username:
|
|
conflict = db.query(User).filter(User.username == body.username, User.id != user.id).first()
|
|
if conflict:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken")
|
|
user.username = body.username
|
|
|
|
# Display name
|
|
if body.full_name is not None:
|
|
user.full_name = body.full_name
|
|
|
|
db.commit()
|
|
db.refresh(user)
|
|
return user
|
|
|
|
|
|
class WaiterSettingsPayload(_PydanticBase):
|
|
settings: str # raw JSON string
|
|
|
|
|
|
@router.get("/me/settings")
|
|
def get_my_settings(user: User = Depends(get_current_user)):
|
|
"""Return the stored waiter settings JSON blob for the current user."""
|
|
return {"settings": user.waiter_settings or "{}"}
|
|
|
|
|
|
@router.put("/me/settings")
|
|
def put_my_settings(body: WaiterSettingsPayload, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
|
"""Replace the stored waiter settings JSON blob."""
|
|
import json as _json
|
|
try:
|
|
_json.loads(body.settings) # validate it's valid JSON
|
|
except Exception:
|
|
raise HTTPException(status_code=422, detail="settings must be valid JSON")
|
|
user.waiter_settings = body.settings
|
|
db.commit()
|
|
return {"settings": user.waiter_settings}
|