feat(auth): implement tenant registration wizard and related hooks

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-05 23:44:50 +08:00
parent 2e117ecdd3
commit bff85b86a2
7 changed files with 1026 additions and 3 deletions

View File

@@ -9,6 +9,7 @@ import { TenantContextResolver } from 'core/modules/tenant/tenant-context-resolv
import { eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { logger } from '../helpers/logger.helper'
import type { AuthSession } from '../modules/auth/auth.provider'
import { AuthProvider } from '../modules/auth/auth.provider'
import type { TenantAuthSession } from '../modules/tenant-auth/tenant-auth.provider'
@@ -27,6 +28,8 @@ declare module '@afilmory/framework' {
@injectable()
export class AuthGuard implements CanActivate {
private readonly log = logger.extend('AuthGuard')
constructor(
private readonly authProvider: AuthProvider,
private readonly tenantAuthProvider: TenantAuthProvider,
@@ -37,6 +40,14 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const store = context.getContext()
const { hono } = store
const { method, path } = hono.req
if (this.isPublicRoute(method, path)) {
this.log.verbose(`Bypass guard for public route ${method} ${path}`)
return true
}
this.log.verbose(`Evaluating guard for ${method} ${path}`)
let tenantContext = getTenantContext()
@@ -44,8 +55,16 @@ export class AuthGuard implements CanActivate {
const resolvedTenant = await this.tenantContextResolver.resolve(hono, {
setResponseHeaders: false,
})
HttpContext.setValue('tenant', tenantContext)
tenantContext = resolvedTenant ?? undefined
if (resolvedTenant) {
HttpContext.setValue('tenant', resolvedTenant)
tenantContext = resolvedTenant
this.log.verbose(
`Resolved tenant context slug=${resolvedTenant.tenant.slug ?? 'n/a'} id=${resolvedTenant.tenant.id} for ${method} ${path}`,
)
} else {
this.log.verbose(`Tenant context not resolved for ${method} ${path}`)
tenantContext = undefined
}
}
const { headers } = hono.req.raw
@@ -56,12 +75,20 @@ export class AuthGuard implements CanActivate {
if (authSession) {
sessionSource = 'global'
this.log.verbose(`Global session detected for user ${(authSession.user as { id?: string }).id ?? 'unknown'}`)
} else if (tenantContext) {
const tenantAuth = await this.tenantAuthProvider.getAuth(tenantContext.tenant.id)
authSession = await tenantAuth.api.getSession({ headers })
if (authSession) {
sessionSource = 'tenant'
this.log.verbose(
`Tenant session detected for user ${(authSession.user as { id?: string }).id ?? 'unknown'} on tenant ${tenantContext.tenant.id}`,
)
} else {
this.log.verbose(`No tenant session present for tenant ${tenantContext.tenant.id}`)
}
} else {
this.log.verbose('No session context available (no tenant resolved and no global session)')
}
if (authSession) {
@@ -99,13 +126,22 @@ export class AuthGuard implements CanActivate {
}
if (!sessionTenantId) {
this.log.warn(
`Denied access: session ${(authSession.user as { id?: string }).id ?? 'unknown'} missing tenant id for ${method} ${path}`,
)
throw new BizException(ErrorCode.AUTH_FORBIDDEN)
}
if (!tenantContext) {
this.log.warn(
`Denied access: tenant context missing while session tenant=${sessionTenantId} accessing ${method} ${path}`,
)
throw new BizException(ErrorCode.AUTH_FORBIDDEN)
}
if (sessionTenantId !== tenantContext.tenant.id) {
this.log.warn(
`Denied access: session tenant=${sessionTenantId} does not match context tenant=${tenantContext.tenant.id} for ${method} ${path}`,
)
throw new BizException(ErrorCode.AUTH_FORBIDDEN)
}
}
@@ -126,6 +162,7 @@ export class AuthGuard implements CanActivate {
const requiredMask = getAllowedRoleMask(handler)
if (requiredMask > 0) {
if (!authSession) {
this.log.warn(`Denied access: missing session for protected resource ${method} ${path}`)
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
}
@@ -138,9 +175,24 @@ export class AuthGuard implements CanActivate {
const userMask = userRoleName ? roleNameToBit(userRoleName) : 0
const hasRole = (requiredMask & userMask) !== 0
if (!hasRole) {
this.log.warn(
`Denied access: user ${(authSession.user as { id?: string }).id ?? 'unknown'} role=${userRoleName ?? 'n/a'} lacks permission mask=${requiredMask} on ${method} ${path}`,
)
throw new BizException(ErrorCode.AUTH_FORBIDDEN)
}
}
return true
}
private isPublicRoute(method: string, path: string): boolean {
if (method !== 'POST') {
return false
}
if (path === '/api/auth/tenants/sign-up' || path.startsWith('/api/auth/tenants/sign-up/')) {
return true
}
return false
}
}

View File

@@ -13,13 +13,14 @@ const ONBOARDING_STATUS_QUERY_KEY = ['onboarding', 'status'] as const
const DEFAULT_LOGIN_PATH = '/login'
const DEFAULT_ONBOARDING_PATH = '/onboarding'
const DEFAULT_REGISTER_PATH = '/register'
const DEFAULT_AUTHENTICATED_PATH = '/'
const SUPERADMIN_ROOT_PATH = '/superadmin'
const SUPERADMIN_DEFAULT_PATH = '/superadmin/settings'
const AUTH_FAILURE_STATUSES = new Set([401, 403, 419])
const PUBLIC_PATHS = new Set([DEFAULT_LOGIN_PATH, DEFAULT_ONBOARDING_PATH])
const PUBLIC_PATHS = new Set([DEFAULT_LOGIN_PATH, DEFAULT_ONBOARDING_PATH, DEFAULT_REGISTER_PATH])
export function usePageRedirect() {
const location = useLocation()

View File

@@ -0,0 +1,26 @@
import type { FetchResponse } from 'ofetch'
import { coreApi } from '~/lib/api-client'
export interface RegisterTenantAccountPayload {
email: string
password: string
name: string
}
export interface RegisterTenantPayload {
account: RegisterTenantAccountPayload
tenant: {
name: string
slug: string | null
}
}
export type RegisterTenantResult = FetchResponse<unknown>
export async function registerTenant(payload: RegisterTenantPayload): Promise<RegisterTenantResult> {
return await coreApi.raw('/auth/tenants/sign-up', {
method: 'POST',
body: payload,
})
}

View File

@@ -0,0 +1,629 @@
import { Button, Checkbox, FormError, Input, Label, ScrollArea } from '@afilmory/ui'
import { cx, Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import type { FC, KeyboardEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router'
import { useRegisterTenant } from '~/modules/auth/hooks/useRegisterTenant'
import type { TenantRegistrationFormState } from '~/modules/auth/hooks/useRegistrationForm'
import { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
import { LinearBorderContainer } from '~/modules/onboarding/components/LinearBorderContainer'
const REGISTRATION_STEPS = [
{
id: 'workspace',
title: 'Workspace details',
description: 'Give your workspace a recognizable name and choose a slug for tenant URLs.',
},
{
id: 'admin',
title: 'Administrator account',
description: 'Set up the primary administrator who will manage the workspace after creation.',
},
{
id: 'review',
title: 'Review & confirm',
description: 'Verify everything looks right and accept the terms before provisioning the workspace.',
},
] as const satisfies ReadonlyArray<{
id: 'workspace' | 'admin' | 'review'
title: string
description: string
}>
const STEP_FIELDS: Record<(typeof REGISTRATION_STEPS)[number]['id'], Array<keyof TenantRegistrationFormState>> = {
workspace: ['tenantName', 'tenantSlug'],
admin: ['accountName', 'email', 'password', 'confirmPassword'],
review: ['termsAccepted'],
}
const progressForStep = (index: number) => Math.round((index / (REGISTRATION_STEPS.length - 1 || 1)) * 100)
type SidebarProps = {
currentStepIndex: number
canNavigateTo: (index: number) => boolean
onStepSelect: (index: number) => void
}
const RegistrationSidebar: FC<SidebarProps> = ({ currentStepIndex, canNavigateTo, onStepSelect }) => (
<aside className="hidden min-h-full flex-col gap-6 p-6 lg:flex">
<div>
<p className="text-accent text-xs font-medium">Workspace Setup</p>
<h2 className="text-text mt-2 text-base font-semibold">Create your tenant</h2>
</div>
<div className="relative flex-1">
{REGISTRATION_STEPS.map((step, index) => {
const status: 'done' | 'current' | 'pending' =
index < currentStepIndex ? 'done' : index === currentStepIndex ? 'current' : 'pending'
const isLast = index === REGISTRATION_STEPS.length - 1
const isClickable = canNavigateTo(index)
return (
<div key={step.id} className="relative flex gap-3">
{!isLast && (
<div className="absolute top-7 bottom-0 left-[13px] w-[1.5px]">
{status === 'done' && <div className="bg-accent h-full w-full" />}
{status === 'current' && (
<div
className="h-full w-full"
style={{
background:
'linear-gradient(to bottom, var(--color-accent) 0%, var(--color-accent) 35%, color-mix(in srgb, var(--color-text) 15%, transparent) 100%)',
}}
/>
)}
{status === 'pending' && <div className="bg-text/15 h-full w-full" />}
</div>
)}
<button
type="button"
className={cx(
'group relative flex w-full items-start gap-3 pb-6 text-left transition-all duration-200',
isClickable ? 'cursor-pointer' : 'cursor-default',
!isClickable && 'opacity-60',
)}
onClick={() => {
if (isClickable) onStepSelect(index)
}}
disabled={!isClickable}
>
<div className="relative z-10 shrink-0 pt-0.5">
<div
className={cx(
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-all duration-200',
status === 'done' && 'bg-accent text-white ring-4 ring-accent/10',
status === 'current' && 'bg-accent text-white ring-4 ring-accent/25',
status === 'pending' && 'border-[1.5px] border-text/20 bg-background text-text-tertiary',
)}
>
{status === 'done' ? <i className="i-mingcute-check-fill text-sm" /> : <span>{index + 1}</span>}
</div>
</div>
<div className="min-w-0 flex-1 pt-0.5">
<p
className={cx(
'text-sm font-medium transition-colors duration-200',
status === 'done' && 'text-text',
status === 'current' && 'text-accent',
status === 'pending' && 'text-text-tertiary',
isClickable && status !== 'current' && 'group-hover:text-text',
)}
>
{step.title}
</p>
<p
className={cx(
'mt-0.5 text-xs transition-colors duration-200',
status === 'done' && 'text-text-secondary',
status === 'current' && 'text-text-secondary',
status === 'pending' && 'text-text-tertiary',
)}
>
{step.description}
</p>
</div>
</button>
</div>
)
})}
</div>
<div className="pt-4">
<div className="via-text/20 mb-4 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="text-text-tertiary mb-2 flex items-center justify-between text-xs">
<span>Progress</span>
<span className="text-accent font-medium">{progressForStep(currentStepIndex)}%</span>
</div>
<div className="bg-fill-tertiary relative h-1.5 overflow-hidden rounded-full">
<div
className="bg-accent absolute top-0 left-0 h-full transition-all duration-500 ease-out"
style={{ width: `${progressForStep(currentStepIndex)}%` }}
/>
</div>
</div>
</aside>
)
type HeaderProps = {
currentStepIndex: number
}
const RegistrationHeader: FC<HeaderProps> = ({ currentStepIndex }) => {
const step = REGISTRATION_STEPS[currentStepIndex]
return (
<header className="p-8 pb-6">
<div className="bg-accent/10 text-accent inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium">
Step {currentStepIndex + 1} of {REGISTRATION_STEPS.length}
</div>
<h1 className="text-text mt-4 text-3xl font-bold">{step.title}</h1>
<p className="text-text-secondary mt-2 max-w-2xl text-sm">{step.description}</p>
</header>
)
}
type FooterProps = {
disableBack: boolean
isSubmitting: boolean
isLastStep: boolean
onBack: () => void
onNext: () => void
}
const RegistrationFooter: FC<FooterProps> = ({ disableBack, isSubmitting, isLastStep, onBack, onNext }) => (
<footer className="flex flex-col gap-3 p-8 pt-6 sm:flex-row sm:items-center sm:justify-between">
{!disableBack ? (
<div className="text-text-tertiary text-xs">
Adjustments are always possibleuse the sidebar or go back to modify earlier details.
</div>
) : (
<div />
)}
<div className="flex gap-2">
{!disableBack && (
<Button
type="button"
variant="ghost"
size="md"
className="text-text-secondary hover:text-text hover:bg-fill/50 min-w-[140px]"
onClick={onBack}
disabled={isSubmitting}
>
Back
</Button>
)}
<Button type="button" variant="primary" size="md" className="min-w-40" onClick={onNext} isLoading={isSubmitting}>
{isLastStep ? 'Create workspace' : 'Continue'}
</Button>
</div>
</footer>
)
type StepCommonProps = {
values: TenantRegistrationFormState
errors: Partial<Record<keyof TenantRegistrationFormState, string>>
onFieldChange: <Field extends keyof TenantRegistrationFormState>(
field: Field,
value: TenantRegistrationFormState[Field],
) => void
isLoading: boolean
}
const WorkspaceStep: FC<StepCommonProps> = ({ values, errors, onFieldChange, isLoading }) => (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">Workspace basics</h2>
<p className="text-text-secondary text-sm">
This information appears in navigation, invitations, and other tenant-facing areas.
</p>
</section>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-name">Workspace name</Label>
<Input
id="tenant-name"
value={values.tenantName}
onChange={(event) => onFieldChange('tenantName', event.currentTarget.value)}
placeholder="Acme Studio"
disabled={isLoading}
error={Boolean(errors.tenantName)}
autoComplete="organization"
/>
<FormError>{errors.tenantName}</FormError>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug">Workspace slug</Label>
<Input
id="tenant-slug"
value={values.tenantSlug}
onChange={(event) => onFieldChange('tenantSlug', event.currentTarget.value)}
placeholder="acme"
disabled={isLoading}
error={Boolean(errors.tenantSlug)}
autoComplete="off"
/>
<p className="text-text-tertiary text-xs">
Lowercase letters, numbers, and hyphen are allowed. We&apos;ll ensure the slug is unique.
</p>
<FormError>{errors.tenantSlug}</FormError>
</div>
</div>
</div>
)
const AdminStep: FC<StepCommonProps> = ({ values, errors, onFieldChange, isLoading }) => (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">Administrator</h2>
<p className="text-text-secondary text-sm">
The first user becomes the workspace administrator and can invite additional members later.
</p>
</section>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="account-name">Full name</Label>
<Input
id="account-name"
value={values.accountName}
onChange={(event) => onFieldChange('accountName', event.currentTarget.value)}
placeholder="Jane Doe"
disabled={isLoading}
error={Boolean(errors.accountName)}
autoComplete="name"
/>
<FormError>{errors.accountName}</FormError>
</div>
<div className="space-y-2">
<Label htmlFor="account-email">Work email</Label>
<Input
id="account-email"
type="email"
value={values.email}
onChange={(event) => onFieldChange('email', event.currentTarget.value)}
placeholder="jane@acme.studio"
disabled={isLoading}
error={Boolean(errors.email)}
autoComplete="email"
/>
<FormError>{errors.email}</FormError>
</div>
<div className="space-y-2">
<Label htmlFor="account-password">Password</Label>
<Input
id="account-password"
type="password"
value={values.password}
onChange={(event) => onFieldChange('password', event.currentTarget.value)}
placeholder="Create a strong password"
disabled={isLoading}
error={Boolean(errors.password)}
autoComplete="new-password"
/>
<FormError>{errors.password}</FormError>
</div>
<div className="space-y-2">
<Label htmlFor="account-confirm-password">Confirm password</Label>
<Input
id="account-confirm-password"
type="password"
value={values.confirmPassword}
onChange={(event) => onFieldChange('confirmPassword', event.currentTarget.value)}
placeholder="Repeat your password"
disabled={isLoading}
error={Boolean(errors.confirmPassword)}
autoComplete="new-password"
/>
<FormError>{errors.confirmPassword}</FormError>
</div>
</div>
<p className="text-text-tertiary text-xs">
We recommend using a secure password manager to store credentials for critical roles like the administrator.
</p>
</div>
)
type ReviewStepProps = Omit<StepCommonProps, 'onFieldChange'> & {
onToggleTerms: (value: boolean) => void
serverError: string | null
}
const ReviewStep: FC<ReviewStepProps> = ({ values, errors, onToggleTerms, isLoading, serverError }) => (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">Confirm workspace configuration</h2>
<p className="text-text-secondary text-sm">
Double-check the details below. You can go back to make adjustments before creating the workspace.
</p>
</section>
<dl className="bg-fill/40 border border-white/5 grid gap-x-6 gap-y-4 rounded-2xl p-6 md:grid-cols-2">
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace name</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.tenantName || '—'}</dd>
</div>
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace slug</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.tenantSlug || '—'}</dd>
</div>
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator name</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.accountName || '—'}</dd>
</div>
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator email</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.email || '—'}</dd>
</div>
</dl>
{serverError && (
<m.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={Spring.presets.snappy}
className="border-red/60 bg-red/10 rounded-xl border px-4 py-3"
>
<p className="text-red text-sm">{serverError}</p>
</m.div>
)}
<section className="space-y-3">
<h3 className="text-text text-base font-semibold">Policies</h3>
<p className="text-text-tertiary text-sm">
Creating a workspace means you agree to comply with our usage guidelines and privacy practices.
</p>
<div className="space-y-2">
<label className="text-text flex items-center gap-3 text-sm">
<Checkbox
checked={values.termsAccepted}
onCheckedChange={(checked) => onToggleTerms(checked === true)}
disabled={isLoading}
className="mt-0.5"
/>
<span className="text-text-secondary">
I agree to the{' '}
<a href="/terms" target="_blank" rel="noreferrer" className="text-accent hover:underline">
Terms of Service
</a>{' '}
and{' '}
<a href="/privacy" target="_blank" rel="noreferrer" className="text-accent hover:underline">
Privacy Policy
</a>
.
</span>
</label>
<FormError>{errors.termsAccepted}</FormError>
</div>
</section>
</div>
)
export const RegistrationWizard: FC = () => {
const { values, errors, updateValue, validate, getFieldError } = useRegistrationForm()
const { registerTenant, isLoading, error, clearError } = useRegisterTenant()
const [currentStepIndex, setCurrentStepIndex] = useState(0)
const [maxVisitedIndex, setMaxVisitedIndex] = useState(0)
const contentRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const root = contentRef.current
if (!root) return
const rafId = requestAnimationFrame(() => {
const selector = [
'input:not([type="hidden"]):not([disabled])',
'textarea:not([disabled])',
'select:not([disabled])',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
].join(',')
const candidates = Array.from(root.querySelectorAll<HTMLElement>(selector))
const firstVisible = candidates.find((el) => {
if (el.getAttribute('aria-hidden') === 'true') return false
const rect = el.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return false
if ((el as HTMLInputElement).disabled) return false
return true
})
firstVisible?.focus({ preventScroll: true })
})
return () => cancelAnimationFrame(rafId)
}, [currentStepIndex])
const canNavigateTo = useCallback((index: number) => index <= maxVisitedIndex, [maxVisitedIndex])
const jumpToStep = useCallback(
(index: number) => {
if (isLoading) return
if (index === currentStepIndex) return
if (!canNavigateTo(index)) return
if (error) clearError()
setCurrentStepIndex(index)
setMaxVisitedIndex((prev) => Math.max(prev, index))
},
[canNavigateTo, clearError, currentStepIndex, error, isLoading],
)
const handleFieldChange = useCallback(
<Field extends keyof TenantRegistrationFormState>(field: Field, value: TenantRegistrationFormState[Field]) => {
updateValue(field, value)
if (error) clearError()
},
[clearError, error, updateValue],
)
const handleBack = useCallback(() => {
if (isLoading) return
if (currentStepIndex === 0) return
if (error) clearError()
setCurrentStepIndex((prev) => Math.max(0, prev - 1))
}, [clearError, currentStepIndex, error, isLoading])
const focusFirstInvalidStep = useCallback(() => {
const invalidStepIndex = REGISTRATION_STEPS.findIndex((step) =>
STEP_FIELDS[step.id].some((field) => Boolean(getFieldError(field))),
)
if (invalidStepIndex !== -1 && invalidStepIndex !== currentStepIndex) {
setCurrentStepIndex(invalidStepIndex)
setMaxVisitedIndex((prev) => Math.max(prev, invalidStepIndex))
}
}, [currentStepIndex, getFieldError])
const handleNext = useCallback(() => {
if (isLoading) return
const step = REGISTRATION_STEPS[currentStepIndex]
const fields = STEP_FIELDS[step.id]
const isStepValid = validate(fields)
if (!isStepValid) {
focusFirstInvalidStep()
return
}
if (step.id === 'review') {
const formIsValid = validate()
if (!formIsValid) {
focusFirstInvalidStep()
return
}
if (error) clearError()
registerTenant({
tenantName: values.tenantName,
tenantSlug: values.tenantSlug,
accountName: values.accountName,
email: values.email,
password: values.password,
})
return
}
setCurrentStepIndex((prev) => {
const nextIndex = Math.min(REGISTRATION_STEPS.length - 1, prev + 1)
setMaxVisitedIndex((visited) => Math.max(visited, nextIndex))
return nextIndex
})
}, [
clearError,
currentStepIndex,
error,
focusFirstInvalidStep,
isLoading,
registerTenant,
validate,
values.accountName,
values.email,
values.password,
values.tenantName,
values.tenantSlug,
])
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key !== 'Enter') return
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return
const nativeEvent = event.nativeEvent as unknown as { isComposing?: boolean }
if (nativeEvent?.isComposing) return
const target = event.target as HTMLElement
if (target.isContentEditable) return
if (target.tagName === 'TEXTAREA') return
if (target.tagName === 'BUTTON' || target.tagName === 'A') return
if (target.tagName === 'INPUT') {
const { type } = target as HTMLInputElement
if (type === 'checkbox' || type === 'radio') return
}
event.preventDefault()
handleNext()
},
[handleNext],
)
const StepComponent = useMemo(() => {
const step = REGISTRATION_STEPS[currentStepIndex]
switch (step.id) {
case 'workspace': {
return <WorkspaceStep values={values} errors={errors} onFieldChange={handleFieldChange} isLoading={isLoading} />
}
case 'admin': {
return <AdminStep values={values} errors={errors} onFieldChange={handleFieldChange} isLoading={isLoading} />
}
case 'review': {
return (
<ReviewStep
values={values}
errors={errors}
onToggleTerms={(accepted) => handleFieldChange('termsAccepted', accepted)}
isLoading={isLoading}
serverError={error}
/>
)
}
default: {
return null
}
}
}, [currentStepIndex, error, errors, handleFieldChange, isLoading, values])
const isLastStep = currentStepIndex === REGISTRATION_STEPS.length - 1
return (
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-4 py-10">
<LinearBorderContainer className="bg-background-tertiary h-[85vh] w-full max-w-5xl">
<div className="grid h-full lg:grid-cols-[280px_1fr]">
<div className="relative h-full">
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
<RegistrationSidebar
currentStepIndex={currentStepIndex}
canNavigateTo={canNavigateTo}
onStepSelect={jumpToStep}
/>
</div>
<main className="flex h-full w-[700px] flex-col">
<div className="shrink-0">
<RegistrationHeader currentStepIndex={currentStepIndex} />
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
</div>
<div className="relative flex h-0 flex-1">
<ScrollArea rootClassName="absolute! inset-0 h-full w-full">
<section ref={contentRef} className="p-12" onKeyDown={handleKeyDown}>
{StepComponent}
</section>
</ScrollArea>
</div>
<div className="shrink-0">
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<RegistrationFooter
disableBack={currentStepIndex === 0}
isSubmitting={isLoading}
isLastStep={isLastStep}
onBack={handleBack}
onNext={handleNext}
/>
</div>
</main>
</div>
</LinearBorderContainer>
<p className="text-text-tertiary mt-6 text-sm">
Already have an account?{' '}
<Link to="/login" className="text-accent hover:underline">
Sign in
</Link>
.
</p>
</div>
)
}

