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

@@ -0,0 +1,52 @@
"""Add invite codes
Revision ID: 002
Revises: 7e00fa3c75c9
Create Date: 2026-02-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '002'
down_revision: Union[str, None] = '7e00fa3c75c9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create invite_codes table
op.create_table(
'invite_codes',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('code', sa.String(16), nullable=False, unique=True, index=True),
sa.Column('created_by_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=False),
sa.Column('used_by_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('note', sa.String(255), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
)
# Add invite_code_id FK to users table
op.add_column('users', sa.Column('invite_code_id', postgresql.UUID(as_uuid=True), nullable=True))
op.create_foreign_key(
'fk_users_invite_code_id',
'users',
'invite_codes',
['invite_code_id'],
['id']
)
def downgrade() -> None:
# Remove FK and column from users
op.drop_constraint('fk_users_invite_code_id', 'users', type_='foreignkey')
op.drop_column('users', 'invite_code_id')
# Drop invite_codes table
op.drop_table('invite_codes')

View File

@@ -5,6 +5,7 @@ from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.core.database import get_db
from app.core.security import (
verify_password,
@@ -14,6 +15,7 @@ from app.core.security import (
decode_token
)
from app.models.user import User
from app.models.invite_code import InviteCode
from app.schemas.user import UserCreate, UserResponse, UserLogin
from app.schemas.token import Token
from app.api.deps import get_current_user
@@ -27,6 +29,39 @@ async def register(
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Register a new user."""
# Validate invite code if required
invite_code_record = None
if settings.REQUIRE_INVITE_CODE:
if not user_data.invite_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code is required"
)
# Look up invite code (case-insensitive)
result = await db.execute(
select(InviteCode).where(InviteCode.code == user_data.invite_code.upper())
)
invite_code_record = result.scalar_one_or_none()
if not invite_code_record:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid invite code"
)
if invite_code_record.is_used:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has already been used"
)
if invite_code_record.is_expired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has expired"
)
# Check if email already exists
result = await db.execute(select(User).where(User.email == user_data.email))
existing_user = result.scalar_one_or_none()
@@ -41,9 +76,16 @@ async def register(
email=user_data.email,
password_hash=get_password_hash(user_data.password),
name=user_data.name,
role=user_data.role # Use role from request (defaults to "engineer")
role=user_data.role,
invite_code_id=invite_code_record.id if invite_code_record else None
)
db.add(new_user)
# Mark invite code as used
if invite_code_record:
invite_code_record.used_by_id = new_user.id
invite_code_record.used_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(new_user)

View File

