Add forgot/reset password UI and Resend email config

- Forgot password form in auth modal with email input
- Reset password form handles token from email link
- /reset-password route serves index.html for SPA
- EMAIL_FROM env var in docker-compose
- Success/error feedback for both flows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken 2026-02-21 23:51:58 -05:00
parent 9339abe19c
commit 538ca51ba5
5 changed files with 167 additions and 3 deletions

View File

@ -4628,6 +4628,19 @@ class AuthManager {
this.signupError = document.getElementById('signup-error'); this.signupError = document.getElementById('signup-error');
this.showSignupLink = document.getElementById('show-signup'); this.showSignupLink = document.getElementById('show-signup');
this.showLoginLink = document.getElementById('show-login'); this.showLoginLink = document.getElementById('show-login');
this.showForgotLink = document.getElementById('show-forgot');
this.forgotFormContainer = document.getElementById('forgot-form-container');
this.forgotForm = document.getElementById('forgot-form');
this.forgotEmail = document.getElementById('forgot-email');
this.forgotError = document.getElementById('forgot-error');
this.forgotSuccess = document.getElementById('forgot-success');
this.forgotBackLogin = document.getElementById('forgot-back-login');
this.resetFormContainer = document.getElementById('reset-form-container');
this.resetForm = document.getElementById('reset-form');
this.resetPassword = document.getElementById('reset-password');
this.resetPasswordConfirm = document.getElementById('reset-password-confirm');
this.resetError = document.getElementById('reset-error');
this.resetSuccess = document.getElementById('reset-success');
} }
bindEvents() { bindEvents() {
@ -4648,6 +4661,19 @@ class AuthManager {
this.loginForm?.addEventListener('submit', (e) => this.handleLogin(e)); this.loginForm?.addEventListener('submit', (e) => this.handleLogin(e));
this.signupForm?.addEventListener('submit', (e) => this.handleSignup(e)); this.signupForm?.addEventListener('submit', (e) => this.handleSignup(e));
this.logoutBtn?.addEventListener('click', () => this.logout()); this.logoutBtn?.addEventListener('click', () => this.logout());
this.showForgotLink?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('forgot');
});
this.forgotBackLogin?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('login');
});
this.forgotForm?.addEventListener('submit', (e) => this.handleForgotPassword(e));
this.resetForm?.addEventListener('submit', (e) => this.handleResetPassword(e));
// Check URL for reset token on page load
this.checkResetToken();
} }
showModal(form = 'login') { showModal(form = 'login') {
@ -4662,14 +4688,24 @@ class AuthManager {
} }
showForm(form) { showForm(form) {
this.loginFormContainer.classList.add('hidden');
this.signupFormContainer.classList.add('hidden');
this.forgotFormContainer?.classList.add('hidden');
this.resetFormContainer?.classList.add('hidden');
this.clearErrors();
if (form === 'login') { if (form === 'login') {
this.loginFormContainer.classList.remove('hidden'); this.loginFormContainer.classList.remove('hidden');
this.signupFormContainer.classList.add('hidden');
this.loginUsername.focus(); this.loginUsername.focus();
} else { } else if (form === 'signup') {
this.loginFormContainer.classList.add('hidden');
this.signupFormContainer.classList.remove('hidden'); this.signupFormContainer.classList.remove('hidden');
this.signupUsername.focus(); this.signupUsername.focus();
} else if (form === 'forgot') {
this.forgotFormContainer?.classList.remove('hidden');
this.forgotEmail?.focus();
} else if (form === 'reset') {
this.resetFormContainer?.classList.remove('hidden');
this.resetPassword?.focus();
} }
} }
@ -4682,6 +4718,10 @@ class AuthManager {
clearErrors() { clearErrors() {
this.loginError.textContent = ''; this.loginError.textContent = '';
this.signupError.textContent = ''; this.signupError.textContent = '';
if (this.forgotError) this.forgotError.textContent = '';
if (this.forgotSuccess) this.forgotSuccess.textContent = '';
if (this.resetError) this.resetError.textContent = '';
if (this.resetSuccess) this.resetSuccess.textContent = '';
} }
async handleLogin(e) { async handleLogin(e) {
@ -4772,4 +4812,77 @@ class AuthManager {
this.lobbyGameControls?.classList.add('hidden'); this.lobbyGameControls?.classList.add('hidden');
} }
} }
checkResetToken() {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const path = window.location.pathname;
if (token && path.includes('reset-password')) {
this._resetToken = token;
this.showModal('reset');
// Clean URL
window.history.replaceState({}, '', '/');
}
}
async handleForgotPassword(e) {
e.preventDefault();
this.clearErrors();
const email = this.forgotEmail.value.trim();
try {
const response = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const data = await response.json();
this.forgotError.textContent = data.detail || 'Request failed';
return;
}
this.forgotSuccess.textContent = 'If an account exists with that email, a reset link has been sent.';
this.forgotForm.reset();
} catch (err) {
this.forgotError.textContent = 'Connection error';
}
}
async handleResetPassword(e) {
e.preventDefault();
this.clearErrors();
const password = this.resetPassword.value;
const confirm = this.resetPasswordConfirm.value;
if (password !== confirm) {
this.resetError.textContent = 'Passwords do not match';
return;
}
try {
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: this._resetToken, new_password: password }),
});
const data = await response.json();
if (!response.ok) {
this.resetError.textContent = data.detail || 'Reset failed';
return;
}
this.resetSuccess.textContent = 'Password reset! You can now log in.';
this.resetForm.reset();
setTimeout(() => this.showForm('login'), 2000);
} catch (err) {
this.resetError.textContent = 'Connection error';
}
}
} }

