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

@@ -198,6 +198,9 @@ function showPanel(panelId) {
case 'invites':
loadInvites();
break;
case 'invite-requests':
loadInviteRequests();
break;
case 'audit':
loadAuditLog();
break;
@@ -643,6 +646,80 @@ async function promptRevokeInvite(code) {
}
}
// =============================================================================
// Invite Requests
// =============================================================================
async function loadInviteRequests() {
const status = document.getElementById('request-status-filter').value;
const params = new URLSearchParams();
if (status) params.set('status', status);
try {
const data = await apiRequest(`/api/admin/invite-requests?${params}`);
const tbody = document.querySelector('#invite-requests-table tbody');
tbody.innerHTML = '';
if (data.requests.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">No invite requests</td></tr>';
return;
}
data.requests.forEach(req => {
const statusBadge = req.status === 'approved'
? '<span class="badge badge-success">Approved</span>'
: req.status === 'denied'
? '<span class="badge badge-danger">Denied</span>'
: '<span class="badge badge-warning">Pending</span>';
const actions = req.status === 'pending'
? `<button class="btn btn-small btn-primary" data-action="approve-request" data-id="${req.id}">Approve</button>
<button class="btn btn-small btn-danger" data-action="deny-request" data-id="${req.id}">Deny</button>`
: `<span class="text-muted">${req.reviewed_by_username || '-'}</span>`;
tbody.innerHTML += `
<tr>
<td>${escapeHtml(req.name)}</td>
<td>${escapeHtml(req.email)}</td>
<td>${req.message ? escapeHtml(req.message).substring(0, 80) : '<span class="text-muted">-</span>'}</td>
<td>${formatDate(req.created_at)}</td>
<td>${statusBadge}</td>
<td>${actions}</td>
</tr>`;
});
} catch (error) {
showToast('Failed to load invite requests: ' + error.message, 'error');
}
}
async function handleApproveRequest(requestId) {
if (!confirm('Approve this invite request? An invite code will be created and emailed to the requester.')) return;
try {
const data = await apiRequest(`/api/admin/invite-requests/${requestId}/approve`, {
method: 'POST',
});
showToast(`Request approved! Invite code: ${data.code}`, 'success');
loadInviteRequests();
} catch (error) {
showToast('Failed to approve request: ' + error.message, 'error');
}
}
async function handleDenyRequest(requestId) {
if (!confirm('Deny this invite request? The requester will be notified.')) return;
try {
await apiRequest(`/api/admin/invite-requests/${requestId}/deny`, {
method: 'POST',
});
showToast('Request denied', 'success');
loadInviteRequests();
} catch (error) {
showToast('Failed to deny request: ' + error.message, 'error');
}
}
// =============================================================================
// Auth
// =============================================================================
@@ -786,6 +863,9 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('create-invite-btn').addEventListener('click', handleCreateInvite);
document.getElementById('include-expired').addEventListener('change', loadInvites);
// Invite requests panel
document.getElementById('request-filter-btn').addEventListener('click', loadInviteRequests);
// Audit panel
document.getElementById('audit-filter-btn').addEventListener('click', () => {
auditPage = 0;
@@ -826,6 +906,8 @@ document.addEventListener('DOMContentLoaded', () => {
else if (action === 'end-game') promptEndGame(btn.dataset.id);
else if (action === 'copy-invite') copyInviteLink(btn.dataset.code);
else if (action === 'revoke-invite') promptRevokeInvite(btn.dataset.code);
else if (action === 'approve-request') handleApproveRequest(parseInt(btn.dataset.id));
else if (action === 'deny-request') handleDenyRequest(parseInt(btn.dataset.id));
});
// Check auth on load