mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat(auth): implement tenant registration wizard and related hooks
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
26
be/apps/dashboard/src/modules/auth/api/registerTenant.ts
Normal file
26
be/apps/dashboard/src/modules/auth/api/registerTenant.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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 possible—use 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'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>
|
||||
)
|
||||
}
|
||||
154
be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts
Normal file
154
be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
156
be/apps/dashboard/src/modules/auth/hooks/useRegistrationForm.ts
Normal file
156
be/apps/dashboard/src/modules/auth/hooks/useRegistrationForm.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
5
be/apps/dashboard/src/pages/(onboarding)/register.tsx
Normal file
5
be/apps/dashboard/src/pages/(onboarding)/register.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { RegistrationWizard } from '~/modules/auth/components/RegistrationWizard'
|
||||
|
||||
export function Component() {
|
||||
return <RegistrationWizard />
|
||||
}
|
||||
Reference in New Issue
Block a user