Add invite request system and Gitea Actions CI/CD pipeline
Some checks failed
Build & Deploy Staging / build (release) Waiting to run
Build & Deploy Staging / deploy (release) Has been cancelled

Invite request feature:
- Public form to request an invite when INVITE_REQUEST_ENABLED=true
- Stores requests in new invite_requests DB table
- Emails admins on new request, emails requester on approve/deny
- Admin panel tab to review, approve, and deny requests
- Approval auto-creates invite code and sends signup link

CI/CD pipeline:
- Build & push Docker image to Gitea registry on release
- Auto-deploy to staging with health check
- Manual workflow_dispatch for production deploys

Also includes client layout/sizing improvements for card grid
and opponent spacing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-07 19:38:52 -04:00
parent 0c0588f920
commit ef54ac201a
16 changed files with 1003 additions and 50 deletions

View File

@@ -138,6 +138,35 @@ class InviteCode:
}
@dataclass
class InviteRequest:
"""Invite request details."""
id: int
name: str
email: str
message: Optional[str]
status: str
ip_address: Optional[str]
created_at: datetime
reviewed_at: Optional[datetime]
reviewed_by: Optional[str]
reviewed_by_username: Optional[str] = None
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"email": self.email,
"message": self.message,
"status": self.status,
"ip_address": self.ip_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None,
"reviewed_by": self.reviewed_by,
"reviewed_by_username": self.reviewed_by_username,
}
class AdminService:
"""
Admin operations and moderation service.
@@ -1211,6 +1240,183 @@ class AdminService:
return result != "UPDATE 0"
# -------------------------------------------------------------------------
# Invite Requests
# -------------------------------------------------------------------------
async def create_invite_request(
self,
name: str,
email: str,
message: Optional[str] = None,
ip_address: Optional[str] = None,
) -> int:
"""
Create a new invite request.
Returns:
The request ID.
"""
async with self.pool.acquire() as conn:
# Check for existing pending request from same email
existing = await conn.fetchval(
"SELECT id FROM invite_requests WHERE email = $1 AND status = 'pending'",
email,
)
if existing:
return existing
row_id = await conn.fetchval(
"""
INSERT INTO invite_requests (name, email, message, ip_address)
VALUES ($1, $2, $3, $4::inet)
RETURNING id
""",
name,
email,
message,
ip_address,
)
logger.info(f"New invite request #{row_id} from {email}")
return row_id
async def get_invite_requests(self, status: Optional[str] = None) -> List[InviteRequest]:
"""Get invite requests, optionally filtered by status."""
async with self.pool.acquire() as conn:
query = """
SELECT r.id, r.name, r.email, r.message, r.status, r.ip_address,
r.created_at, r.reviewed_at, r.reviewed_by,
u.username as reviewed_by_username
FROM invite_requests r
LEFT JOIN users_v2 u ON r.reviewed_by = u.id
"""
params = []
if status:
query += " WHERE r.status = $1"
params.append(status)
query += " ORDER BY r.created_at DESC"
rows = await conn.fetch(query, *params)
return [
InviteRequest(
id=row["id"],
name=row["name"],
email=row["email"],
message=row["message"],
status=row["status"],
ip_address=str(row["ip_address"]) if row["ip_address"] else None,
created_at=row["created_at"],
reviewed_at=row["reviewed_at"],
reviewed_by=str(row["reviewed_by"]) if row["reviewed_by"] else None,
reviewed_by_username=row["reviewed_by_username"],
)
for row in rows
]
async def approve_invite_request(
self,
request_id: int,
admin_id: str,
ip_address: Optional[str] = None,
) -> Optional[str]:
"""
Approve an invite request: create an invite code and update the request.
Returns:
The generated invite code, or None if request not found/already handled.
"""
async with self.pool.acquire() as conn:
# Verify request exists and is pending
row = await conn.fetchrow(
"SELECT id, email, name FROM invite_requests WHERE id = $1 AND status = 'pending'",
request_id,
)
if not row:
return None
# Create an invite code for this request
code = secrets.token_urlsafe(6).upper()[:8]
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
invite_id = await conn.fetchval(
"""
INSERT INTO invite_codes (code, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, 1)
RETURNING id
""",
code,
admin_id,
expires_at,
)
# Update the request
await conn.execute(
"""
UPDATE invite_requests
SET status = 'approved', reviewed_at = NOW(), reviewed_by = $1, invite_code_id = $2
WHERE id = $3
""",
admin_id,
invite_id,
request_id,
)
await self.audit(
admin_id,
"approve_invite_request",
"invite_request",
str(request_id),
{"email": row["email"], "invite_code": code},
ip_address,
)
logger.info(f"Admin {admin_id} approved invite request #{request_id}, code={code}")
return code
async def deny_invite_request(
self,
request_id: int,
admin_id: str,
ip_address: Optional[str] = None,
) -> Optional[dict]:
"""
Deny an invite request.
Returns:
The request info (name, email) or None if not found/already handled.
"""
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, email, name FROM invite_requests WHERE id = $1 AND status = 'pending'",
request_id,
)
if not row:
return None
await conn.execute(
"""
UPDATE invite_requests
SET status = 'denied', reviewed_at = NOW(), reviewed_by = $1
WHERE id = $2
""",
admin_id,
request_id,
)
await self.audit(
admin_id,
"deny_invite_request",
"invite_request",
str(request_id),
{"email": row["email"]},
ip_address,
)
logger.info(f"Admin {admin_id} denied invite request #{request_id}")
return {"name": row["name"], "email": row["email"]}
# Global admin service instance
_admin_service: Optional[AdminService] = None