@@ -0,0 +1,96 @@
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.models.user import User
from app.models.invite_code import InviteCode
from app.schemas.invite_code import InviteCodeCreate, InviteCodeResponse, InviteCodeValidation
from app.api.deps import require_admin
router = APIRouter(prefix="/invites", tags=["invites"])
@router.post("", response_model=InviteCodeResponse, status_code=status.HTTP_201_CREATED)
async def create_invite_code(
invite_data: InviteCodeCreate,
current_user: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Create a new invite code. Admin only."""
invite_code = InviteCode(
created_by_id=current_user.id,
expires_at=invite_data.expires_at,
note=invite_data.note
)
db.add(invite_code)
await db.commit()
await db.refresh(invite_code)
return invite_code
@router.get("", response_model=list[InviteCodeResponse])
async def list_invite_codes(
current_user: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)]
):
"""List all invite codes. Admin only."""
result = await db.execute(
select(InviteCode).order_by(InviteCode.created_at.desc())
)
invite_codes = result.scalars().all()
return invite_codes
@router.delete("/{code}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_invite_code(
code: str,
current_user: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Revoke (delete) an invite code. Admin only."""
result = await db.execute(
select(InviteCode).where(InviteCode.code == code)
)
invite_code = result.scalar_one_or_none()
if not invite_code:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invite code not found"
)
if invite_code.is_used:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot revoke a used invite code"
)
await db.delete(invite_code)
await db.commit()
@router.get("/validate/{code}", response_model=InviteCodeValidation)
async def validate_invite_code(
code: str,
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Check if an invite code is valid. Public endpoint for UX."""
result = await db.execute(
select(InviteCode).where(InviteCode.code == code.upper())
)
invite_code = result.scalar_one_or_none()
if not invite_code:
return InviteCodeValidation(valid=False, message="Invalid invite code")
if invite_code.is_used:
return InviteCodeValidation(valid=False, message="Invite code has already been used")
if invite_code.is_expired:
return InviteCodeValidation(valid=False, message="Invite code has expired")
return InviteCodeValidation(valid=True, message="Invite code is valid")

View File

@@ -1,8 +1,9 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions
from app.api.endpoints import auth, trees, sessions, invite
api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(trees.router)
api_router.include_router(sessions.router)
api_router.include_router(invite.router)

View File

@@ -38,6 +38,9 @@ class Settings(BaseSettings):
# Security
BCRYPT_ROUNDS: int = 12
# Registration
REQUIRE_INVITE_CODE: bool = True # Set to False to allow open registration
# CORS - set FRONTEND_URL in production (e.g., https://patherly.up.railway.app)
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173", "http://localhost:5174"]
FRONTEND_URL: Optional[str] = None

View File

@@ -3,5 +3,6 @@ from .team import Team
from .tree import Tree
from .session import Session
from .attachment import Attachment
from .invite_code import InviteCode
__all__ = ["User", "Team", "Tree", "Session", "Attachment"]
__all__ = ["User", "Team", "Tree", "Session", "Attachment", "InviteCode"]

View File

@@ -0,0 +1,86 @@
import uuid
import secrets
import string
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
def generate_invite_code() -> str:
"""Generate an 8-character alphanumeric invite code."""
alphabet = string.ascii_uppercase + string.digits
# Remove confusing characters: 0, O, I, 1
alphabet = alphabet.replace("0", "").replace("O", "").replace("I", "").replace("1", "")
return "".join(secrets.choice(alphabet) for _ in range(8))
class InviteCode(Base):
__tablename__ = "invite_codes"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
code: Mapped[str] = mapped_column(
String(16),
unique=True,
nullable=False,
index=True,
default=generate_invite_code
)
created_by_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=False
)
used_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True
)
expires_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True
)
note: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
used_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True
)
# Relationships
created_by: Mapped["User"] = relationship(
"User",
foreign_keys=[created_by_id],
backref="created_invite_codes"
)
used_by: Mapped[Optional["User"]] = relationship(
"User",
foreign_keys=[used_by_id],
backref="used_invite_code"
)
@property
def is_used(self) -> bool:
"""Check if the invite code has been used."""
return self.used_by_id is not None
@property
def is_expired(self) -> bool:
"""Check if the invite code has expired."""
if self.expires_at is None:
return False
return datetime.now(timezone.utc) > self.expires_at
@property
def is_valid(self) -> bool:
"""Check if the invite code is valid (not used and not expired)."""
return not self.is_used and not self.is_expired

View File

@@ -24,6 +24,11 @@ class User(Base):
ForeignKey("teams.id"),
nullable=True
)
invite_code_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("invite_codes.id"),
nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class InviteCodeCreate(BaseModel):
"""Schema for creating a new invite code."""
expires_at: Optional[datetime] = Field(None, description="Optional expiration time")
note: Optional[str] = Field(None, max_length=255, description="Note about who this code is for")
class InviteCodeResponse(BaseModel):
"""Schema for invite code response."""
id: UUID
code: str
created_by_id: UUID
used_by_id: Optional[UUID] = None
expires_at: Optional[datetime] = None
note: Optional[str] = None
created_at: datetime
used_at: Optional[datetime] = None
is_used: bool
is_expired: bool
is_valid: bool
class Config:
from_attributes = True
class InviteCodeValidation(BaseModel):
"""Schema for invite code validation response."""
valid: bool
message: str

View File

@@ -12,6 +12,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
password: str = Field(..., min_length=10, description="Password must be at least 10 characters")
role: str = Field(default="engineer", description="User role: admin, engineer, or viewer")
invite_code: Optional[str] = Field(None, description="Invite code for registration (required when invite system is enabled)")
class UserUpdate(BaseModel):

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 {