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:
@@ -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'
|
||||
|
||||
11
frontend/src/api/invite.ts
Normal file
11
frontend/src/api/invite.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
4
frontend/src/types/invite.ts
Normal file
4
frontend/src/types/invite.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface InviteCodeValidation {
|
||||
valid: boolean
|
||||
message: string
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface UserCreate {
|
||||
password: string
|
||||
name: string
|
||||
role?: UserRole
|
||||
invite_code?: string
|
||||
}
|
||||
|
||||
export interface UserLogin {
|
||||
|
||||
Reference in New Issue
Block a user