View File

@@ -0,0 +1,154 @@
import { useMutation } from '@tanstack/react-query'
import { FetchError } from 'ofetch'
import { useState } from 'react'
import type { RegisterTenantPayload } from '~/modules/auth/api/registerTenant'
import { registerTenant } from '~/modules/auth/api/registerTenant'
interface TenantRegistrationRequest {
tenantName: string
tenantSlug: string
accountName: string
email: string
password: string
}
const SECOND_LEVEL_PUBLIC_SUFFIXES = new Set(['ac', 'co', 'com', 'edu', 'gov', 'net', 'org'])
function resolveBaseDomain(hostname: string): string {
const envValue = (import.meta.env as Record<string, unknown> | undefined)?.VITE_APP_TENANT_BASE_DOMAIN
if (typeof envValue === 'string' && envValue.trim().length > 0) {
return envValue.trim().replace(/^\./, '').toLowerCase()
}
if (!hostname) {
return ''
}
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
return 'localhost'
}
const parts = hostname.split('.').filter(Boolean)
if (parts.length <= 2) {
return hostname
}
const tld = parts.at(-1) ?? ''
const secondLevel = parts.at(-2) ?? ''
if (tld.length === 2 && SECOND_LEVEL_PUBLIC_SUFFIXES.has(secondLevel) && parts.length >= 3) {
return parts.slice(-3).join('.').toLowerCase()
}
return parts.slice(-2).join('.').toLowerCase()
}
function buildTenantLoginUrl(slug: string): string {
const normalizedSlug = slug.trim().toLowerCase()
if (!normalizedSlug) {
throw new Error('Registration succeeded but a workspace slug was not returned.')
}
const { protocol, hostname, port } = window.location
const baseDomain = resolveBaseDomain(hostname)
if (!baseDomain) {
throw new Error('Unable to resolve base domain for workspace login redirect.')
}
const shouldAppendPort = Boolean(
port && (baseDomain === 'localhost' || hostname === baseDomain || hostname.endsWith(`.${baseDomain}`)),
)
const portSegment = shouldAppendPort ? `:${port}` : ''
const scheme = protocol || 'https:'
return `${scheme}//${normalizedSlug}.${baseDomain}${portSegment}/login`
}
export function useRegisterTenant() {
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const mutation = useMutation({
mutationFn: async (data: TenantRegistrationRequest) => {
const payload: RegisterTenantPayload = {
tenant: {
name: data.tenantName.trim(),
slug: data.tenantSlug.trim(),
},
account: {
name: data.accountName.trim() || data.email.trim(),
email: data.email.trim(),
password: data.password,
},
}
const response = await registerTenant(payload)
const headerSlug = response.headers.get('x-tenant-slug')?.trim().toLowerCase() ?? null
const submittedSlug = payload.tenant.slug?.trim().toLowerCase() ?? ''
const finalSlug = headerSlug && headerSlug.length > 0 ? headerSlug : submittedSlug
if (!finalSlug) {
throw new Error('Registration succeeded but the workspace slug could not be determined.')
}
return {
slug: finalSlug,
}
},
onSuccess: ({ slug }) => {
try {
const loginUrl = buildTenantLoginUrl(slug)
setErrorMessage(null)
window.location.replace(loginUrl)
} catch (redirectError) {
if (redirectError instanceof Error) {
setErrorMessage(redirectError.message)
} else {
setErrorMessage('Registration succeeded but redirect failed. Please use your workspace URL to sign in.')
}
}
},
onError: (error: Error) => {
if (error instanceof FetchError) {
const status = error.statusCode ?? error.response?.status
const serverMessage = (error.data as any)?.message
switch (status) {
case 400: {
setErrorMessage(serverMessage || 'Please verify your inputs and try again')
break
}
case 403: {
setErrorMessage(serverMessage || 'Registration is currently disabled')
break
}
case 409: {
setErrorMessage(serverMessage || 'An account or workspace with these details already exists')
break
}
case 429: {
setErrorMessage('Too many attempts. Please try again later')
break
}
default: {
setErrorMessage(serverMessage || error.message || 'Registration failed. Please try again')
}
}
} else {
setErrorMessage(error.message || 'An unexpected error occurred. Please try again')
}
},
})
const clearError = () => setErrorMessage(null)
return {
registerTenant: mutation.mutate,
isLoading: mutation.isPending,
error: errorMessage,
clearError,
}
}

