diff --git a/backend/app/gateway/auth/models.py b/backend/app/gateway/auth/models.py index 1e9f91a7..4a904fec 100644 --- a/backend/app/gateway/auth/models.py +++ b/backend/app/gateway/auth/models.py @@ -47,6 +47,26 @@ class UserResponse(BaseModel): needs_setup: bool = False +class UserMeWorkspace(BaseModel): + """One workspace entry in ``GET /auth/me`` (Stage 0 PR4).""" + + id: str + name: str + slug: str + role: str + + +class UserMeResponse(BaseModel): + """Response model for ``GET /auth/me`` — extends UserResponse with workspaces.""" + + id: str + email: str + system_role: Literal["admin", "user"] + needs_setup: bool = False + default_workspace_id: str | None = None + workspaces: list[UserMeWorkspace] = [] + + class ActiveWorkspace(BaseModel): """Lightweight workspace proxy injected into the request-scoped contextvar. diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py index dd8a7ff5..5635bcdd 100644 --- a/backend/app/gateway/routers/auth.py +++ b/backend/app/gateway/routers/auth.py @@ -15,6 +15,7 @@ from app.gateway.auth import ( ) from app.gateway.auth.config import get_auth_config from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse +from app.gateway.auth.models import UserMeResponse, UserMeWorkspace from app.gateway.auth.workspace_slug import auto_slug_from_email, next_available_slug from app.gateway.csrf_middleware import is_secure_request from app.gateway.deps import get_current_user_from_request, get_local_provider @@ -447,11 +448,41 @@ async def change_password(request: Request, response: Response, body: ChangePass return MessageResponse(message="Password changed successfully") -@router.get("/me", response_model=UserResponse) +@router.get("/me", response_model=UserMeResponse) async def get_me(request: Request): - """Get current authenticated user info.""" + """Get current authenticated user info, including the workspaces they belong to.""" + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.workspace import WorkspaceRepository + from deerflow.persistence.workspace_membership import WorkspaceMembershipRepository + user = await get_current_user_from_request(request) - return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup) + + sf = get_session_factory() + workspaces: list[UserMeWorkspace] = [] + if sf is not None: + ws_repo = WorkspaceRepository(sf) + m_repo = WorkspaceMembershipRepository(sf) + ws_rows = await ws_repo.list_by_user(user_id=str(user.id)) + memberships = await m_repo.list_by_user(user_id=str(user.id)) + role_by_ws = {m["workspace_id"]: m["role"] for m in memberships} + workspaces = [ + UserMeWorkspace( + id=w["id"], + name=w["name"], + slug=w["slug"], + role=role_by_ws.get(w["id"], "member"), + ) + for w in ws_rows + ] + + return UserMeResponse( + id=str(user.id), + email=user.email, + system_role=user.system_role, + needs_setup=user.needs_setup, + default_workspace_id=user.default_workspace_id, + workspaces=workspaces, + ) _SETUP_STATUS_COOLDOWN: dict[str, float] = {} diff --git a/backend/tests/test_auth_me_returns_workspaces.py b/backend/tests/test_auth_me_returns_workspaces.py new file mode 100644 index 00000000..1a853958 --- /dev/null +++ b/backend/tests/test_auth_me_returns_workspaces.py @@ -0,0 +1,90 @@ +"""``GET /auth/me`` returns the user's workspace memberships. + +Stage 0 PR4 T4.11. After PR4 the frontend needs to discover which +workspaces the current user belongs to (eventually for a picker UI). +Each membership entry surfaces ``id``, ``name``, ``slug``, ``role`` +so the picker can render the list without a second roundtrip. +""" + +from __future__ import annotations + +import asyncio +import os + +import pytest +from fastapi.testclient import TestClient + +os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-auth-me-workspaces-32+") + +from app.gateway.auth.config import AuthConfig, set_auth_config + +_TEST_SECRET = "test-secret-key-auth-me-workspaces-32+" + + +@pytest.fixture(autouse=True) +def _setup_auth(tmp_path): + from app.gateway import deps + from app.gateway.routers.auth import _SETUP_STATUS_COOLDOWN + from deerflow.persistence.engine import close_engine, init_engine + + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + url = f"sqlite+aiosqlite:///{tmp_path}/auth_me.db" + asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))) + deps._cached_local_provider = None + deps._cached_repo = None + _SETUP_STATUS_COOLDOWN.clear() + try: + yield + finally: + deps._cached_local_provider = None + deps._cached_repo = None + _SETUP_STATUS_COOLDOWN.clear() + asyncio.run(close_engine()) + + +@pytest.fixture() +def client(_setup_auth): + from app.gateway.app import create_app + + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + yield TestClient(create_app()) + + +def test_auth_me_returns_workspaces_list(client): + """After registration, /auth/me lists the user's single personal workspace.""" + client.post("/api/v1/auth/initialize", json={"email": "admin@example.com", "password": "Str0ng!Pass99"}) + reg = client.post("/api/v1/auth/register", json={"email": "alice@example.com", "password": "Tr0ub4dor3a-strong!"}) + user_id = reg.json()["id"] + + resp = client.get("/api/v1/auth/me") + assert resp.status_code == 200, resp.text + + body = resp.json() + assert body["id"] == user_id + assert body["email"] == "alice@example.com" + assert body["default_workspace_id"], "user should land with a default workspace" + + assert len(body["workspaces"]) == 1, body + ws = body["workspaces"][0] + assert ws["id"] == body["default_workspace_id"] + assert ws["slug"] == "alice" + assert ws["role"] == "owner" + assert ws["name"] # whatever the helper picks — just sanity check it's non-empty + + +def test_auth_me_workspaces_isolated_per_user(client): + """Two users see only their own workspaces in /auth/me.""" + client.post("/api/v1/auth/initialize", json={"email": "admin@example.com", "password": "Str0ng!Pass99"}) + client.post("/api/v1/auth/register", json={"email": "alice@example.com", "password": "Tr0ub4dor3a-strong!"}) + a_id = client.get("/api/v1/auth/me").json()["id"] + a_workspaces = client.get("/api/v1/auth/me").json()["workspaces"] + + client.cookies.clear() + client.post("/api/v1/auth/register", json={"email": "bob@example.com", "password": "Tr0ub4dor3a-strong!"}) + b_id = client.get("/api/v1/auth/me").json()["id"] + b_workspaces = client.get("/api/v1/auth/me").json()["workspaces"] + + assert a_id != b_id + assert {w["id"] for w in a_workspaces}.isdisjoint({w["id"] for w in b_workspaces}) + assert {w["slug"] for w in a_workspaces} == {"alice"} + assert {w["slug"] for w in b_workspaces} == {"bob"}