feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date: - local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync - manager_dashboard: React manager UI with product/category management, reports, settings - waiter_pwa: React PWA for waiter devices - Category reparent endpoint and UI - Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response - QR code modal in AppInfoTab for waiter domain - Product form: number input spinners removed, category pre-selected on new product - Category row: count badge moved to far right Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
local_backend/middleware/__init__.py
Normal file
0
local_backend/middleware/__init__.py
Normal file
69
local_backend/middleware/license_check.py
Normal file
69
local_backend/middleware/license_check.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# Shared mutable state — updated by cloud_sync.py
|
||||
# Fields:
|
||||
# licensed bool — False only after 72h offline OR after expiry grace passes
|
||||
# locked bool — True once lock_pending is enforced at workday close
|
||||
# lock_pending bool — Cloud requested lock; waiting for workday to close
|
||||
# expires_at str|None — ISO timestamp from cloud
|
||||
# days_until_expiry int|None — negative when expired
|
||||
# grace_expires_at str|None — ISO timestamp of expiry + 5 days
|
||||
# last_sync str|None — ISO timestamp of last successful heartbeat
|
||||
# sync_failed bool
|
||||
# latest_version str|None
|
||||
license_state: dict = {
|
||||
"licensed": True,
|
||||
"locked": False,
|
||||
"lock_pending": False,
|
||||
"expires_at": None,
|
||||
"days_until_expiry": None,
|
||||
"grace_expires_at": None,
|
||||
"last_sync": None,
|
||||
"sync_failed": False,
|
||||
"latest_version": None,
|
||||
}
|
||||
|
||||
# Paths that bypass all license checks (health probe)
|
||||
EXEMPT_PATHS = {"/api/system/health"}
|
||||
|
||||
# Paths that are always allowed so the frontend can read license status
|
||||
# and managers can still log in / close the workday when restricted
|
||||
STATUS_ALLOWED_PATHS = {
|
||||
"/api/system/status",
|
||||
"/api/system/sync-license",
|
||||
"/api/auth/login",
|
||||
"/api/auth/me",
|
||||
"/api/business-day/current",
|
||||
"/api/business-day/close",
|
||||
}
|
||||
|
||||
|
||||
class LicenseCheckMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
if path in EXEMPT_PATHS or path in STATUS_ALLOWED_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
# Hard block: licensed=False means either 72h offline grace expired
|
||||
# OR expiry grace period (5 days) has passed. In both cases the
|
||||
# business_day router already prevented opening a new workday, so
|
||||
# existing operations can still complete — we only block new ones.
|
||||
# The business_day /open endpoint has its own detailed error message.
|
||||
if not license_state.get("licensed", True):
|
||||
return Response(
|
||||
content='{"detail":"license_expired","code":"LICENSE_EXPIRED"}',
|
||||
status_code=402,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
# Hard block: locked=True (lock_pending was enforced at workday close)
|
||||
if license_state.get("locked"):
|
||||
return Response(
|
||||
content='{"detail":"system_locked","code":"SYSTEM_LOCKED"}',
|
||||
status_code=423,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
Reference in New Issue
Block a user