Add invite request system and Gitea Actions CI/CD pipeline
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user