- 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>
640 lines
29 KiB
Python
640 lines
29 KiB
Python
"""
|
|
Member Management Service for WHOOSH - Handles project member invitations, roles, and collaboration.
|
|
Integrates with GITEA for repository access and Age encryption for secure communication.
|
|
"""
|
|
import os
|
|
import json
|
|
import smtplib
|
|
import secrets
|
|
import hashlib
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional, Any, Tuple
|
|
from datetime import datetime, timedelta
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.base import MIMEBase
|
|
from email import encoders
|
|
|
|
from app.services.gitea_service import GiteaService
|
|
from app.services.age_service import AgeService
|
|
|
|
|
|
class MemberService:
|
|
"""
|
|
Member management service for WHOOSH project collaboration.
|
|
Handles invitations, role assignments, Age key distribution, and GITEA permissions.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.gitea_service = GiteaService()
|
|
self.age_service = AgeService()
|
|
self.invitations_storage = Path("/home/tony/AI/secrets/member_invitations")
|
|
self.invitations_storage.mkdir(parents=True, exist_ok=True)
|
|
|
|
# GITEA collaboration roles mapping
|
|
self.gitea_roles = {
|
|
"owner": "admin", # Full administrative access
|
|
"maintainer": "write", # Write access, can merge PRs
|
|
"developer": "write", # Write access, standard development
|
|
"viewer": "read" # Read-only access
|
|
}
|
|
|
|
# Role permissions mapping
|
|
self.role_permissions = {
|
|
"owner": [
|
|
"repo.admin", "repo.write", "repo.read", "repo.delete",
|
|
"issues.write", "issues.read", "issues.assign",
|
|
"pulls.write", "pulls.read", "pulls.merge",
|
|
"members.invite", "members.manage", "members.remove",
|
|
"settings.configure", "age.manage"
|
|
],
|
|
"maintainer": [
|
|
"repo.write", "repo.read",
|
|
"issues.write", "issues.read", "issues.assign",
|
|
"pulls.write", "pulls.read", "pulls.merge",
|
|
"members.invite", "age.decrypt"
|
|
],
|
|
"developer": [
|
|
"repo.write", "repo.read",
|
|
"issues.write", "issues.read",
|
|
"pulls.write", "pulls.read",
|
|
"age.decrypt"
|
|
],
|
|
"viewer": [
|
|
"repo.read", "issues.read", "pulls.read"
|
|
]
|
|
}
|
|
|
|
def generate_member_invitation(self, project_id: str, member_email: str, role: str,
|
|
inviter_name: str, project_name: str,
|
|
custom_message: Optional[str] = None) -> Dict[str, Any]:
|
|
"""
|
|
Generate a secure invitation for a project member.
|
|
|
|
Args:
|
|
project_id: Project identifier
|
|
member_email: Email address of the invitee
|
|
role: Role to assign (owner, maintainer, developer, viewer)
|
|
inviter_name: Name of the person sending the invitation
|
|
project_name: Human-readable project name
|
|
custom_message: Optional custom message from inviter
|
|
|
|
Returns:
|
|
Invitation details and security tokens
|
|
"""
|
|
try:
|
|
# Generate secure invitation token
|
|
invitation_token = secrets.token_urlsafe(32)
|
|
invitation_id = f"inv_{project_id}_{hashlib.sha256(member_email.encode()).hexdigest()[:8]}"
|
|
|
|
# Create expiration (7 days from now)
|
|
expires_at = datetime.now() + timedelta(days=7)
|
|
|
|
# Create invitation record
|
|
invitation_data = {
|
|
"invitation_id": invitation_id,
|
|
"invitation_token": invitation_token,
|
|
"project_id": project_id,
|
|
"project_name": project_name,
|
|
"member_email": member_email,
|
|
"role": role,
|
|
"inviter_name": inviter_name,
|
|
"custom_message": custom_message,
|
|
"permissions": self.role_permissions.get(role, []),
|
|
"created_at": datetime.now().isoformat(),
|
|
"expires_at": expires_at.isoformat(),
|
|
"status": "pending",
|
|
"gitea_role": self.gitea_roles.get(role, "read"),
|
|
"age_key_access": role in ["owner", "maintainer", "developer"],
|
|
"responses": [],
|
|
"metadata": {
|
|
"invitation_method": "email",
|
|
"security_level": "standard",
|
|
"requires_age_key": True
|
|
}
|
|
}
|
|
|
|
# Store invitation securely
|
|
invitation_file = self.invitations_storage / f"{invitation_id}.json"
|
|
invitation_file.write_text(json.dumps(invitation_data, indent=2))
|
|
invitation_file.chmod(0o600) # Restrict access
|
|
|
|
print(f"Generated invitation for {member_email} to join {project_name} as {role}")
|
|
print(f"Invitation ID: {invitation_id}")
|
|
print(f"Expires: {expires_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
|
|
return {
|
|
"invitation_id": invitation_id,
|
|
"invitation_token": invitation_token,
|
|
"member_email": member_email,
|
|
"role": role,
|
|
"expires_at": expires_at.isoformat(),
|
|
"invitation_url": self._generate_invitation_url(invitation_id, invitation_token),
|
|
"permissions": self.role_permissions.get(role, []),
|
|
"created": True
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Error generating member invitation: {e}")
|
|
return {
|
|
"invitation_id": None,
|
|
"created": False,
|
|
"error": str(e)
|
|
}
|
|
|
|
def _generate_invitation_url(self, invitation_id: str, invitation_token: str) -> str:
|
|
"""Generate secure invitation URL for member to accept."""
|
|
base_url = os.getenv("WHOOSH_BASE_URL", "http://localhost:3000")
|
|
return f"{base_url}/invite/{invitation_id}?token={invitation_token}"
|
|
|
|
def send_email_invitation(self, invitation_data: Dict[str, Any],
|
|
age_public_key: Optional[str] = None) -> bool:
|
|
"""
|
|
Send email invitation to project member.
|
|
|
|
Args:
|
|
invitation_data: Invitation details from generate_member_invitation
|
|
age_public_key: Optional Age public key for encrypted communication
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
try:
|
|
# Email configuration (using system sendmail or SMTP)
|
|
smtp_config = self._get_smtp_config()
|
|
if not smtp_config:
|
|
print("No SMTP configuration found. Invitation email not sent.")
|
|
return False
|
|
|
|
# Create email content
|
|
subject = f"Invitation to join {invitation_data['project_name']} on WHOOSH"
|
|
|
|
# Create HTML email body
|
|
email_body = self._create_invitation_email_body(invitation_data, age_public_key)
|
|
|
|
# Create email message
|
|
msg = MIMEMultipart('alternative')
|
|
msg['Subject'] = subject
|
|
msg['From'] = smtp_config['from_email']
|
|
msg['To'] = invitation_data['member_email']
|
|
|
|
# Add HTML content
|
|
html_part = MIMEText(email_body, 'html')
|
|
msg.attach(html_part)
|
|
|
|
# Add Age public key as attachment if provided
|
|
if age_public_key:
|
|
key_attachment = MIMEBase('application', 'octet-stream')
|
|
key_content = f"# Age Public Key for {invitation_data['project_name']}\n{age_public_key}"
|
|
key_attachment.set_payload(key_content.encode())
|
|
encoders.encode_base64(key_attachment)
|
|
key_attachment.add_header(
|
|
'Content-Disposition',
|
|
f'attachment; filename="{invitation_data["project_id"]}_public_key.age"'
|
|
)
|
|
msg.attach(key_attachment)
|
|
|
|
# Send email
|
|
with smtplib.SMTP(smtp_config['smtp_host'], smtp_config['smtp_port']) as server:
|
|
if smtp_config.get('use_tls'):
|
|
server.starttls()
|
|
if smtp_config.get('username'):
|
|
server.login(smtp_config['username'], smtp_config['password'])
|
|
|
|
server.send_message(msg)
|
|
|
|
print(f"Invitation email sent to {invitation_data['member_email']}")
|
|
|
|
# Update invitation status
|
|
self._update_invitation_status(
|
|
invitation_data['invitation_id'],
|
|
"email_sent",
|
|
{"email_sent_at": datetime.now().isoformat()}
|
|
)
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"Error sending invitation email: {e}")
|
|
self._update_invitation_status(
|
|
invitation_data['invitation_id'],
|
|
"email_failed",
|
|
{"email_error": str(e)}
|
|
)
|
|
return False
|
|
|
|
def _get_smtp_config(self) -> Optional[Dict[str, Any]]:
|
|
"""Get SMTP configuration from environment or secrets."""
|
|
try:
|
|
# Try to load from secrets file first
|
|
smtp_config_path = Path("/home/tony/AI/secrets/smtp_config.json")
|
|
if smtp_config_path.exists():
|
|
return json.loads(smtp_config_path.read_text())
|
|
|
|
# Fallback to environment variables
|
|
smtp_host = os.getenv("SMTP_HOST")
|
|
if smtp_host:
|
|
return {
|
|
"smtp_host": smtp_host,
|
|
"smtp_port": int(os.getenv("SMTP_PORT", "587")),
|
|
"from_email": os.getenv("SMTP_FROM_EMAIL", "noreply@whoosh.local"),
|
|
"username": os.getenv("SMTP_USERNAME"),
|
|
"password": os.getenv("SMTP_PASSWORD"),
|
|
"use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true"
|
|
}
|
|
|
|
return None
|
|
|
|
except Exception as e:
|
|
print(f"Error loading SMTP configuration: {e}")
|
|
return None
|
|
|
|
def _create_invitation_email_body(self, invitation_data: Dict[str, Any],
|
|
age_public_key: Optional[str] = None) -> str:
|
|
"""Create HTML email body for member invitation."""
|
|
|
|
# Calculate days until expiration
|
|
expires_at = datetime.fromisoformat(invitation_data['expires_at'])
|
|
days_until_expiry = (expires_at - datetime.now()).days
|
|
|
|
role_descriptions = {
|
|
"owner": "Full administrative access to the project",
|
|
"maintainer": "Write access with merge permissions",
|
|
"developer": "Write access for development work",
|
|
"viewer": "Read-only access to project resources"
|
|
}
|
|
|
|
html_body = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>WHOOSH Project Invitation</title>
|
|
<style>
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }}
|
|
.content {{ background: white; padding: 30px; border: 1px solid #ddd; }}
|
|
.footer {{ background: #f8f9fa; padding: 20px; text-align: center; border-radius: 0 0 8px 8px; font-size: 14px; color: #666; }}
|
|
.btn {{ display: inline-block; background: #667eea; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500; }}
|
|
.role-badge {{ background: #e3f2fd; color: #1976d2; padding: 4px 12px; border-radius: 16px; font-size: 14px; font-weight: 500; }}
|
|
.permissions {{ background: #f5f5f5; padding: 15px; border-radius: 6px; margin: 15px 0; }}
|
|
.permissions ul {{ margin: 0; padding-left: 20px; }}
|
|
.security-info {{ background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 6px; margin: 15px 0; }}
|
|
.expiry-warning {{ background: #d4edda; border: 1px solid #c3e6cb; padding: 10px; border-radius: 6px; margin: 15px 0; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>🚀 You're Invited to Join</h1>
|
|
<h2>{invitation_data['project_name']}</h2>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<p>Hi there!</p>
|
|
|
|
<p><strong>{invitation_data['inviter_name']}</strong> has invited you to collaborate on the project <strong>{invitation_data['project_name']}</strong> through the WHOOSH platform.</p>
|
|
|
|
{"<div style='background: #f8f9fa; padding: 15px; border-radius: 6px; margin: 15px 0; border-left: 4px solid #667eea;'>" + invitation_data['custom_message'] + "</div>" if invitation_data.get('custom_message') else ""}
|
|
|
|
<h3>Your Role: <span class="role-badge">{invitation_data['role'].title()}</span></h3>
|
|
<p>{role_descriptions.get(invitation_data['role'], 'Custom role with specific permissions')}</p>
|
|
|
|
<div class="permissions">
|
|
<h4>🔐 Your Permissions</h4>
|
|
<ul>
|
|
{"".join(f"<li>{perm.replace('_', ' ').replace('.', ': ').title()}</li>" for perm in invitation_data['permissions'][:6])}
|
|
{f"<li>... and {len(invitation_data['permissions']) - 6} more</li>" if len(invitation_data['permissions']) > 6 else ""}
|
|
</ul>
|
|
</div>
|
|
|
|
{f'''
|
|
<div class="security-info">
|
|
<h4>🔒 Secure Communication</h4>
|
|
<p>This project uses <strong>Age encryption</strong> for secure member communication. An Age public key is attached to this email for encrypted data exchange.</p>
|
|
<p>Once you join, you'll receive access to the project's encryption keys for secure collaboration.</p>
|
|
</div>
|
|
''' if age_public_key else ''}
|
|
|
|
<div class="expiry-warning">
|
|
<p><strong>⏰ Time Sensitive:</strong> This invitation expires in <strong>{days_until_expiry} days</strong> ({expires_at.strftime('%B %d, %Y at %I:%M %p')})</p>
|
|
</div>
|
|
|
|
<div style="text-align: center; margin: 30px 0;">
|
|
<a href="{invitation_data['invitation_url']}" class="btn">Accept Invitation</a>
|
|
</div>
|
|
|
|
<h3>What happens next?</h3>
|
|
<ol>
|
|
<li><strong>Accept the invitation</strong> using the button above</li>
|
|
<li><strong>Set up your WHOOSH account</strong> (if you don't have one)</li>
|
|
<li><strong>Gain access to the project repository</strong> and collaboration tools</li>
|
|
<li><strong>Start collaborating</strong> with the team immediately</li>
|
|
</ol>
|
|
|
|
<h3>🛠️ WHOOSH Features You'll Access</h3>
|
|
<ul>
|
|
<li><strong>GITEA Integration:</strong> Direct access to project repositories</li>
|
|
<li><strong>BZZZ Task Coordination:</strong> AI-powered task assignment and collaboration</li>
|
|
<li><strong>Age Encryption:</strong> Secure communication and data sharing</li>
|
|
<li><strong>Project Metrics:</strong> Real-time progress tracking and analytics</li>
|
|
</ul>
|
|
|
|
<p>If you have any questions about this invitation or need help getting started, feel free to reach out to <strong>{invitation_data['inviter_name']}</strong> or the WHOOSH support team.</p>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>This invitation was sent by WHOOSH Project Management Platform</p>
|
|
<p>If you believe you received this invitation in error, please ignore this email.</p>
|
|
<p><small>Invitation ID: {invitation_data['invitation_id']}</small></p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return html_body
|
|
|
|
def accept_invitation(self, invitation_id: str, invitation_token: str,
|
|
accepter_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Process invitation acceptance and set up member access.
|
|
|
|
Args:
|
|
invitation_id: Invitation identifier
|
|
invitation_token: Security token for verification
|
|
accepter_data: Data from the person accepting (name, username, etc.)
|
|
|
|
Returns:
|
|
Setup results and next steps
|
|
"""
|
|
try:
|
|
# Load and validate invitation
|
|
invitation = self._load_invitation(invitation_id)
|
|
if not invitation:
|
|
return {"success": False, "error": "Invitation not found"}
|
|
|
|
if invitation["status"] != "pending":
|
|
return {"success": False, "error": f"Invitation already {invitation['status']}"}
|
|
|
|
if invitation["invitation_token"] != invitation_token:
|
|
return {"success": False, "error": "Invalid invitation token"}
|
|
|
|
# Check expiration
|
|
expires_at = datetime.fromisoformat(invitation["expires_at"])
|
|
if datetime.now() > expires_at:
|
|
return {"success": False, "error": "Invitation has expired"}
|
|
|
|
# Extract setup data
|
|
project_id = invitation["project_id"]
|
|
member_email = invitation["member_email"]
|
|
role = invitation["role"]
|
|
gitea_role = invitation["gitea_role"]
|
|
|
|
# Set up GITEA repository access
|
|
gitea_setup = self._setup_gitea_member_access(
|
|
project_id, member_email, gitea_role, accepter_data
|
|
)
|
|
|
|
# Set up Age encryption access if required
|
|
age_setup = None
|
|
if invitation["age_key_access"]:
|
|
age_setup = self._setup_age_member_access(
|
|
project_id, member_email, role, accepter_data
|
|
)
|
|
|
|
# Update invitation status
|
|
self._update_invitation_status(
|
|
invitation_id,
|
|
"accepted",
|
|
{
|
|
"accepted_at": datetime.now().isoformat(),
|
|
"accepter_data": accepter_data,
|
|
"gitea_setup": gitea_setup,
|
|
"age_setup": age_setup
|
|
}
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"member_email": member_email,
|
|
"role": role,
|
|
"project_id": project_id,
|
|
"project_name": invitation["project_name"],
|
|
"gitea_access": gitea_setup,
|
|
"age_access": age_setup,
|
|
"permissions": invitation["permissions"],
|
|
"next_steps": self._generate_next_steps(invitation, gitea_setup, age_setup)
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Error accepting invitation: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
def _setup_gitea_member_access(self, project_id: str, member_email: str,
|
|
gitea_role: str, accepter_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Set up GITEA repository access for new member."""
|
|
try:
|
|
# Get project repository info
|
|
# Note: This would need to be coordinated with project service to get repo details
|
|
# For now, assume standard naming convention
|
|
repo_owner = "whoosh" # Default organization
|
|
repo_name = project_id
|
|
|
|
# Add collaborator to repository
|
|
# Note: GITEA API for adding collaborators would be implemented here
|
|
# For now, return setup information
|
|
|
|
return {
|
|
"gitea_username": accepter_data.get("gitea_username", member_email.split("@")[0]),
|
|
"repository": f"{repo_owner}/{repo_name}",
|
|
"role": gitea_role,
|
|
"access_granted": True,
|
|
"repository_url": f"{self.gitea_service.gitea_base_url}/{repo_owner}/{repo_name}"
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Error setting up GITEA access: {e}")
|
|
return {"access_granted": False, "error": str(e)}
|
|
|
|
def _setup_age_member_access(self, project_id: str, member_email: str,
|
|
role: str, accepter_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Set up Age encryption access for new member."""
|
|
try:
|
|
# Get project Age keys
|
|
project_keys = self.age_service.list_project_keys(project_id)
|
|
if not project_keys:
|
|
return {"age_access": False, "error": "No Age keys found for project"}
|
|
|
|
# For now, provide the public key for encrypted communication
|
|
# In a full implementation, this would involve key exchange protocols
|
|
primary_key = project_keys[0]
|
|
|
|
return {
|
|
"age_public_key": primary_key["public_key"],
|
|
"key_id": primary_key["key_id"],
|
|
"encryption_enabled": True,
|
|
"member_can_decrypt": role in ["owner", "maintainer", "developer"],
|
|
"setup_instructions": "Save the Age public key for encrypting data to this project"
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Error setting up Age access: {e}")
|
|
return {"age_access": False, "error": str(e)}
|
|
|
|
def _generate_next_steps(self, invitation: Dict, gitea_setup: Dict, age_setup: Optional[Dict]) -> List[str]:
|
|
"""Generate personalized next steps for new member."""
|
|
steps = [
|
|
f"Welcome to {invitation['project_name']}! Your {invitation['role']} access is now active.",
|
|
]
|
|
|
|
if gitea_setup.get("access_granted"):
|
|
steps.append(f"Clone the repository: git clone {gitea_setup.get('repository_url')}")
|
|
steps.append("Review the project README and documentation")
|
|
|
|
if age_setup and age_setup.get("encryption_enabled"):
|
|
steps.append("Set up Age encryption for secure communication")
|
|
if age_setup.get("member_can_decrypt"):
|
|
steps.append("Contact project owner for private key access (if needed)")
|
|
|
|
steps.extend([
|
|
"Check project issues and BZZZ tasks for available work",
|
|
"Join the project communication channels",
|
|
"Review project settings and configuration"
|
|
])
|
|
|
|
return steps
|
|
|
|
def _load_invitation(self, invitation_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Load invitation data from secure storage."""
|
|
try:
|
|
invitation_file = self.invitations_storage / f"{invitation_id}.json"
|
|
if invitation_file.exists():
|
|
return json.loads(invitation_file.read_text())
|
|
return None
|
|
except Exception as e:
|
|
print(f"Error loading invitation {invitation_id}: {e}")
|
|
return None
|
|
|
|
def _update_invitation_status(self, invitation_id: str, status: str,
|
|
metadata: Optional[Dict[str, Any]] = None):
|
|
"""Update invitation status and metadata."""
|
|
try:
|
|
invitation = self._load_invitation(invitation_id)
|
|
if invitation:
|
|
invitation["status"] = status
|
|
invitation["updated_at"] = datetime.now().isoformat()
|
|
if metadata:
|
|
invitation.setdefault("responses", []).append({
|
|
"timestamp": datetime.now().isoformat(),
|
|
"status": status,
|
|
"metadata": metadata
|
|
})
|
|
|
|
invitation_file = self.invitations_storage / f"{invitation_id}.json"
|
|
invitation_file.write_text(json.dumps(invitation, indent=2))
|
|
|
|
except Exception as e:
|
|
print(f"Error updating invitation status: {e}")
|
|
|
|
def list_project_members(self, project_id: str) -> List[Dict[str, Any]]:
|
|
"""List all members of a project with their roles and status."""
|
|
members = []
|
|
|
|
try:
|
|
# Search for all invitations related to this project
|
|
for invitation_file in self.invitations_storage.glob("*.json"):
|
|
try:
|
|
invitation = json.loads(invitation_file.read_text())
|
|
if invitation.get("project_id") == project_id:
|
|
member_info = {
|
|
"email": invitation["member_email"],
|
|
"role": invitation["role"],
|
|
"status": invitation["status"],
|
|
"invited_at": invitation["created_at"],
|
|
"invited_by": invitation["inviter_name"],
|
|
"permissions": invitation["permissions"]
|
|
}
|
|
|
|
if invitation["status"] == "accepted":
|
|
# Add acceptance details
|
|
for response in invitation.get("responses", []):
|
|
if response.get("status") == "accepted":
|
|
member_info["accepted_at"] = response["timestamp"]
|
|
member_info["accepter_data"] = response.get("metadata", {}).get("accepter_data", {})
|
|
break
|
|
|
|
members.append(member_info)
|
|
|
|
except Exception as e:
|
|
print(f"Error reading invitation file {invitation_file}: {e}")
|
|
continue
|
|
|
|
return members
|
|
|
|
except Exception as e:
|
|
print(f"Error listing project members: {e}")
|
|
return []
|
|
|
|
def revoke_member_access(self, project_id: str, member_email: str,
|
|
revoked_by: str, reason: str = "") -> Dict[str, Any]:
|
|
"""Revoke member access to a project."""
|
|
try:
|
|
# Find the member's invitation
|
|
for invitation_file in self.invitations_storage.glob("*.json"):
|
|
try:
|
|
invitation = json.loads(invitation_file.read_text())
|
|
if (invitation.get("project_id") == project_id and
|
|
invitation.get("member_email") == member_email):
|
|
|
|
# Update invitation status
|
|
self._update_invitation_status(
|
|
invitation["invitation_id"],
|
|
"revoked",
|
|
{
|
|
"revoked_by": revoked_by,
|
|
"revoke_reason": reason,
|
|
"revoked_at": datetime.now().isoformat()
|
|
}
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"member_email": member_email,
|
|
"revoked_by": revoked_by,
|
|
"revoke_reason": reason
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Error processing invitation file {invitation_file}: {e}")
|
|
continue
|
|
|
|
return {"success": False, "error": "Member not found"}
|
|
|
|
except Exception as e:
|
|
print(f"Error revoking member access: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
def get_invitation_status(self, invitation_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Get current status of an invitation."""
|
|
invitation = self._load_invitation(invitation_id)
|
|
if invitation:
|
|
return {
|
|
"invitation_id": invitation_id,
|
|
"status": invitation["status"],
|
|
"project_name": invitation["project_name"],
|
|
"member_email": invitation["member_email"],
|
|
"role": invitation["role"],
|
|
"created_at": invitation["created_at"],
|
|
"expires_at": invitation["expires_at"],
|
|
"is_expired": datetime.now() > datetime.fromisoformat(invitation["expires_at"])
|
|
}
|
|
return None
|
|
|
|
def validate_invitation_token(self, invitation_id: str, token: str) -> bool:
|
|
"""Validate an invitation token for security."""
|
|
invitation = self._load_invitation(invitation_id)
|
|
if invitation:
|
|
return invitation.get("invitation_token") == token
|
|
return False |