Add invite code registration system for beta

Backend:
- Add InviteCode model with single-use codes
- Add invite API endpoints (create, list, revoke, validate)
- Modify registration to require invite code when enabled
- Add REQUIRE_INVITE_CODE config toggle (default: true)
- Add Alembic migration for invite_codes table

Frontend:
- Add invite code field to registration page
- Validate invite code on blur with visual feedback
- Pass invite code to registration API

Admins can generate invite codes via /api/docs (Swagger UI).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-01 00:08:06 -05:00
parent 005db0700c
commit 20c4c40a1f
16 changed files with 412 additions and 4 deletions

View File

@@ -2,3 +2,4 @@ export { default as apiClient } from './client'
export { default as authApi } from './auth'
export { default as treesApi } from './trees'
export { default as sessionsApi } from './sessions'
export { default as inviteApi } from './invite'

View File

@@ -0,0 +1,11 @@
import apiClient from './client'
import type { InviteCodeValidation } from '@/types'
export const inviteApi = {
async validateCode(code: string): Promise<InviteCodeValidation> {
const response = await apiClient.get<InviteCodeValidation>(`/invites/validate/${code}`)
return response.data
},
}
export default inviteApi

View File

@@ -1,23 +1,55 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { inviteApi } from '@/api'
import { cn } from '@/lib/utils'
export function RegisterPage() {
const navigate = useNavigate()
const { register, isLoading, error, clearError } = useAuthStore()
const [inviteCode, setInviteCode] = useState('')
const [inviteCodeStatus, setInviteCodeStatus] = useState<'idle' | 'checking' | 'valid' | 'invalid'>('idle')
const [inviteCodeMessage, setInviteCodeMessage] = useState('')
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [localError, setLocalError] = useState('')
const validateInviteCode = async (code: string) => {
if (!code.trim()) {
setInviteCodeStatus('idle')
setInviteCodeMessage('')
return
}
setInviteCodeStatus('checking')
try {
const result = await inviteApi.validateCode(code.trim())
setInviteCodeStatus(result.valid ? 'valid' : 'invalid')
setInviteCodeMessage(result.message)
} catch {
setInviteCodeStatus('invalid')
setInviteCodeMessage('Failed to validate invite code')
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLocalError('')
clearError()
if (!inviteCode.trim()) {
setLocalError('Invite code is required')
return
}
if (inviteCodeStatus !== 'valid') {
setLocalError('Please enter a valid invite code')
return
}
if (!name || !email || !password) {
setLocalError('Please fill in all fields')
return
@@ -34,7 +66,7 @@ export function RegisterPage() {
}
try {
await register({ email, password, name })
await register({ email, password, name, invite_code: inviteCode.trim() })
navigate('/trees', { replace: true })
} catch {
// Error is set in the store
@@ -57,6 +89,43 @@ export function RegisterPage() {
</div>
)}
<div>
<label htmlFor="inviteCode" className="block text-sm font-medium text-foreground">
Invite code
</label>
<input
id="inviteCode"
name="inviteCode"
type="text"
required
value={inviteCode}
onChange={(e) => {
setInviteCode(e.target.value.toUpperCase())
setInviteCodeStatus('idle')
}}
onBlur={(e) => validateInviteCode(e.target.value)}
className={cn(
'mt-1 block w-full rounded-md border bg-background px-3 py-2 font-mono tracking-wider',
'text-foreground placeholder:text-muted-foreground',
'focus:outline-none focus:ring-1',
inviteCodeStatus === 'valid' && 'border-green-500 focus:border-green-500 focus:ring-green-500',
inviteCodeStatus === 'invalid' && 'border-destructive focus:border-destructive focus:ring-destructive',
inviteCodeStatus === 'idle' && 'border-input focus:border-primary focus:ring-primary',
inviteCodeStatus === 'checking' && 'border-input focus:border-primary focus:ring-primary'
)}
placeholder="ABCD1234"
/>
{inviteCodeStatus === 'checking' && (
<p className="mt-1 text-xs text-muted-foreground">Validating...</p>
)}
{inviteCodeStatus === 'valid' && (
<p className="mt-1 text-xs text-green-600">{inviteCodeMessage}</p>
)}
{inviteCodeStatus === 'invalid' && (
<p className="mt-1 text-xs text-destructive">{inviteCodeMessage}</p>
)}
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-foreground">
Full name

View File

@@ -2,6 +2,7 @@ export * from './user'
export * from './auth'
export * from './tree'
export * from './session'
export * from './invite'
// API response wrapper types
export interface PaginatedResponse<T> {

View File

@@ -0,0 +1,4 @@
export interface InviteCodeValidation {
valid: boolean
message: string
}

View File

@@ -15,6 +15,7 @@ export interface UserCreate {
password: string
name: string
role?: UserRole
invite_code?: string
}
export interface UserLogin {