M3: chat widget, owner dashboard, super-admin panel
- Embeddable vanilla-JS chat widget (one script tag + tenant public key):
WebSocket, resumable sessions (24h localStorage token), honeypot + per-IP
and per-message rate limits, Bosnian UI (§7)
- Session auth (bcrypt + signed cookies), owner accounts + super-admins,
admin impersonation ('otvori kao salon', §11)
- Owner dashboard (§10, Bosnian): requests with same actions as email,
call/chat history with transcripts, usage bar; settings for profile,
working hours (manual + LLM paste-to-parse), services CRUD + 'Zalijepi
cjenovnik' LLM import with review step, scheduling (Google OAuth free/busy
read-only + calendar mappings + buffers, partner status), telephony
(forwarding MMI codes per operator, ring group, worker SIP creds + QR),
agent voice/price-mode/notes + widget snippet, notifications/SMS templates
- Super-admin panel (§11): tenant CRUD incl. plan/minutes/paid-until,
partner API config (encrypted secrets), number/SIM registry + assignment,
connectivity test (live availability + dry-run push), versioned prompt
template editor with publish/rollback, playground, usage overview, health
- Fixes: WAL + busy_timeout for sqlite tests, no awaits in WS disconnect
path (deadlock), template publish autoflush bug
- 76 tests green (incl. widget WS e2e booking flow); dashboard, widget and
admin verified live in a browser against Postgres
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
106
gogo/auth.py
Normal file
106
gogo/auth.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Session-based auth (§4): owner accounts (one per salon) + super-admins.
|
||||
|
||||
No public registration — accounts are created by the super-admin. Sessions are
|
||||
signed cookies (itsdangerous). Admins can impersonate a tenant ("open dashboard
|
||||
as salon") for support/onboarding (§11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, Request, Response
|
||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.db import get_session
|
||||
from gogo.models import Admin, Tenant, User
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _serializer() -> URLSafeTimedSerializer:
|
||||
return URLSafeTimedSerializer(get_settings().secret_key, salt="session")
|
||||
|
||||
|
||||
def set_session(response: Response, data: dict[str, Any]) -> None:
|
||||
s = get_settings()
|
||||
response.set_cookie(
|
||||
s.session_cookie,
|
||||
_serializer().dumps(data),
|
||||
max_age=s.session_max_age,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def clear_session(response: Response) -> None:
|
||||
response.delete_cookie(get_settings().session_cookie)
|
||||
|
||||
|
||||
def read_session(request: Request) -> dict[str, Any] | None:
|
||||
raw = request.cookies.get(get_settings().session_cookie)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return _serializer().loads(raw, max_age=get_settings().session_max_age)
|
||||
except BadSignature:
|
||||
return None
|
||||
|
||||
|
||||
class OwnerContext:
|
||||
def __init__(self, user: User | None, tenant: Tenant, impersonated_by: str | None = None):
|
||||
self.user = user
|
||||
self.tenant = tenant
|
||||
self.impersonated_by = impersonated_by # admin email when impersonating
|
||||
|
||||
|
||||
async def current_owner(
|
||||
request: Request, session: AsyncSession = Depends(get_session)
|
||||
) -> OwnerContext:
|
||||
"""Dependency: require an owner session (or an admin impersonating one)."""
|
||||
data = read_session(request)
|
||||
if data and data.get("kind") == "owner":
|
||||
user = (
|
||||
await session.execute(select(User).where(User.id == uuid.UUID(data["user_id"])))
|
||||
).scalar_one_or_none()
|
||||
if user and user.active:
|
||||
tenant = (
|
||||
await session.execute(select(Tenant).where(Tenant.id == user.tenant_id))
|
||||
).scalar_one()
|
||||
return OwnerContext(user, tenant)
|
||||
if data and data.get("kind") == "admin" and data.get("impersonate_tenant"):
|
||||
tenant = (
|
||||
await session.execute(
|
||||
select(Tenant).where(Tenant.id == uuid.UUID(data["impersonate_tenant"]))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if tenant:
|
||||
return OwnerContext(None, tenant, impersonated_by=data.get("email"))
|
||||
raise HTTPException(status_code=303, headers={"Location": "/login"})
|
||||
|
||||
|
||||
async def current_admin(
|
||||
request: Request, session: AsyncSession = Depends(get_session)
|
||||
) -> Admin:
|
||||
data = read_session(request)
|
||||
if data and data.get("kind") == "admin":
|
||||
admin = (
|
||||
await session.execute(select(Admin).where(Admin.id == uuid.UUID(data["admin_id"])))
|
||||
).scalar_one_or_none()
|
||||
if admin and admin.active:
|
||||
return admin
|
||||
raise HTTPException(status_code=303, headers={"Location": "/admin/login"})
|
||||
Reference in New Issue
Block a user