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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user