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:
52
backend/alembic/versions/002_add_invite_codes.py
Normal file
52
backend/alembic/versions/002_add_invite_codes.py
Normal 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')
|
||||||
@@ -5,6 +5,7 @@ from fastapi.security import OAuth2PasswordRequestForm
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import (
|
from app.core.security import (
|
||||||
verify_password,
|
verify_password,
|
||||||
@@ -14,6 +15,7 @@ from app.core.security import (
|
|||||||
decode_token
|
decode_token
|
||||||
)
|
)
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.invite_code import InviteCode
|
||||||
from app.schemas.user import UserCreate, UserResponse, UserLogin
|
from app.schemas.user import UserCreate, UserResponse, UserLogin
|
||||||
from app.schemas.token import Token
|
from app.schemas.token import Token
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
@@ -27,6 +29,39 @@ async def register(
|
|||||||
db: Annotated[AsyncSession, Depends(get_db)]
|
db: Annotated[AsyncSession, Depends(get_db)]
|
||||||
):
|
):
|
||||||
"""Register a new user."""
|
"""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
|
# Check if email already exists
|
||||||
result = await db.execute(select(User).where(User.email == user_data.email))
|
result = await db.execute(select(User).where(User.email == user_data.email))
|
||||||
existing_user = result.scalar_one_or_none()
|
existing_user = result.scalar_one_or_none()
|
||||||
@@ -41,9 +76,16 @@ async def register(
|
|||||||
email=user_data.email,
|
email=user_data.email,
|
||||||
password_hash=get_password_hash(user_data.password),
|
password_hash=get_password_hash(user_data.password),
|
||||||
name=user_data.name,
|
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)
|
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.commit()
|
||||||
await db.refresh(new_user)
|
await db.refresh(new_user)
|
||||||
|
|
||||||
|
|||||||
96
backend/app/api/endpoints/invite.py
Normal file
96
backend/app/api/endpoints/invite.py
Normal 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")
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
from fastapi import APIRouter
|
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 = APIRouter()
|
||||||
|
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(trees.router)
|
api_router.include_router(trees.router)
|
||||||
api_router.include_router(sessions.router)
|
api_router.include_router(sessions.router)
|
||||||
|
api_router.include_router(invite.router)
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
|
|||||||
# Security
|
# Security
|
||||||
BCRYPT_ROUNDS: int = 12
|
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 - 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"]
|
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173", "http://localhost:5174"]
|
||||||
FRONTEND_URL: Optional[str] = None
|
FRONTEND_URL: Optional[str] = None
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ from .team import Team
|
|||||||
from .tree import Tree
|
from .tree import Tree
|
||||||
from .session import Session
|
from .session import Session
|
||||||
from .attachment import Attachment
|
from .attachment import Attachment
|
||||||
|
from .invite_code import InviteCode
|
||||||
|
|
||||||
__all__ = ["User", "Team", "Tree", "Session", "Attachment"]
|
__all__ = ["User", "Team", "Tree", "Session", "Attachment", "InviteCode"]
|
||||||
|
|||||||
86
backend/app/models/invite_code.py
Normal file
86
backend/app/models/invite_code.py
Normal 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
|
||||||
@@ -24,6 +24,11 @@ class User(Base):
|
|||||||
ForeignKey("teams.id"),
|
ForeignKey("teams.id"),
|
||||||
nullable=True
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
default=lambda: datetime.now(timezone.utc)
|
default=lambda: datetime.now(timezone.utc)
|
||||||
|
|||||||
34
backend/app/schemas/invite_code.py
Normal file
34
backend/app/schemas/invite_code.py
Normal 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
|
||||||
@@ -12,6 +12,7 @@ class UserBase(BaseModel):
|
|||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: str = Field(..., min_length=10, description="Password must be at least 10 characters")
|
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")
|
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):
|
class UserUpdate(BaseModel):
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export { default as apiClient } from './client'
|
|||||||
export { default as authApi } from './auth'
|
export { default as authApi } from './auth'
|
||||||
export { default as treesApi } from './trees'
|
export { default as treesApi } from './trees'
|
||||||
export { default as sessionsApi } from './sessions'
|
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 { useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { inviteApi } from '@/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { register, isLoading, error, clearError } = useAuthStore()
|
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 [name, setName] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
const [localError, setLocalError] = 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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLocalError('')
|
setLocalError('')
|
||||||
clearError()
|
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) {
|
if (!name || !email || !password) {
|
||||||
setLocalError('Please fill in all fields')
|
setLocalError('Please fill in all fields')
|
||||||
return
|
return
|
||||||
@@ -34,7 +66,7 @@ export function RegisterPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await register({ email, password, name })
|
await register({ email, password, name, invite_code: inviteCode.trim() })
|
||||||
navigate('/trees', { replace: true })
|
navigate('/trees', { replace: true })
|
||||||
} catch {
|
} catch {
|
||||||
// Error is set in the store
|
// Error is set in the store
|
||||||
@@ -57,6 +89,43 @@ export function RegisterPage() {
|
|||||||
</div>
|
</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>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-foreground">
|
<label htmlFor="name" className="block text-sm font-medium text-foreground">
|
||||||
Full name
|
Full name
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from './user'
|
|||||||
export * from './auth'
|
export * from './auth'
|
||||||
export * from './tree'
|
export * from './tree'
|
||||||
export * from './session'
|
export * from './session'
|
||||||
|
export * from './invite'
|
||||||
|
|
||||||
// API response wrapper types
|
// API response wrapper types
|
||||||
export interface PaginatedResponse<T> {
|
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
|
password: string
|
||||||
name: string
|
name: string
|
||||||
role?: UserRole
|
role?: UserRole
|
||||||
|
invite_code?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserLogin {
|
export interface UserLogin {
|
||||||
|
|||||||
Reference in New Issue
Block a user