feat: implement workspace management and member invitations

- Added tenant API for managing workspaces, including listing, creating, and inviting members.
- Introduced new models for organizations, workspaces, and workspace memberships in the backend.
- Created frontend components for workspace administration, allowing users to manage workspaces and invite members.
- Updated document listing to support workspace-specific filtering.
- Implemented account settings page for user profile management and password changes.
- Added end-to-end tests for workspace administration and dashboard functionalities.
This commit is contained in:
Anurag Jangid 2026-10-23 01:19:11 +05:30
parent 809235e962
commit bf5cd44dc1
20 changed files with 928 additions and 27 deletions

View file

@ -2,8 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user, get_mongo_db_dep
from app.database import get_db
from app.services.tenant_service import get_workspace
from app.models.user import User
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, DocumentVersionResponse, ShareRequest
from app.schemas.collaboration import (
@ -199,10 +200,19 @@ async def list_docs(
stead_id: str | None = Query(None, description="Filter by parent stead"),
parent_page_id: str | None = Query(None, description="Filter by parent page"),
starred: bool | None = Query(None, description="Filter starred projects"),
workspace_id: int | None = Query(None, description="Filter by workspace"),
sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
auth_db: Session = Depends(get_db),
) -> list[DocumentResponse]:
if workspace_id is not None:
if get_workspace(auth_db, workspace_id, current_user.id) is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied")
await db["documents"].update_many(
{"owner_id": current_user.id, "workspace_id": {"$exists": False}},
{"$set": {"workspace_id": workspace_id}},
)
sort_field = "last_visited_at" if sort == "last_visited_at" else "updated_at"
docs = await list_documents(
db,
@ -212,6 +222,7 @@ async def list_docs(
parent_page_id=parent_page_id,
starred=starred,
sort=sort_field,
workspace_id=workspace_id,
)
return [DocumentResponse(**doc) for doc in docs]
@ -243,7 +254,10 @@ async def create_doc(
data: DocumentCreate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
auth_db: Session = Depends(get_db),
) -> DocumentResponse:
if data.workspace_id is not None and get_workspace(auth_db, data.workspace_id, current_user.id) is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied")
created_by = current_user.full_name or current_user.username
try:
doc = await create_document(db, current_user.id, created_by, data)

View file

@ -0,0 +1,150 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.models.tenant import WorkspaceInvitation
from app.schemas.tenant import (
InvitationCreate,
InvitationResponse,
MemberResponse,
RoleUpdate,
AuditEventResponse,
WorkspaceCreate,
WorkspaceResponse,
)
from app.config import settings
from app.services.tenant_service import (
accept_invitation,
can_manage,
create_workspace,
get_membership,
get_workspace,
invite_member,
list_audit_events,
revoke_invitation,
transfer_ownership,
list_members,
list_workspaces,
update_member_role,
)
router = APIRouter(prefix="/tenants", tags=["tenants"])
def workspace_response(row) -> WorkspaceResponse:
workspace, organization, role = row
return WorkspaceResponse(
id=workspace.id,
organization_id=organization.id,
organization_name=organization.name,
name=workspace.name,
slug=workspace.slug,
role=role,
created_at=workspace.created_at,
)
@router.get("/workspaces", response_model=list[WorkspaceResponse])
def get_workspaces(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return [workspace_response(row) for row in list_workspaces(db, current_user.id)]
@router.post("/workspaces", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
def post_workspace(data: WorkspaceCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
workspace = create_workspace(db, current_user, data)
row = get_workspace(db, workspace.id, current_user.id)
return workspace_response(row)
@router.get("/workspaces/{workspace_id}/members", response_model=list[MemberResponse])
def get_workspace_members(workspace_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
if get_workspace(db, workspace_id, current_user.id) is None:
raise HTTPException(status_code=403, detail="Workspace not found or access denied")
return [
MemberResponse(user_id=user.id, email=user.email, full_name=user.full_name, username=user.username, role=membership.role, joined_at=membership.created_at)
for user, membership in list_members(db, workspace_id)
]
@router.post("/workspaces/{workspace_id}/invitations", response_model=InvitationResponse, status_code=201)
def post_invitation(workspace_id: int, data: InvitationCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
if row is None or not can_manage(row[2]):
raise HTTPException(status_code=403, detail="Only workspace owners and admins can invite members")
invitation = invite_member(db, workspace_id, current_user.id, str(data.email), data.role)
return InvitationResponse(
id=invitation.id,
workspace_id=invitation.workspace_id,
email=invitation.email,
role=invitation.role,
expires_at=invitation.expires_at,
accepted=invitation.accepted,
token=invitation.token,
invite_url=f"{settings.frontend_url}/invite/{invitation.token}",
)
@router.delete("/workspaces/{workspace_id}/invitations/{invitation_id}", status_code=204)
def delete_invitation(workspace_id: int, invitation_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
if row is None or not can_manage(row[2]) or not revoke_invitation(db, workspace_id, invitation_id, current_user.id):
raise HTTPException(status_code=404, detail="Invitation not found")
@router.post("/workspaces/{workspace_id}/invitations/{invitation_id}/resend", response_model=InvitationResponse)
def resend_invitation(workspace_id: int, invitation_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
invitation = db.query(WorkspaceInvitation).filter_by(id=invitation_id, workspace_id=workspace_id, accepted=False).first()
if row is None or not can_manage(row[2]) or invitation is None:
raise HTTPException(status_code=404, detail="Invitation not found")
invitation.accepted = True
db.commit()
replacement = invite_member(db, workspace_id, current_user.id, invitation.email, invitation.role)
return InvitationResponse(
id=replacement.id,
workspace_id=replacement.workspace_id,
email=replacement.email,
role=replacement.role,
expires_at=replacement.expires_at,
accepted=replacement.accepted,
token=replacement.token,
invite_url=f"{settings.frontend_url}/invite/{replacement.token}",
)
@router.post("/workspaces/{workspace_id}/ownership/{user_id}", response_model=WorkspaceResponse)
def post_transfer_ownership(workspace_id: int, user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
if row is None or row[2] != "owner" or not transfer_ownership(db, workspace_id, current_user.id, user_id):
raise HTTPException(status_code=403, detail="Only the workspace owner can transfer ownership")
return workspace_response(get_workspace(db, workspace_id, user_id))
@router.get("/workspaces/{workspace_id}/audit", response_model=list[AuditEventResponse])
def get_audit(workspace_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
if row is None or not can_manage(row[2]):
raise HTTPException(status_code=403, detail="Only workspace admins can view audit history")
return [AuditEventResponse.model_validate(event, from_attributes=True) for event in list_audit_events(db, workspace_id)]
@router.post("/invitations/{token}/accept", response_model=MemberResponse)
def post_accept_invitation(token: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
membership = accept_invitation(db, token, current_user)
if membership is None:
raise HTTPException(status_code=400, detail="Invitation is invalid, expired, or not addressed to this account")
return MemberResponse(user_id=current_user.id, email=current_user.email, full_name=current_user.full_name, username=current_user.username, role=membership.role, joined_at=membership.created_at)
@router.patch("/workspaces/{workspace_id}/members/{user_id}", response_model=MemberResponse)
def patch_member_role(workspace_id: int, user_id: int, data: RoleUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
if row is None or not can_manage(row[2]):
raise HTTPException(status_code=403, detail="Only workspace owners and admins can manage roles")
membership = update_member_role(db, workspace_id, user_id, data.role)
target = db.query(User).filter(User.id == user_id).first()
if membership is None or target is None:
raise HTTPException(status_code=400, detail="Member role cannot be changed")
return MemberResponse(user_id=target.id, email=target.email, full_name=target.full_name, username=target.username, role=membership.role, joined_at=membership.created_at)