feat(auth): /auth/me returns workspaces[] with id/name/slug/role

GET /auth/me now responds with the new UserMeResponse: the existing
user fields plus default_workspace_id and a workspaces list that
joins WorkspaceRepository.list_by_user with the caller's role from
WorkspaceMembershipRepository.list_by_user. Stage 0 every user has
exactly one entry there, but the shape is forward-compatible for
Stage 2 multi-workspace memberships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1445043649
2026-05-12 22:37:29 +08:00
parent 634e5119e1
commit 91846a201e
3 changed files with 144 additions and 3 deletions
+20
View File
@@ -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.
+34 -3
View File
@@ -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] = {}
@@ -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"}