diff --git a/.env.example b/.env.example index 9dc4381..5e50bde 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,9 @@ SECRET_KEY= # Enable invite-only mode (requires invitation to register) INVITE_ONLY=true +# Allow visitors to request an invite from the login page (only relevant when INVITE_ONLY=true) +INVITE_REQUEST_ENABLED=false + # Metered open signups (public beta) # 0 = disabled (invite-only enforced), -1 = unlimited, N = max open signups per day # When set > 0, users can register without an invite code up to the daily limit. diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml new file mode 100644 index 0000000..dc7250b --- /dev/null +++ b/.gitea/workflows/deploy-prod.yml @@ -0,0 +1,51 @@ +name: Deploy Production + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to deploy (e.g. v3.3.0)' + required: true + +env: + IMAGE: git.adlee.work/alee/golfgame + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy to production + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.PROD_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + envs: IMAGE + script: | + cd /opt/golfgame + + # Pull the same image that passed staging + docker login git.adlee.work -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_TOKEN }} + docker pull $IMAGE:${{ github.event.inputs.tag }} + + # Tag it so compose uses it + docker tag $IMAGE:${{ github.event.inputs.tag }} golfgame-app:latest + + # Update code (for compose file / env changes) + git fetch origin && git checkout ${{ github.event.inputs.tag }} + + # Restart app + docker compose -f docker-compose.prod.yml up -d app + + # Wait for healthy + echo "Waiting for health check..." + for i in $(seq 1 30); do + if docker compose -f docker-compose.prod.yml ps app | grep -q "healthy"; then + echo "Production deploy successful — ${{ github.event.inputs.tag }}" + exit 0 + fi + sleep 2 + done + echo "CRITICAL: app not healthy after 60s" + docker compose -f docker-compose.prod.yml logs --tail=30 app + exit 1 diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml new file mode 100644 index 0000000..d28cb0a --- /dev/null +++ b/.gitea/workflows/deploy-staging.yml @@ -0,0 +1,70 @@ +name: Build & Deploy Staging + +on: + release: + types: [published] + +env: + IMAGE: git.adlee.work/alee/golfgame + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: git.adlee.work + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.IMAGE }}:${{ github.ref_name }} + ${{ env.IMAGE }}:latest + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Deploy to staging + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.STAGING_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + envs: IMAGE + script: | + cd /opt/golfgame + + # Pull the pre-built image + docker login git.adlee.work -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_TOKEN }} + docker pull $IMAGE:${{ github.ref_name }} + + # Tag it so compose uses it + docker tag $IMAGE:${{ github.ref_name }} golfgame-app:latest + + # Update code (for compose file / env changes) + git fetch origin && git checkout ${{ github.ref_name }} + + # Restart app (no --build, image is pre-built) + docker compose -f docker-compose.staging.yml up -d app + + # Wait for healthy + echo "Waiting for health check..." + for i in $(seq 1 30); do + if docker compose -f docker-compose.staging.yml ps app | grep -q "healthy"; then + echo "Staging deploy successful — ${{ github.ref_name }}" + exit 0 + fi + sleep 2 + done + echo "WARNING: app not healthy after 60s" + docker compose -f docker-compose.staging.yml logs --tail=20 app + exit 1 diff --git a/client/admin.html b/client/admin.html index 154a0ba..3f5da71 100644 --- a/client/admin.html +++ b/client/admin.html @@ -37,6 +37,7 @@ Users Games Invites + Requests Audit Log diff --git a/client/admin.js b/client/admin.js index e29c6cc..372a7d2 100644 --- a/client/admin.js +++ b/client/admin.js @@ -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 = 'No invite requests'; + return; + } + + data.requests.forEach(req => { + const statusBadge = req.status === 'approved' + ? 'Approved' + : req.status === 'denied' + ? 'Denied' + : 'Pending'; + + const actions = req.status === 'pending' + ? ` + ` + : `${req.reviewed_by_username || '-'}`; + + tbody.innerHTML += ` + + ${escapeHtml(req.name)} + ${escapeHtml(req.email)} + ${req.message ? escapeHtml(req.message).substring(0, 80) : '-'} + ${formatDate(req.created_at)} + ${statusBadge} + ${actions} + `; + }); + } 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 diff --git a/client/app.js b/client/app.js index 50b2aae..bc48fa4 100644 --- a/client/app.js +++ b/client/app.js @@ -1,4 +1,3 @@ -// SPDX-License-Identifier: GPL-3.0-or-later // Golf Card Game - Client Application // Debug logging - set to true to see detailed state/animation logs @@ -4868,6 +4867,15 @@ class AuthManager { this.resetPasswordConfirm = document.getElementById('reset-password-confirm'); this.resetError = document.getElementById('reset-error'); this.resetSuccess = document.getElementById('reset-success'); + this.requestInviteContainer = document.getElementById('request-invite-container'); + this.requestInviteForm = document.getElementById('request-invite-form'); + this.requestInviteName = document.getElementById('request-invite-name'); + this.requestInviteEmail = document.getElementById('request-invite-email'); + this.requestInviteMessage = document.getElementById('request-invite-message'); + this.requestInviteError = document.getElementById('request-invite-error'); + this.requestInviteSuccess = document.getElementById('request-invite-success'); + this.requestBackSignup = document.getElementById('request-back-signup'); + this.requestBackLogin = document.getElementById('request-back-login'); } bindEvents() { @@ -4898,6 +4906,15 @@ class AuthManager { }); this.forgotForm?.addEventListener('submit', (e) => this.handleForgotPassword(e)); this.resetForm?.addEventListener('submit', (e) => this.handleResetPassword(e)); + this.requestInviteForm?.addEventListener('submit', (e) => this.handleRequestInvite(e)); + this.requestBackSignup?.addEventListener('click', (e) => { + e.preventDefault(); + this.showForm('signup'); + }); + this.requestBackLogin?.addEventListener('click', (e) => { + e.preventDefault(); + this.showForm('login'); + }); // Check URL for reset token or invite code on page load this.checkResetToken(); @@ -4923,6 +4940,7 @@ class AuthManager { this.signupFormContainer.classList.add('hidden'); this.forgotFormContainer?.classList.add('hidden'); this.resetFormContainer?.classList.add('hidden'); + this.requestInviteContainer?.classList.add('hidden'); this.clearErrors(); if (form === 'login') { @@ -4937,6 +4955,9 @@ class AuthManager { } else if (form === 'reset') { this.resetFormContainer?.classList.remove('hidden'); this.resetPassword?.focus(); + } else if (form === 'request-invite') { + this.requestInviteContainer?.classList.remove('hidden'); + this.requestInviteName?.focus(); } } @@ -4953,6 +4974,8 @@ class AuthManager { if (this.forgotSuccess) this.forgotSuccess.textContent = ''; if (this.resetError) this.resetError.textContent = ''; if (this.resetSuccess) this.resetSuccess.textContent = ''; + if (this.requestInviteError) this.requestInviteError.textContent = ''; + if (this.requestInviteSuccess) this.requestInviteSuccess.textContent = ''; } async handleLogin(e) { @@ -5089,7 +5112,17 @@ class AuthManager { if (invite_required) { this.signupInviteCode.required = true; this.signupInviteCode.placeholder = 'Invite Code (required)'; - if (this.inviteCodeHint) this.inviteCodeHint.textContent = ''; + if (this.inviteCodeHint) { + if (this.signupInfo.invite_request_enabled) { + this.inviteCodeHint.innerHTML = 'Don\'t have one? Request an invite'; + document.getElementById('show-request-invite')?.addEventListener('click', (e) => { + e.preventDefault(); + this.showForm('request-invite'); + }); + } else { + this.inviteCodeHint.textContent = ''; + } + } } else if (open_signups_enabled) { this.signupInviteCode.required = false; this.signupInviteCode.placeholder = 'Invite Code (optional)'; @@ -5166,4 +5199,33 @@ class AuthManager { this.resetError.textContent = 'Connection error'; } } + + async handleRequestInvite(e) { + e.preventDefault(); + this.clearErrors(); + + const name = this.requestInviteName.value.trim(); + const email = this.requestInviteEmail.value.trim(); + const message = this.requestInviteMessage.value.trim() || null; + + try { + const response = await fetch('/api/auth/request-invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, message }), + }); + + const data = await response.json(); + + if (!response.ok) { + this.requestInviteError.textContent = data.detail || 'Request failed'; + return; + } + + this.requestInviteSuccess.textContent = data.message; + this.requestInviteForm.reset(); + } catch (err) { + this.requestInviteError.textContent = 'Connection error'; + } + } } diff --git a/client/index.html b/client/index.html index 42386a2..f52e07c 100644 --- a/client/index.html +++ b/client/index.html @@ -322,38 +322,38 @@
-
-
-
- -