View File

@@ -0,0 +1,156 @@
import { useState } from 'react'
import { isLikelyEmail, slugify } from '~/modules/onboarding/utils'
export interface TenantRegistrationFormState {
tenantName: string
tenantSlug: string
accountName: string
email: string
password: string
confirmPassword: string
termsAccepted: boolean
}
const REQUIRED_PASSWORD_LENGTH = 8
const ALL_FIELDS: Array<keyof TenantRegistrationFormState> = [
'tenantName',
'tenantSlug',
'accountName',
'email',
'password',
'confirmPassword',
'termsAccepted',
]
export function useRegistrationForm(initial?: Partial<TenantRegistrationFormState>) {
const [values, setValues] = useState<TenantRegistrationFormState>({
tenantName: initial?.tenantName ?? '',
tenantSlug: initial?.tenantSlug ?? '',
accountName: initial?.accountName ?? '',
email: initial?.email ?? '',
password: initial?.password ?? '',
confirmPassword: initial?.confirmPassword ?? '',
termsAccepted: initial?.termsAccepted ?? false,
})
const [errors, setErrors] = useState<Partial<Record<keyof TenantRegistrationFormState, string>>>({})
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
const updateValue = <K extends keyof TenantRegistrationFormState>(
field: K,
value: TenantRegistrationFormState[K],
) => {
setValues((prev) => {
if (field === 'tenantName' && !slugManuallyEdited) {
return {
...prev,
tenantName: value as string,
tenantSlug: slugify(value as string),
}
}
if (field === 'tenantSlug') {
setSlugManuallyEdited(true)
}
return { ...prev, [field]: value }
})
setErrors((prev) => {
const next = { ...prev }
delete next[field]
return next
})
}
const fieldError = (field: keyof TenantRegistrationFormState): string | undefined => {
switch (field) {
case 'tenantName': {
return values.tenantName.trim() ? undefined : 'Workspace name is required'
}
case 'tenantSlug': {
const slug = values.tenantSlug.trim()
if (!slug) return 'Slug is required'
if (!/^[a-z0-9-]+$/.test(slug)) return 'Use lowercase letters, numbers, and hyphen only'
return undefined
}
case 'email': {
const email = values.email.trim()
if (!email) return 'Email is required'
if (!isLikelyEmail(email)) return 'Enter a valid email address'
return undefined
}
case 'accountName': {
return values.accountName.trim() ? undefined : 'Administrator name is required'
}
case 'password': {
if (!values.password) return 'Password is required'
if (values.password.length < REQUIRED_PASSWORD_LENGTH) {
return `Password must be at least ${REQUIRED_PASSWORD_LENGTH} characters`
}
return undefined
}
case 'confirmPassword': {
if (!values.confirmPassword) return 'Confirm your password'
if (values.confirmPassword !== values.password) return 'Passwords do not match'
return undefined
}
case 'termsAccepted': {
return values.termsAccepted ? undefined : 'You must accept the terms to continue'
}
}
return undefined
}
const validate = (fields?: Array<keyof TenantRegistrationFormState>) => {
const fieldsToValidate = fields ?? ALL_FIELDS
const stepErrors: Partial<Record<keyof TenantRegistrationFormState, string>> = {}
let hasErrors = false
for (const field of fieldsToValidate) {
const error = fieldError(field)
if (error) {
stepErrors[field] = error
hasErrors = true
}
}
setErrors((prev) => {
const next = { ...prev }
for (const field of fieldsToValidate) {
const error = stepErrors[field]
if (error) {
next[field] = error
} else {
delete next[field]
}
}
return next
})
return !hasErrors
}
const reset = () => {
setValues({
tenantName: '',
tenantSlug: '',
accountName: '',
email: '',
password: '',
confirmPassword: '',
termsAccepted: false,
})
setErrors({})
setSlugManuallyEdited(false)
}
return {
values,
errors,
updateValue,
validate,
getFieldError: fieldError,
reset,
}
}

View File

@@ -0,0 +1,5 @@
import { RegistrationWizard } from '~/modules/auth/components/RegistrationWizard'
export function Component() {
return <RegistrationWizard />
}