""" 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)}")