Skip to content

Commit ddc51da

Browse files
mjunaidcaclaude
andcommitted
feat(team): Show org members with project access (least privilege model)
Changes: - SSO: Add /api/organizations/[orgId]/members endpoint to expose org members - API: New /api/workers endpoint merges SSO members with project assignments - Web: Redesigned /workers page as "Team" page focused on access management Key features: - Org membership ≠ Project access (principle of least privilege) - Shows all org members with their current project assignments - "Pending Access" warning for members without any project access - "Add to Project" dropdown for quick access grants - Real-time data from SSO (no sync needed) - Removed AI Agents section (managed separately at /agents) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1fe73e3 commit ddc51da

6 files changed

Lines changed: 524 additions & 129 deletions

File tree

apps/api/src/taskflow_api/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from .chatkit_store import RequestContext # noqa: E402
1818
from .config import settings # noqa: E402
1919
from .database import create_db_and_tables # noqa: E402
20-
from .routers import agents, audit, health, jobs, members, projects, tasks # noqa: E402
20+
from .routers import agents, audit, health, jobs, members, projects, tasks, workers # noqa: E402
2121

2222
# Configure logging
2323
logging.basicConfig(
@@ -132,6 +132,7 @@ async def general_exception_handler(request: Request, exc: Exception) -> JSONRes
132132
app.include_router(tasks.router) # Has its own prefixes defined
133133
app.include_router(audit.router, prefix="/api")
134134
app.include_router(jobs.router) # Dapr Jobs callbacks at /api/jobs
135+
app.include_router(workers.router, prefix="/api/workers") # Worker sync operations
135136

136137

137138
@app.post("/chatkit")

apps/api/src/taskflow_api/routers/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""FastAPI routers."""
22

3-
from . import agents, audit, health, jobs, members, projects, tasks
3+
from . import agents, audit, health, jobs, members, projects, tasks, workers
44

55
__all__ = [
66
"agents",
@@ -10,4 +10,5 @@
1010
"members",
1111
"projects",
1212
"tasks",
13+
"workers",
1314
]
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""Worker endpoints - org members with project assignments (least privilege model).
2+
3+
Philosophy: Org membership ≠ Project access
4+
- SSO is source of truth for "who's in the org"
5+
- TaskFlow is source of truth for "who can access what project"
6+
- This endpoint merges both views for the workers page
7+
"""
8+
9+
import logging
10+
from typing import Literal
11+
12+
import httpx
13+
from fastapi import APIRouter, Depends, HTTPException, Request
14+
from pydantic import BaseModel
15+
from sqlmodel import select
16+
from sqlmodel.ext.asyncio.session import AsyncSession
17+
18+
from ..auth import CurrentUser, get_current_user, get_tenant_id
19+
from ..config import settings
20+
from ..database import get_session
21+
from ..models.project import Project, ProjectMember
22+
from ..models.worker import Worker
23+
from ..services.user_setup import ensure_user_setup
24+
25+
logger = logging.getLogger(__name__)
26+
27+
router = APIRouter(tags=["Workers"])
28+
29+
30+
class ProjectInfo(BaseModel):
31+
"""Project summary for worker display."""
32+
id: int
33+
name: str
34+
role: str
35+
36+
37+
class OrgMemberWithProjects(BaseModel):
38+
"""Organization member with their project assignments."""
39+
user_id: str
40+
email: str
41+
name: str
42+
image: str | None
43+
org_role: str # owner/admin/member in org
44+
projects: list[ProjectInfo]
45+
has_project_access: bool
46+
47+
48+
class WorkerListResponse(BaseModel):
49+
"""Combined view of org members and agents."""
50+
org_members: list[OrgMemberWithProjects]
51+
agents: list[dict]
52+
total_members: int
53+
total_agents: int
54+
unassigned_count: int
55+
56+
57+
@router.get("", response_model=WorkerListResponse)
58+
async def list_workers(
59+
request: Request,
60+
session: AsyncSession = Depends(get_session),
61+
user: CurrentUser = Depends(get_current_user),
62+
) -> WorkerListResponse:
63+
"""List all org members with their project assignments.
64+
65+
Fetches org members from SSO and merges with project assignments.
66+
Shows who has access to which projects (least privilege view).
67+
68+
Returns:
69+
- org_members: All org members with their project assignments
70+
- agents: All registered AI agents
71+
- unassigned_count: Members with no project access yet
72+
"""
73+
await ensure_user_setup(session, user)
74+
tenant_id = get_tenant_id(user, request)
75+
76+
logger.info("[WORKERS] Fetching org members for tenant: %s", tenant_id)
77+
78+
# Get Authorization header to forward to SSO
79+
auth_header = request.headers.get("Authorization")
80+
if not auth_header:
81+
raise HTTPException(status_code=401, detail="Missing Authorization header")
82+
83+
# Step 1: Fetch org members from SSO
84+
sso_url = f"{settings.sso_url}/api/organizations/{tenant_id}/members"
85+
86+
try:
87+
async with httpx.AsyncClient(timeout=30.0) as client:
88+
response = await client.get(
89+
sso_url,
90+
headers={
91+
"Authorization": auth_header,
92+
"Cookie": request.headers.get("Cookie", ""),
93+
},
94+
)
95+
96+
if response.status_code == 401:
97+
raise HTTPException(status_code=401, detail="SSO authentication failed")
98+
if response.status_code == 403:
99+
raise HTTPException(
100+
status_code=403, detail="Not a member of this organization"
101+
)
102+
if response.status_code != 200:
103+
logger.error("[WORKERS] SSO returned %d: %s", response.status_code, response.text)
104+
raise HTTPException(
105+
status_code=502,
106+
detail=f"Failed to fetch org members from SSO: {response.status_code}",
107+
)
108+
109+
sso_data = response.json()
110+
111+
except httpx.RequestError as e:
112+
logger.error("[WORKERS] SSO request failed: %s", e)
113+
raise HTTPException(
114+
status_code=502,
115+
detail=f"Failed to connect to SSO: {e}",
116+
)
117+
118+
org_members_raw = sso_data.get("members", [])
119+
logger.info("[WORKERS] Found %d org members from SSO", len(org_members_raw))
120+
121+
# Step 2: Get all projects for this tenant
122+
stmt = select(Project).where(Project.tenant_id == tenant_id)
123+
result = await session.exec(stmt)
124+
projects = {p.id: p for p in result.all()}
125+
126+
# Step 3: Get all project memberships for this tenant's projects
127+
# Map: user_id -> list of (project_id, role)
128+
user_project_map: dict[str, list[tuple[int, str]]] = {}
129+
130+
for project_id in projects:
131+
stmt = select(ProjectMember, Worker).join(Worker).where(
132+
ProjectMember.project_id == project_id
133+
)
134+
result = await session.exec(stmt)
135+
for membership, worker in result.all():
136+
if worker.user_id:
137+
if worker.user_id not in user_project_map:
138+
user_project_map[worker.user_id] = []
139+
user_project_map[worker.user_id].append((project_id, membership.role))
140+
141+
# Step 4: Build response - merge SSO members with project assignments
142+
org_members_with_projects: list[OrgMemberWithProjects] = []
143+
unassigned_count = 0
144+
145+
for member in org_members_raw:
146+
user_id = member.get("userId", "")
147+
user_projects = user_project_map.get(user_id, [])
148+
149+
project_infos = [
150+
ProjectInfo(
151+
id=pid,
152+
name=projects[pid].name,
153+
role=role,
154+
)
155+
for pid, role in user_projects
156+
if pid in projects
157+
]
158+
159+
has_access = len(project_infos) > 0
160+
if not has_access:
161+
unassigned_count += 1
162+
163+
org_members_with_projects.append(
164+
OrgMemberWithProjects(
165+
user_id=user_id,
166+
email=member.get("email", ""),
167+
name=member.get("name") or member.get("email", "").split("@")[0],
168+
image=member.get("image"),
169+
org_role=member.get("role", "member"),
170+
projects=project_infos,
171+
has_project_access=has_access,
172+
)
173+
)
174+
175+
# Step 5: Get all agents (agents are global, not tied to users)
176+
stmt = select(Worker).where(Worker.type == "agent")
177+
result = await session.exec(stmt)
178+
agents = [
179+
{
180+
"id": w.id,
181+
"handle": w.handle,
182+
"name": w.name,
183+
"type": w.type,
184+
"description": w.description,
185+
}
186+
for w in result.all()
187+
]
188+
189+
logger.info(
190+
"[WORKERS] Response: %d org members (%d unassigned), %d agents",
191+
len(org_members_with_projects),
192+
unassigned_count,
193+
len(agents),
194+
)
195+
196+
return WorkerListResponse(
197+
org_members=org_members_with_projects,
198+
agents=agents,
199+
total_members=len(org_members_with_projects),
200+
total_agents=len(agents),
201+
unassigned_count=unassigned_count,
202+
)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* API endpoint to list organization members.
3+
*
4+
* Used by TaskFlow API to sync org members as workers.
5+
* Requires the user to be a member of the organization.
6+
*/
7+
8+
import { auth } from "@/lib/auth";
9+
import { headers } from "next/headers";
10+
import { NextResponse } from "next/server";
11+
import { db } from "@/lib/db";
12+
import { member, user } from "@/lib/db/schema-export";
13+
import { eq, and } from "drizzle-orm";
14+
15+
export async function GET(
16+
request: Request,
17+
{ params }: { params: Promise<{ orgId: string }> }
18+
) {
19+
const session = await auth.api.getSession({
20+
headers: await headers(),
21+
});
22+
23+
if (!session) {
24+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
25+
}
26+
27+
const { orgId } = await params;
28+
29+
try {
30+
// Verify user is member of this organization
31+
const userMembership = await db.query.member.findFirst({
32+
where: and(
33+
eq(member.organizationId, orgId),
34+
eq(member.userId, session.user.id)
35+
),
36+
});
37+
38+
if (!userMembership) {
39+
return NextResponse.json(
40+
{ error: "Not a member of this organization" },
41+
{ status: 403 }
42+
);
43+
}
44+
45+
// Fetch all members with user details
46+
const members = await db
47+
.select({
48+
userId: user.id,
49+
email: user.email,
50+
name: user.name,
51+
image: user.image,
52+
role: member.role,
53+
joinedAt: member.createdAt,
54+
})
55+
.from(member)
56+
.innerJoin(user, eq(member.userId, user.id))
57+
.where(eq(member.organizationId, orgId));
58+
59+
return NextResponse.json({
60+
organizationId: orgId,
61+
members: members.map((m: typeof members[number]) => ({
62+
userId: m.userId,
63+
email: m.email,
64+
name: m.name,
65+
image: m.image,
66+
role: m.role,
67+
joinedAt: m.joinedAt?.toISOString() || null,
68+
})),
69+
});
70+
} catch (error) {
71+
console.error("Failed to fetch organization members:", error);
72+
return NextResponse.json(
73+
{ error: "Internal server error" },
74+
{ status: 500 }
75+
);
76+
}
77+
}

0 commit comments

Comments
 (0)