- 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>
515 lines
19 KiB
Python
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)}") |