- 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.
150 lines
7.4 KiB
Python
150 lines
7.4 KiB
Python
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)
|