diff --git a/backend/apps/accounts/api.py b/backend/apps/accounts/api.py index 63db9bd..4f3a722 100644 --- a/backend/apps/accounts/api.py +++ b/backend/apps/accounts/api.py @@ -1,3 +1,121 @@ +from django.contrib.auth import authenticate, login, logout +from django.middleware.csrf import get_token from ninja import Router +from ninja.errors import HttpError + +from apps.accounts.models import MenuItem +from apps.accounts.permissions import PERMISSIONS +from apps.accounts.schemas import CurrentUserOut, LoginIn router = Router(tags=["accounts"]) + + +def permission_codes_for_user(user) -> list[str]: + if user.is_superuser: + return sorted(permission.codename for permission in PERMISSIONS) + codes = set() + for permission in user.get_all_permissions(): + if permission.startswith("system."): + codes.add(permission.removeprefix("system.")) + return sorted(codes) + + +def serialize_menu(menu: MenuItem, children: list[dict]) -> dict: + return { + "id": menu.id, + "code": menu.code, + "title": menu.title, + "path": menu.path, + "icon": menu.icon, + "permission_code": menu.permission_code, + "is_visible": menu.is_visible, + "is_enabled": menu.is_enabled, + "children": children, + } + + +def menu_tree_for_permissions(permission_codes: list[str]) -> list[dict]: + allowed = set(permission_codes) + menus = list( + MenuItem.objects.filter(is_visible=True, is_enabled=True) + .select_related("parent") + .order_by("sort_order", "id") + ) + children_by_parent: dict[int | None, list[MenuItem]] = {} + for menu in menus: + if menu.permission_code and menu.permission_code not in allowed: + continue + children_by_parent.setdefault(menu.parent_id, []).append(menu) + + def build(parent_id: int | None) -> list[dict]: + result = [] + for menu in children_by_parent.get(parent_id, []): + result.append(serialize_menu(menu, build(menu.id))) + return result + + return build(None) + + +def serialize_current_user(user) -> dict: + permission_codes = permission_codes_for_user(user) + return { + "id": user.id, + "username": user.username, + "display_name": user.get_full_name() or user.username, + "email": user.email, + "is_superuser": user.is_superuser, + "roles": [{"id": group.id, "name": group.name} for group in user.groups.all()], + "user_groups": [ + { + "id": group.id, + "name": group.name, + "code": group.code, + "description": group.description, + "is_active": group.is_active, + "created_at": group.created_at, + "updated_at": group.updated_at, + } + for group in user.business_groups.all() + ], + "permissions": permission_codes, + "menus": menu_tree_for_permissions(permission_codes), + } + + +def require_login(request): + if not request.user.is_authenticated: + raise HttpError(401, "Authentication required") + return request.user + + +def require_permission(request, codename: str): + user = require_login(request) + if user.is_superuser or user.has_perm(f"system.{codename}"): + return user + raise HttpError(403, "Permission denied") + + +@router.get("/auth/csrf") +def csrf(request): + return {"csrfToken": get_token(request)} + + +@router.post("/auth/login", response=CurrentUserOut) +def login_user(request, payload: LoginIn): + user = authenticate(request, username=payload.username, password=payload.password) + if user is None or not user.is_active: + raise HttpError(401, "Invalid username or password") + login(request, user) + return serialize_current_user(user) + + +@router.post("/auth/logout") +def logout_user(request): + logout(request) + return {"status": "ok"} + + +@router.get("/auth/me", response=CurrentUserOut) +def current_user(request): + user = require_login(request) + return serialize_current_user(user) diff --git a/backend/apps/accounts/schemas.py b/backend/apps/accounts/schemas.py index 8b13789..be0d2dd 100644 --- a/backend/apps/accounts/schemas.py +++ b/backend/apps/accounts/schemas.py @@ -1 +1,59 @@ +from datetime import datetime +from ninja import Schema + + +class LoginIn(Schema): + username: str + password: str + + +class RoleOut(Schema): + id: int + name: str + + +class UserGroupOut(Schema): + id: int + name: str + code: str + description: str = "" + is_active: bool = True + created_at: datetime | None = None + updated_at: datetime | None = None + + +class MenuOut(Schema): + id: int + code: str + title: str + path: str + icon: str + permission_code: str + is_visible: bool + is_enabled: bool + children: list["MenuOut"] = [] + + +class CurrentUserOut(Schema): + id: int + username: str + display_name: str + email: str + is_superuser: bool + roles: list[RoleOut] + user_groups: list[UserGroupOut] + permissions: list[str] + menus: list[MenuOut] + + +class BasicUserOut(Schema): + id: int + username: str + display_name: str + email: str + is_active: bool + is_staff: bool + is_superuser: bool + roles: list[RoleOut] = [] + user_groups: list[UserGroupOut] = [] diff --git a/backend/apps/accounts/tests/test_auth_api.py b/backend/apps/accounts/tests/test_auth_api.py new file mode 100644 index 0000000..f617829 --- /dev/null +++ b/backend/apps/accounts/tests/test_auth_api.py @@ -0,0 +1,37 @@ +from django.contrib.auth import get_user_model +from django.core.management import call_command + + +def test_login_me_and_logout_flow(client, db): + call_command("bootstrap_governance") + + login = client.post( + "/api/accounts/auth/login", + data={"username": "admin", "password": "admin12345"}, + content_type="application/json", + ) + assert login.status_code == 200 + assert login.json()["username"] == "admin" + + me = client.get("/api/accounts/auth/me") + assert me.status_code == 200 + payload = me.json() + assert payload["username"] == "admin" + assert "system.user.manage" in payload["permissions"] + assert any(menu["code"] == "system" for menu in payload["menus"]) + + logout = client.post("/api/accounts/auth/logout") + assert logout.status_code == 200 + assert client.get("/api/accounts/auth/me").status_code == 401 + + +def test_login_rejects_invalid_password(client, db): + get_user_model().objects.create_user(username="u", password="right") + + response = client.post( + "/api/accounts/auth/login", + data={"username": "u", "password": "wrong"}, + content_type="application/json", + ) + + assert response.status_code == 401 diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index e9256f4..7cc466f 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -77,6 +77,11 @@ CORS_ALLOWED_ORIGINS = os.getenv( "CORS_ALLOWED_ORIGINS", "http://localhost:5173", ).split(",") +CORS_ALLOW_CREDENTIALS = True +CSRF_TRUSTED_ORIGINS = os.getenv( + "CSRF_TRUSTED_ORIGINS", + "http://localhost:5173,http://127.0.0.1:5173", +).split(",") CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1") diff --git a/backend/config/urls.py b/backend/config/urls.py index d11b49f..0526748 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -2,12 +2,14 @@ from django.contrib import admin from django.urls import path from ninja import NinjaAPI +from apps.accounts.api import router as accounts_router from apps.audit.api import router as audit_router from apps.solutions.api import router as solutions_router from apps.system.api import router as system_router from apps.tickets.api import router as tickets_router api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0") +api.add_router("/accounts", accounts_router) api.add_router("/system", system_router) api.add_router("/audit", audit_router) api.add_router("/tickets", tickets_router)