View File

@ -839,6 +839,38 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<button type="submit" class="btn btn-primary btn-full">Login</button> <button type="submit" class="btn btn-primary btn-full">Login</button>
</form> </form>
<p class="auth-switch">Don't have an account? <a href="#" id="show-signup">Sign up</a></p> <p class="auth-switch">Don't have an account? <a href="#" id="show-signup">Sign up</a></p>
<p class="auth-switch"><a href="#" id="show-forgot">Forgot password?</a></p>
</div>
<!-- Forgot Password Form -->
<div id="forgot-form-container" class="hidden">
<h3>Reset Password</h3>
<p class="auth-hint">Enter your email and we'll send you a reset link.</p>
<form id="forgot-form">
<div class="form-group">
<input type="email" id="forgot-email" placeholder="Email" required>
</div>
<p id="forgot-error" class="error"></p>
<p id="forgot-success" class="success"></p>
<button type="submit" class="btn btn-primary btn-full">Send Reset Link</button>
</form>
<p class="auth-switch"><a href="#" id="forgot-back-login">Back to login</a></p>
</div>
<!-- Reset Password Form (from email link) -->
<div id="reset-form-container" class="hidden">
<h3>Set New Password</h3>
<form id="reset-form">
<div class="form-group">
<input type="password" id="reset-password" placeholder="New password" required minlength="8">
</div>
<div class="form-group">
<input type="password" id="reset-password-confirm" placeholder="Confirm password" required minlength="8">
</div>
<p id="reset-error" class="error"></p>
<p id="reset-success" class="success"></p>
<button type="submit" class="btn btn-primary btn-full">Reset Password</button>
</form>
</div> </div>
<!-- Signup Form --> <!-- Signup Form -->

View File

@ -3404,6 +3404,20 @@ input::placeholder {
text-align: center; text-align: center;
} }
.modal-auth .success {
color: #4ade80;
font-size: 0.85rem;
margin: 10px 0;
text-align: center;
}
.modal-auth .auth-hint {
color: #94a3b8;
font-size: 0.85rem;
margin-bottom: 15px;
text-align: center;
}
/* =========================================== /* ===========================================
MATCHMAKING SCREEN MATCHMAKING SCREEN
=========================================== */ =========================================== */

View File

@ -26,6 +26,7 @@ services:
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- RESEND_API_KEY=${RESEND_API_KEY:-} - RESEND_API_KEY=${RESEND_API_KEY:-}
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@golfcards.club>}
- SENTRY_DSN=${SENTRY_DSN:-} - SENTRY_DSN=${SENTRY_DSN:-}
- ENVIRONMENT=production - ENVIRONMENT=production
- LOG_LEVEL=INFO - LOG_LEVEL=INFO

View File

@ -768,6 +768,10 @@ if os.path.exists(client_path):
async def serve_replay_page(share_code: str): async def serve_replay_page(share_code: str):
return FileResponse(os.path.join(client_path, "index.html")) return FileResponse(os.path.join(client_path, "index.html"))
@app.get("/reset-password")
async def serve_reset_password_page():
return FileResponse(os.path.join(client_path, "index.html"))
# Mount static files for everything else (JS, CSS, SVG, etc.) # Mount static files for everything else (JS, CSS, SVG, etc.)
app.mount("/", StaticFiles(directory=client_path), name="static") app.mount("/", StaticFiles(directory=client_path), name="static")