Files
hive/backend/app/api/members.py
anthonyrawlins 268214d971 Major WHOOSH system refactoring and feature enhancements
- Migrated from HIVE branding to WHOOSH across all components
- Enhanced backend API with new services: AI models, BZZZ integration, templates, members
- Added comprehensive testing suite with security, performance, and integration tests
- Improved frontend with new components for project setup, AI models, and team management
- Updated MCP server implementation with WHOOSH-specific tools and resources
- Enhanced deployment configurations with production-ready Docker setups
- Added comprehensive documentation and setup guides
- Implemented age encryption service and UCXL integration

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 08:34:48 +10:00

515 lines
19 KiB
Python

"""
Member Management API for WHOOSH - Handles project member invitations, roles, and collaboration.
"""
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from pydantic import BaseModel, Field, EmailStr
from typing import List, Dict, Optional, Any
from datetime import datetime
from app.services.member_service import MemberService
from app.services.project_service import ProjectService
from app.services.age_service import AgeService
from app.core.auth_deps import get_current_user_context
router = APIRouter(prefix="/api/members", tags=["member-management"])
# Pydantic models for request/response validation
class MemberInviteRequest(BaseModel):
project_id: str = Field(..., min_length=1, max_length=100)
member_email: EmailStr
role: str = Field(..., pattern="^(owner|maintainer|developer|viewer)$")
custom_message: Optional[str] = Field(None, max_length=1000)
send_email: bool = True
include_age_key: bool = True
class MemberInviteResponse(BaseModel):
success: bool
invitation_id: Optional[str] = None
invitation_url: Optional[str] = None
member_email: str
role: str
expires_at: Optional[str] = None
email_sent: bool = False
error: Optional[str] = None
class InvitationAcceptRequest(BaseModel):
invitation_token: str
accepter_name: str = Field(..., min_length=1, max_length=100)
accepter_username: Optional[str] = Field(None, max_length=50)
gitea_username: Optional[str] = Field(None, max_length=50)
setup_preferences: Optional[Dict[str, Any]] = None
class InvitationAcceptResponse(BaseModel):
success: bool
member_email: str
role: str
project_id: str
project_name: str
gitea_access: Optional[Dict[str, Any]] = None
age_access: Optional[Dict[str, Any]] = None
permissions: List[str]
next_steps: List[str]
error: Optional[str] = None
class ProjectMemberInfo(BaseModel):
email: str
role: str
status: str
invited_at: str
invited_by: str
accepted_at: Optional[str] = None
permissions: List[str]
gitea_access: bool = False
age_access: bool = False
class MemberRoleUpdateRequest(BaseModel):
member_email: EmailStr
new_role: str = Field(..., pattern="^(owner|maintainer|developer|viewer)$")
reason: Optional[str] = Field(None, max_length=500)
class MemberRemovalRequest(BaseModel):
member_email: EmailStr
reason: Optional[str] = Field(None, max_length=500)
def get_member_service():
"""Dependency injection for member service."""
return MemberService()
def get_project_service():
"""Dependency injection for project service."""
return ProjectService()
def get_age_service():
"""Dependency injection for Age service."""
return AgeService()
@router.post("/invite", response_model=MemberInviteResponse)
async def invite_member(
request: MemberInviteRequest,
background_tasks: BackgroundTasks,
current_user: Dict[str, Any] = Depends(get_current_user_context),
member_service: MemberService = Depends(get_member_service),
project_service: ProjectService = Depends(get_project_service),
age_service: AgeService = Depends(get_age_service)
):
"""Invite a new member to join a project."""
try:
# Verify project exists and user has permission to invite
project = project_service.get_project_by_id(request.project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# TODO: Check if current user has permission to invite members
# For now, assume permission is granted
inviter_name = current_user.get("name", "WHOOSH User")
project_name = project.get("name", request.project_id)
# Generate invitation
invitation_result = member_service.generate_member_invitation(
project_id=request.project_id,
member_email=request.member_email,
role=request.role,
inviter_name=inviter_name,
project_name=project_name,
custom_message=request.custom_message
)
if not invitation_result.get("created"):
raise HTTPException(
status_code=500,
detail=invitation_result.get("error", "Failed to create invitation")
)
# Send email invitation if requested
email_sent = False
if request.send_email:
# Get Age public key if requested
age_public_key = None
if request.include_age_key:
try:
project_keys = age_service.list_project_keys(request.project_id)
if project_keys:
age_public_key = project_keys[0]["public_key"]
except Exception as e:
print(f"Warning: Could not retrieve Age key: {e}")
# Send email in background
background_tasks.add_task(
member_service.send_email_invitation,
invitation_result,
age_public_key
)
email_sent = True
return MemberInviteResponse(
success=True,
invitation_id=invitation_result["invitation_id"],
invitation_url=invitation_result["invitation_url"],
member_email=request.member_email,
role=request.role,
expires_at=invitation_result["expires_at"],
email_sent=email_sent
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to invite member: {str(e)}")
@router.get("/invitations/{invitation_id}")
async def get_invitation_details(
invitation_id: str,
member_service: MemberService = Depends(get_member_service)
):
"""Get invitation details for verification and display."""
try:
invitation_status = member_service.get_invitation_status(invitation_id)
if not invitation_status:
raise HTTPException(status_code=404, detail="Invitation not found")
return invitation_status
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve invitation: {str(e)}")
@router.post("/invitations/{invitation_id}/accept", response_model=InvitationAcceptResponse)
async def accept_invitation(
invitation_id: str,
request: InvitationAcceptRequest,
member_service: MemberService = Depends(get_member_service)
):
"""Accept a project invitation and set up member access."""
try:
# Validate invitation token first
if not member_service.validate_invitation_token(invitation_id, request.invitation_token):
raise HTTPException(status_code=401, detail="Invalid invitation token")
# Prepare accepter data
accepter_data = {
"name": request.accepter_name,
"username": request.accepter_username,
"gitea_username": request.gitea_username or request.accepter_username,
"setup_preferences": request.setup_preferences or {},
"accepted_via": "whoosh_api"
}
# Process acceptance
result = member_service.accept_invitation(
invitation_id=invitation_id,
invitation_token=request.invitation_token,
accepter_data=accepter_data
)
if not result.get("success"):
raise HTTPException(
status_code=400,
detail=result.get("error", "Failed to accept invitation")
)
return InvitationAcceptResponse(
success=True,
member_email=result["member_email"],
role=result["role"],
project_id=result["project_id"],
project_name=result["project_name"],
gitea_access=result.get("gitea_access"),
age_access=result.get("age_access"),
permissions=result["permissions"],
next_steps=result["next_steps"]
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to accept invitation: {str(e)}")
@router.get("/projects/{project_id}", response_model=List[ProjectMemberInfo])
async def list_project_members(
project_id: str,
current_user: Dict[str, Any] = Depends(get_current_user_context),
member_service: MemberService = Depends(get_member_service),
project_service: ProjectService = Depends(get_project_service)
):
"""List all members of a project with their roles and status."""
try:
# Verify project exists and user has permission to view members
project = project_service.get_project_by_id(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# TODO: Check if current user has permission to view members
# For now, assume permission is granted
members = member_service.list_project_members(project_id)
# Convert to response format
member_info_list = []
for member in members:
member_info = ProjectMemberInfo(
email=member["email"],
role=member["role"],
status=member["status"],
invited_at=member["invited_at"],
invited_by=member["invited_by"],
accepted_at=member.get("accepted_at"),
permissions=member["permissions"],
gitea_access=member["status"] == "accepted",
age_access=member["role"] in ["owner", "maintainer", "developer"]
)
member_info_list.append(member_info)
return member_info_list
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to list members: {str(e)}")
@router.put("/projects/{project_id}/members/role")
async def update_member_role(
project_id: str,
request: MemberRoleUpdateRequest,
current_user: Dict[str, Any] = Depends(get_current_user_context),
member_service: MemberService = Depends(get_member_service),
project_service: ProjectService = Depends(get_project_service)
):
"""Update a member's role in the project."""
try:
# Verify project exists and user has permission to manage members
project = project_service.get_project_by_id(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# TODO: Implement role updates
# This would involve updating the member's invitation record and
# updating their permissions in GITEA and Age access
return {
"success": True,
"message": f"Member role update functionality coming soon",
"member_email": request.member_email,
"new_role": request.new_role
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update member role: {str(e)}")
@router.delete("/projects/{project_id}/members")
async def remove_member(
project_id: str,
request: MemberRemovalRequest,
current_user: Dict[str, Any] = Depends(get_current_user_context),
member_service: MemberService = Depends(get_member_service),
project_service: ProjectService = Depends(get_project_service)
):
"""Remove a member from the project."""
try:
# Verify project exists and user has permission to remove members
project = project_service.get_project_by_id(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# TODO: Check if current user has permission to remove members
# For now, assume permission is granted
current_user_name = current_user.get("name", "WHOOSH User")
# Revoke member access
result = member_service.revoke_member_access(
project_id=project_id,
member_email=request.member_email,
revoked_by=current_user_name,
reason=request.reason or "No reason provided"
)
if not result.get("success"):
raise HTTPException(
status_code=400,
detail=result.get("error", "Failed to remove member")
)
return {
"success": True,
"message": "Member access revoked successfully",
"member_email": request.member_email,
"revoked_by": current_user_name
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to remove member: {str(e)}")
@router.get("/projects/{project_id}/invitations")
async def list_project_invitations(
project_id: str,
status: Optional[str] = None, # Filter by status: pending, accepted, revoked, expired
current_user: Dict[str, Any] = Depends(get_current_user_context),
member_service: MemberService = Depends(get_member_service),
project_service: ProjectService = Depends(get_project_service)
):
"""List all invitations for a project with optional status filtering."""
try:
# Verify project exists and user has permission to view invitations
project = project_service.get_project_by_id(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Get all members (which includes invitation data)
members = member_service.list_project_members(project_id)
# Filter by status if requested
if status:
members = [member for member in members if member["status"] == status]
# Add expiration status
for member in members:
if member["status"] == "pending":
# Check if invitation is expired (this would need expiration date from invitation)
member["is_expired"] = False # Placeholder
return {
"project_id": project_id,
"invitations": members,
"count": len(members),
"filtered_by_status": status
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to list invitations: {str(e)}")
@router.post("/projects/{project_id}/invitations/{invitation_id}/resend")
async def resend_invitation(
project_id: str,
invitation_id: str,
background_tasks: BackgroundTasks,
current_user: Dict[str, Any] = Depends(get_current_user_context),
member_service: MemberService = Depends(get_member_service),
age_service: AgeService = Depends(get_age_service)
):
"""Resend an invitation email to a member."""
try:
# Load invitation to verify it exists and is pending
invitation_status = member_service.get_invitation_status(invitation_id)
if not invitation_status:
raise HTTPException(status_code=404, detail="Invitation not found")
if invitation_status["project_id"] != project_id:
raise HTTPException(status_code=400, detail="Invitation does not belong to this project")
if invitation_status["status"] != "pending":
raise HTTPException(status_code=400, detail="Can only resend pending invitations")
if invitation_status["is_expired"]:
raise HTTPException(status_code=400, detail="Cannot resend expired invitation")
# Get Age public key for the project
age_public_key = None
try:
project_keys = age_service.list_project_keys(project_id)
if project_keys:
age_public_key = project_keys[0]["public_key"]
except Exception as e:
print(f"Warning: Could not retrieve Age key: {e}")
# Resend invitation email in background
invitation_data = {
"invitation_id": invitation_id,
"project_name": invitation_status["project_name"],
"member_email": invitation_status["member_email"],
"role": invitation_status["role"],
"inviter_name": current_user.get("name", "WHOOSH User"),
"invitation_url": f"/invite/{invitation_id}?token={invitation_status.get('invitation_token', '')}",
"expires_at": invitation_status["expires_at"],
"permissions": [] # Would need to get from stored invitation
}
background_tasks.add_task(
member_service.send_email_invitation,
invitation_data,
age_public_key
)
return {
"success": True,
"message": "Invitation email resent successfully",
"invitation_id": invitation_id,
"member_email": invitation_status["member_email"]
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to resend invitation: {str(e)}")
# === Member Dashboard and Profile Endpoints ===
@router.get("/profile")
async def get_member_profile(
current_user: Dict[str, Any] = Depends(get_current_user_context),
member_service: MemberService = Depends(get_member_service)
):
"""Get current member's profile and project memberships."""
try:
# TODO: Implement member profile lookup across all projects
# This would involve searching through all invitations/memberships
user_email = current_user.get("email", "")
return {
"member_email": user_email,
"name": current_user.get("name", ""),
"projects": [], # Placeholder for projects this member belongs to
"total_projects": 0,
"active_invitations": 0,
"roles": {} # Mapping of project_id to role
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get member profile: {str(e)}")
@router.get("/projects/{project_id}/permissions")
async def get_member_permissions(
project_id: str,
member_email: Optional[str] = None, # If not provided, use current user
current_user: Dict[str, Any] = Depends(get_current_user_context),
member_service: MemberService = Depends(get_member_service)
):
"""Get detailed permissions for a member in a specific project."""
try:
target_email = member_email or current_user.get("email", "")
# Get project members to find this member's role
members = member_service.list_project_members(project_id)
member_info = None
for member in members:
if member["email"] == target_email:
member_info = member
break
if not member_info:
raise HTTPException(status_code=404, detail="Member not found in project")
return {
"project_id": project_id,
"member_email": target_email,
"role": member_info["role"],
"status": member_info["status"],
"permissions": member_info["permissions"],
"can_access_gitea": member_info["status"] == "accepted",
"can_decrypt_age": member_info["role"] in ["owner", "maintainer", "developer"]
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get member permissions: {str(e)}")