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:
parent
809235e962
commit
bf5cd44dc1
20 changed files with 928 additions and 27 deletions
150
backend/app/routers/tenant.py
Normal file
150
backend/app/routers/tenant.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue