refactor(server): tanant auth (#148)

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-06 01:10:08 +08:00
committed by GitHub
parent 22f058aee3
commit 4633dbbd1e
63 changed files with 5949 additions and 400 deletions

View File

@@ -2,7 +2,8 @@ import 'reflect-metadata'
import { env } from '@afilmory/env'
import type { HonoHttpApplication } from '@afilmory/framework'
import { createApplication, createZodValidationPipe } from '@afilmory/framework'
import { createApplication, createLogger, createZodValidationPipe, HttpException } from '@afilmory/framework'
import { BizException } from 'core/errors'
import { PgPoolProvider } from './database/database.provider'
import { AllExceptionsFilter } from './filters/all-exceptions.filter'
@@ -27,11 +28,15 @@ const GlobalValidationPipe = createZodValidationPipe({
stopAtFirstError: true,
})
const honoErrorLogger = createLogger('HonoErrorHandler')
export async function createConfiguredApp(options: BootstrapOptions = {}): Promise<HonoHttpApplication> {
const app = await createApplication(AppModules, {
globalPrefix: options.globalPrefix ?? '/api',
})
const container = app.getContainer()
app.useGlobalFilters(new AllExceptionsFilter())
app.useGlobalInterceptors(new LoggingInterceptor())
app.useGlobalInterceptors(new ResponseTransformInterceptor())
@@ -39,7 +44,6 @@ export async function createConfiguredApp(options: BootstrapOptions = {}): Promi
app.useGlobalPipes(new GlobalValidationPipe())
// Warm up DB connection during bootstrap
const container = app.getContainer()
const poolProvider = container.resolve(PgPoolProvider)
await poolProvider.warmup()
@@ -49,5 +53,55 @@ export async function createConfiguredApp(options: BootstrapOptions = {}): Promi
registerOpenApiRoutes(app.getInstance(), { globalPrefix: options.globalPrefix ?? '/api' })
const hono = app.getInstance()
hono.onError((error, context) => {
if (error instanceof BizException) {
return new Response(JSON.stringify(error.toResponse()), {
status: error.getHttpStatus(),
headers: {
'content-type': 'application/json',
},
})
}
if (error instanceof HttpException) {
return new Response(JSON.stringify(error.getResponse()), {
status: error.getStatus(),
headers: {
'content-type': 'application/json',
},
})
}
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
const statusCode =
typeof (error as { statusCode?: number }).statusCode === 'number'
? (error as { statusCode?: number }).statusCode!
: 500
return new Response(JSON.stringify(error), {
status: statusCode,
headers: {
'content-type': 'application/json',
},
})
}
honoErrorLogger.error(`Unhandled error ${context.req.method} ${context.req.url}`, error)
return new Response(
JSON.stringify({
statusCode: 500,
message: 'Internal server error',
}),
{
status: 500,
headers: {
'content-type': 'application/json',
},
},
)
})
return app
}

View File

@@ -1,90 +1,157 @@
import { authUsers } from '@afilmory/db'
import { authUsers, tenantAuthUsers } from '@afilmory/db'
import type { CanActivate, ExecutionContext } from '@afilmory/framework'
import { HttpContext } from '@afilmory/framework'
import type { Session } from 'better-auth'
import { applyTenantIsolationContext, DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
import { getTenantContext } from 'core/modules/tenant/tenant.context'
import { TenantService } from 'core/modules/tenant/tenant.service'
import { TenantContextResolver } from 'core/modules/tenant/tenant-context-resolver.service'
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'
import { TenantAuthProvider } from '../modules/tenant-auth/tenant-auth.provider'
import { getAllowedRoleMask, roleNameToBit } from './roles.decorator'
declare module '@afilmory/framework' {
interface HttpContextValues {
auth?: {
user?: AuthSession['user']
user?: AuthSession['user'] | TenantAuthSession['user']
session?: Session
source?: 'global' | 'tenant'
}
}
}
@injectable()
export class AuthGuard implements CanActivate {
private readonly log = logger.extend('AuthGuard')
constructor(
private readonly authProvider: AuthProvider,
private readonly tenantAuthProvider: TenantAuthProvider,
private readonly dbAccessor: DbAccessor,
private readonly tenantService: TenantService,
private readonly tenantContextResolver: TenantContextResolver,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const store = context.getContext()
const { hono } = store
const { method, path } = hono.req
const auth = await this.authProvider.getAuth()
if (this.isPublicRoute(method, path)) {
this.log.verbose(`Bypass guard for public route ${method} ${path}`)
return true
}
const session = await auth.api.getSession({ headers: hono.req.raw.headers })
this.log.verbose(`Evaluating guard for ${method} ${path}`)
let tenantContext = getTenantContext()
if (!tenantContext) {
tenantContext = await this.tenantService.resolve({ fallbackToPrimary: true })
HttpContext.assign({ tenant: tenantContext })
}
if (!tenantContext) {
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
const resolvedTenant = await this.tenantContextResolver.resolve(hono, {
setResponseHeaders: false,
})
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
}
}
if (session) {
const { headers } = hono.req.raw
const globalAuth = this.authProvider.getAuth()
let sessionSource: 'global' | 'tenant' | null = null
let authSession: AuthSession | TenantAuthSession | null = await globalAuth.api.getSession({ headers })
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) {
HttpContext.assign({
auth: {
user: session.user,
session: session.session,
user: authSession.user,
session: authSession.session,
source: sessionSource ?? undefined,
},
})
const roleName = session.user.role as 'user' | 'admin' | 'superadmin' | undefined
const isSuperAdmin = roleName === 'superadmin'
let sessionTenantId = session.user?.tenantId
const userRoleValue = (authSession.user as { role?: string }).role
const roleName = userRoleValue as 'user' | 'admin' | 'superadmin' | 'guest' | undefined
const isGlobalSession = sessionSource === 'global'
const isSuperAdmin = isGlobalSession && roleName === 'superadmin'
let sessionTenantId = (authSession.user as { tenantId?: string | null }).tenantId ?? null
if (!isSuperAdmin) {
if (!sessionTenantId) {
const db = this.dbAccessor.get()
const [record] = await db
.select({ tenantId: authUsers.tenantId })
.from(authUsers)
.where(eq(authUsers.id, session.user.id))
.limit(1)
sessionTenantId = record?.tenantId ?? ''
if (sessionSource === 'tenant') {
const [record] = await db
.select({ tenantId: tenantAuthUsers.tenantId })
.from(tenantAuthUsers)
.where(eq(tenantAuthUsers.id, authSession.user.id))
.limit(1)
sessionTenantId = record?.tenantId ?? ''
} else {
const [record] = await db
.select({ tenantId: authUsers.tenantId })
.from(authUsers)
.where(eq(authUsers.id, authSession.user.id))
.limit(1)
sessionTenantId = record?.tenantId ?? ''
}
}
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)
}
}
await applyTenantIsolationContext({
tenantId: tenantContext.tenant.id,
isSuperAdmin,
})
if (tenantContext) {
await applyTenantIsolationContext({
tenantId: tenantContext.tenant.id,
isSuperAdmin,
})
}
if (isSuperAdmin) {
return true
@@ -94,17 +161,38 @@ export class AuthGuard implements CanActivate {
const handler = context.getHandler()
const requiredMask = getAllowedRoleMask(handler)
if (requiredMask > 0) {
if (!session) {
if (!authSession) {
this.log.warn(`Denied access: missing session for protected resource ${method} ${path}`)
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
}
const userRoleName = session.user.role as 'user' | 'admin' | 'superadmin' | undefined
const userRoleName = (authSession.user as { role?: string }).role as
| 'user'
| 'admin'
| 'superadmin'
| 'guest'
| undefined
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

@@ -0,0 +1,24 @@
import type { TenantResolutionOptions } from '../modules/tenant/tenant-context-resolver.service'
export const TENANT_RESOLUTION_OPTIONS = Symbol('core:tenantResolutionOptions')
type DecoratorTarget = object | Function
function setMetadata(target: DecoratorTarget, options: TenantResolutionOptions): void {
Reflect.defineMetadata(TENANT_RESOLUTION_OPTIONS, options, target)
}
export function TenantResolution(options: TenantResolutionOptions): ClassDecorator & MethodDecorator {
return ((
target: DecoratorTarget,
_propertyKey?: string | symbol,
descriptor?: PropertyDescriptor,
): void | PropertyDescriptor => {
if (descriptor && descriptor.value) {
setMetadata(descriptor.value as DecoratorTarget, options)
return descriptor
}
setMetadata(target, options)
}) as unknown as ClassDecorator & MethodDecorator
}

View File

@@ -0,0 +1,54 @@
import type { CallHandler, ExecutionContext, FrameworkResponse, Interceptor } from '@afilmory/framework'
import { injectable } from 'tsyringe'
import type { TenantResolutionOptions } from '../modules/tenant/tenant-context-resolver.service'
import { TenantContextResolver } from '../modules/tenant/tenant-context-resolver.service'
import { TENANT_RESOLUTION_OPTIONS } from './tenant-resolver.decorator'
const DEFAULT_OPTIONS: Required<TenantResolutionOptions> = {
throwOnMissing: true,
setResponseHeaders: true,
skipInitializationCheck: false,
}
function getResolutionOptions(target: object | Function | undefined): TenantResolutionOptions | undefined {
if (!target) {
return undefined
}
try {
return Reflect.getMetadata(TENANT_RESOLUTION_OPTIONS, target) as TenantResolutionOptions | undefined
} catch {
return undefined
}
}
function mergeOptions(
classOptions: TenantResolutionOptions | undefined,
handlerOptions: TenantResolutionOptions | undefined,
): TenantResolutionOptions {
return {
...DEFAULT_OPTIONS,
...classOptions,
...handlerOptions,
}
}
@injectable()
export class TenantResolverInterceptor implements Interceptor {
constructor(private readonly tenantContextResolver: TenantContextResolver) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<FrameworkResponse> {
const { hono } = context.getContext()
const handler = context.getHandler()
const clazz = context.getClass()
const classOptions = getResolutionOptions(clazz)
const handlerOptions = getResolutionOptions(handler)
const resolutionOptions = mergeOptions(classOptions, handlerOptions)
await this.tenantContextResolver.resolve(hono, resolutionOptions)
return await next.handle()
}
}

View File

@@ -1,14 +1,14 @@
import type { HttpMiddleware, OnModuleDestroy, OnModuleInit } from '@afilmory/framework'
import { EventEmitterService, Middleware } from '@afilmory/framework'
import { OnboardingService } from 'core/modules/onboarding/onboarding.service'
import type { Context, Next } from 'hono'
import type { Context } from 'hono'
import { cors } from 'hono/cors'
import { injectable } from 'tsyringe'
import { logger } from '../helpers/logger.helper'
import { SettingService } from '../modules/setting/setting.service'
import { getTenantContext } from '../modules/tenant/tenant.context'
import { TenantService } from '../modules/tenant/tenant.service'
import { TenantContextResolver } from '../modules/tenant/tenant-context-resolver.service'
type AllowedOrigins = '*' | string[]
@@ -47,12 +47,11 @@ function parseAllowedOrigins(raw: string | null): AllowedOrigins {
@injectable()
export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDestroy {
private readonly allowedOrigins = new Map<string, AllowedOrigins>()
private defaultTenantId?: string
private readonly logger = logger.extend('CorsMiddleware')
constructor(
private readonly eventEmitter: EventEmitterService,
private readonly settingService: SettingService,
private readonly tenantService: TenantService,
private readonly tenantContextResolver: TenantContextResolver,
private readonly onboardingService: OnboardingService,
) {}
@@ -76,16 +75,6 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
}
async onModuleInit(): Promise<void> {
try {
const defaultTenant = await this.tenantService.resolve({ fallbackToPrimary: true }, true)
if (!defaultTenant) {
return
}
this.defaultTenantId = defaultTenant.tenant.id
await this.reloadAllowedOrigins(defaultTenant.tenant.id)
} catch (error) {
this.logger.warn('Failed to preload default tenant CORS configuration', error)
}
this.eventEmitter.on('setting.updated', this.handleSettingUpdated)
this.eventEmitter.on('setting.deleted', this.handleSettingDeleted)
}
@@ -131,7 +120,7 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
})
}
async use(context: Context, next: Next): Promise<Response | void> {
['use']: HttpMiddleware['use'] = async (context, next) => {
const initialized = await this.onboardingService.isInitialized()
if (!initialized) {
@@ -143,8 +132,12 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
return await next()
}
const tenantContext = getTenantContext()
const tenantId = tenantContext?.tenant.id ?? this.defaultTenantId
const tenantContext = await this.tenantContextResolver.resolve(context, {
setResponseHeaders: false,
skipInitializationCheck: true,
})
const tenantId = tenantContext?.tenant.id
if (tenantId) {
await this.ensureTenantOriginsLoaded(tenantId)
@@ -193,7 +186,7 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
}
const tenantContext = getTenantContext()
const tenantId = tenantContext?.tenant.id ?? this.defaultTenantId
const tenantId = tenantContext?.tenant.id
if (!tenantId) {
return null

View File

@@ -1,60 +0,0 @@
import type { HttpMiddleware } from '@afilmory/framework'
import { HttpContext, Middleware } from '@afilmory/framework'
import type { Context, Next } from 'hono'
import { injectable } from 'tsyringe'
import { logger } from '../helpers/logger.helper'
import { OnboardingService } from '../modules/onboarding/onboarding.service'
import { TenantService } from '../modules/tenant/tenant.service'
const HEADER_TENANT_ID = 'x-tenant-id'
const HEADER_TENANT_SLUG = 'x-tenant-slug'
@Middleware()
@injectable()
export class TenantResolverMiddleware implements HttpMiddleware {
private readonly log = logger.extend('TenantResolver')
constructor(
private readonly tenantService: TenantService,
private readonly onboardingService: OnboardingService,
) {}
async use(context: Context, next: Next): Promise<Response | void> {
const { path } = context.req
// During onboarding (before any user/tenant exists), skip tenant resolution entirely
const initialized = await this.onboardingService.isInitialized()
if (!initialized) {
this.log.info(`Application not initialized yet, skip tenant resolution for ${path}`)
return await next()
}
const tenantContext = await this.resolveTenantContext(context)
HttpContext.assign({ tenant: tenantContext })
const response = await next()
context.header(HEADER_TENANT_ID, tenantContext.tenant.id)
context.header(HEADER_TENANT_SLUG, tenantContext.tenant.slug)
return response
}
private async resolveTenantContext(context: Context) {
const host = context.req.header('host')
const tenantId = context.req.header(HEADER_TENANT_ID)
const tenantSlug = context.req.header(HEADER_TENANT_SLUG)
this.log.debug(
`Resolve tenant for request ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'}, id=${tenantId ?? 'n/a'}, slug=${tenantSlug ?? 'n/a'})`,
)
return await this.tenantService.resolve({
tenantId,
slug: tenantSlug,
domain: host,
fallbackToPrimary: true,
})
}
}

View File

@@ -0,0 +1,162 @@
import { authUsers } from '@afilmory/db'
import { BizException, ErrorCode } from 'core/errors'
import { eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { DbAccessor } from '../../database/database.provider'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { TenantRepository } from '../tenant/tenant.repository'
import { TenantService } from '../tenant/tenant.service'
import type { TenantRecord } from '../tenant/tenant.types'
import { AuthProvider } from './auth.provider'
type RegisterTenantAccountInput = {
email: string
password: string
name: string
}
type RegisterTenantInput = {
account: RegisterTenantAccountInput
tenant: {
name: string
slug?: string | null
}
}
export interface RegisterTenantResult {
response: Response
tenant?: TenantRecord
accountId?: string
success: boolean
}
function slugify(value: string): string {
return value
.normalize('NFKD')
.replaceAll(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, '-')
.replaceAll(/-{2,}/g, '-')
.replaceAll(/^-+|-+$/g, '')
}
@injectable()
export class AuthRegistrationService {
constructor(
private readonly authProvider: AuthProvider,
private readonly tenantService: TenantService,
private readonly tenantRepository: TenantRepository,
private readonly superAdminSettings: SuperAdminSettingService,
private readonly dbAccessor: DbAccessor,
) {}
async registerTenant(input: RegisterTenantInput, headers: Headers): Promise<RegisterTenantResult> {
await this.superAdminSettings.ensureRegistrationAllowed()
const accountEmail = input.account.email.trim().toLowerCase()
const accountPassword = input.account.password
const accountName = input.account.name.trim() || accountEmail
if (!accountEmail) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '邮箱不能为空' })
}
if (accountPassword.trim().length < 8) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '密码长度至少需要 8 个字符',
})
}
const tenantName = input.tenant.name.trim()
if (!tenantName) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户名称不能为空' })
}
const slugBase = input.tenant.slug?.trim() ? slugify(input.tenant.slug) : slugify(tenantName)
if (!slugBase) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户标识不能为空' })
}
const slug = await this.generateUniqueSlug(slugBase)
let tenantId: string | null = null
try {
const tenantAggregate = await this.tenantService.createTenant({
name: tenantName,
slug,
})
tenantId = tenantAggregate.tenant.id
const auth = this.authProvider.getAuth()
const response = await auth.api.signUpEmail({
body: {
email: accountEmail,
password: accountPassword,
name: accountName,
},
headers,
asResponse: true,
})
if (!response.ok) {
if (tenantId) {
await this.tenantService.deleteTenant(tenantId).catch(() => {})
tenantId = null
}
return { response, success: false }
}
let userId: string | undefined
try {
const payload = (await response.clone().json()) as { user?: { id?: string } } | null
userId = payload?.user?.id
} catch {
userId = undefined
}
if (!userId) {
if (tenantId) {
await this.tenantService.deleteTenant(tenantId).catch(() => {})
tenantId = null
}
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '注册成功但未返回用户信息,请稍后重试。',
})
}
const db = this.dbAccessor.get()
await db.update(authUsers).set({ tenantId, role: 'admin' }).where(eq(authUsers.id, userId))
const refreshed = await this.tenantService.getById(tenantId)
return {
response,
tenant: refreshed.tenant,
accountId: userId,
success: true,
}
} catch (error) {
if (tenantId) {
await this.tenantService.deleteTenant(tenantId).catch(() => {})
}
throw error
}
}
private async generateUniqueSlug(base: string): Promise<string> {
const sanitizedBase = base.length > 0 ? base : 'tenant'
for (let attempt = 0; attempt < 50; attempt += 1) {
const candidate = attempt === 0 ? sanitizedBase : `${sanitizedBase}-${attempt + 1}`
const existing = await this.tenantRepository.findBySlug(candidate)
if (!existing) {
return candidate
}
}
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '无法生成唯一的租户标识,请尝试使用不同的名称',
})
}
}

View File

@@ -1,5 +1,5 @@
import { authUsers } from '@afilmory/db'
import { Body, ContextParam, Controller, Get, Post, UnauthorizedException } from '@afilmory/framework'
import { Body, ContextParam, Controller, Get, HttpContext, Post, UnauthorizedException } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { eq } from 'drizzle-orm'
import type { Context } from 'hono'
@@ -8,6 +8,19 @@ import { DbAccessor } from '../../database/database.provider'
import { RoleBit, Roles } from '../../guards/roles.decorator'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { AuthProvider } from './auth.provider'
import { AuthRegistrationService } from './auth-registration.service'
type TenantSignUpRequest = {
account?: {
email?: string
password?: string
name?: string
}
tenant?: {
name?: string
slug?: string | null
}
}
@Controller('auth')
export class AuthController {
@@ -15,23 +28,20 @@ export class AuthController {
private readonly auth: AuthProvider,
private readonly dbAccessor: DbAccessor,
private readonly superAdminSettings: SuperAdminSettingService,
private readonly registration: AuthRegistrationService,
) {}
@Get('/session')
async getSession(@ContextParam() context: Context) {
const auth = this.auth.getAuth()
// forward tenant headers so Better Auth can persist tenantId via databaseHooks
const headers = new Headers(context.req.raw.headers)
const tenant = (context as any).var?.tenant
if (tenant?.tenant?.id) {
headers.set('x-tenant-id', tenant.tenant.id)
if (tenant.tenant.slug) headers.set('x-tenant-slug', tenant.tenant.slug)
}
const session = await auth.api.getSession({ headers })
if (!session) {
async getSession(@ContextParam() _context: Context) {
const authContext = HttpContext.getValue('auth')
if (!authContext?.user || !authContext.session) {
throw new UnauthorizedException()
}
return { user: session.user, session: session.session }
return {
user: authContext.user,
session: authContext.session,
source: authContext.source ?? 'global',
}
}
@Post('/sign-in/email')
@@ -75,6 +85,37 @@ export class AuthController {
return response
}
@Post('/tenants/sign-up')
async signUpTenant(@ContextParam() context: Context, @Body() body: TenantSignUpRequest) {
if (!body?.account || !body?.tenant) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少注册信息' })
}
const headers = new Headers(context.req.raw.headers)
const result = await this.registration.registerTenant(
{
account: {
email: body.account.email ?? '',
password: body.account.password ?? '',
name: body.account.name ?? '',
},
tenant: {
name: body.tenant.name ?? '',
slug: body.tenant.slug ?? null,
},
},
headers,
)
if (result.success && result.tenant) {
context.header('x-tenant-id', result.tenant.id)
context.header('x-tenant-slug', result.tenant.slug)
}
return result.response
}
@Get('/admin-only')
@Roles(RoleBit.ADMIN)
async adminOnly(@ContextParam() _context: Context) {

View File

@@ -2,13 +2,15 @@ import { Module } from '@afilmory/framework'
import { DatabaseModule } from 'core/database/database.module'
import { SystemSettingModule } from '../system-setting/system-setting.module'
import { TenantModule } from '../tenant/tenant.module'
import { AuthConfig } from './auth.config'
import { AuthController } from './auth.controller'
import { AuthProvider } from './auth.provider'
import { AuthRegistrationService } from './auth-registration.service'
@Module({
imports: [DatabaseModule, SystemSettingModule],
imports: [DatabaseModule, SystemSettingModule, TenantModule],
controllers: [AuthController],
providers: [AuthProvider, AuthConfig],
providers: [AuthProvider, AuthConfig, AuthRegistrationService],
})
export class AuthModule {}

View File

@@ -1,8 +1,8 @@
import { APP_GUARD, APP_MIDDLEWARE, EventModule, Module } from '@afilmory/framework'
import { APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, EventModule, Module } from '@afilmory/framework'
import { AuthGuard } from 'core/guards/auth.guard'
import { TenantResolverInterceptor } from 'core/interceptors/tenant-resolver.interceptor'
import { CorsMiddleware } from 'core/middlewares/cors.middleware'
import { DatabaseContextMiddleware } from 'core/middlewares/database-context.middleware'
import { TenantResolverMiddleware } from 'core/middlewares/tenant-resolver.middleware'
import { RedisAccessor } from 'core/redis/redis.provider'
import { DatabaseModule } from '../database/database.module'
@@ -18,6 +18,7 @@ import { StaticWebModule } from './static-web/static-web.module'
import { SuperAdminModule } from './super-admin/super-admin.module'
import { SystemSettingModule } from './system-setting/system-setting.module'
import { TenantModule } from './tenant/tenant.module'
import { TenantAuthModule } from './tenant-auth/tenant-auth.module'
function createEventModuleOptions(redis: RedisAccessor) {
return {
@@ -38,6 +39,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
ReactionModule,
DashboardModule,
TenantModule,
TenantAuthModule,
DataSyncModule,
StaticWebModule,
EventModule.forRootAsync({
@@ -50,10 +52,6 @@ function createEventModuleOptions(redis: RedisAccessor) {
provide: APP_MIDDLEWARE,
useClass: CorsMiddleware,
},
{
provide: APP_MIDDLEWARE,
useClass: TenantResolverMiddleware,
},
{
provide: APP_MIDDLEWARE,
useClass: DatabaseContextMiddleware,
@@ -63,6 +61,10 @@ function createEventModuleOptions(redis: RedisAccessor) {
provide: APP_GUARD,
useClass: AuthGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: TenantResolverInterceptor,
},
],
})
export class AppModules {}

View File

@@ -38,11 +38,6 @@ export class OnboardingInitDto extends createZodDto(
.string()
.min(1)
.regex(/^[a-z0-9-]+$/, { message: 'Slug should be lowercase alphanumeric with hyphen' }),
domain: z
.string()
.min(1)
.regex(/^[a-z0-9.-]+$/, { message: 'Domain should be lowercase letters, numbers, dot or hyphen' })
.optional(),
}),
settings: normalizeEntries.optional().transform((entries) => entries ?? []),
}),

View File

@@ -43,8 +43,6 @@ export class OnboardingService {
const tenantAggregate = await this.tenantService.createTenant({
name: payload.tenant.name,
slug: payload.tenant.slug,
domain: payload.tenant.domain,
isPrimary: true,
})
log.info(`Created tenant ${tenantAggregate.tenant.slug} (${tenantAggregate.tenant.id})`)

View File

@@ -31,6 +31,28 @@ export const DEFAULT_SETTING_DEFINITIONS = {
isSensitive: true,
schema: z.string().min(1, 'GitHub Client secret cannot be empty'),
},
'auth.tenant.config': {
isSensitive: true,
schema: z
.string()
.transform((value) => value.trim())
.transform((value, ctx) => {
if (value.length === 0) {
return '{}'
}
try {
const parsed = JSON.parse(value)
return JSON.stringify(parsed)
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Tenant auth configuration must be valid JSON',
})
return z.NEVER
}
}),
},
'builder.storage.providers': {
isSensitive: false,
schema: z.string().transform((value, ctx) => {

View File

@@ -3,6 +3,7 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:
import { settings } from '@afilmory/db'
import { env } from '@afilmory/env'
import { EventEmitterService } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { and, eq, inArray } from 'drizzle-orm'
import { injectable } from 'tsyringe'
@@ -260,8 +261,7 @@ export class SettingService {
return tenant.tenant.id
}
const fallback = await this.tenantService.resolve({ fallbackToPrimary: true })
return fallback.tenant.id
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
}
private encrypt(value: string): string {

View File

@@ -6,6 +6,12 @@ const updateSuperAdminSettingsSchema = z
allowRegistration: z.boolean().optional(),
maxRegistrableUsers: z.number().int().min(0).nullable().optional(),
localProviderEnabled: z.boolean().optional(),
baseDomain: z
.string()
.trim()
.min(1)
.regex(/^[a-z0-9.-]+$/i, { message: '无效的基础域名' })
.optional(),
})
.refine((value) => Object.values(value).some((entry) => entry !== undefined), {
message: '至少需要更新一项设置',

View File

@@ -1,3 +1,4 @@
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
import { z } from 'zod'
export const SUPER_ADMIN_SETTING_DEFINITIONS = {
@@ -16,6 +17,17 @@ export const SUPER_ADMIN_SETTING_DEFINITIONS = {
schema: z.boolean(),
defaultValue: true,
},
baseDomain: {
key: 'system.domain.base',
schema: z
.string()
.trim()
.min(1)
.regex(/^[a-z0-9.-]+$/i, {
message: '域名只能包含字母、数字、连字符和点',
}),
defaultValue: DEFAULT_BASE_DOMAIN,
},
} as const
export type SuperAdminSettingField = keyof typeof SUPER_ADMIN_SETTING_DEFINITIONS

View File

@@ -43,10 +43,19 @@ export class SuperAdminSettingService {
SUPER_ADMIN_SETTING_DEFINITIONS.localProviderEnabled.defaultValue,
)
const baseDomainRaw = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.key],
SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.defaultValue,
)
const baseDomain = baseDomainRaw.trim().toLowerCase()
return {
allowRegistration,
maxRegistrableUsers,
localProviderEnabled,
baseDomain,
}
}
@@ -111,6 +120,23 @@ export class SuperAdminSettingService {
}
}
if (patch.baseDomain !== undefined) {
const sanitized = patch.baseDomain === null ? null : String(patch.baseDomain).trim().toLowerCase()
if (!sanitized) {
updates.push({
key: SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.key,
value: SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.defaultValue,
})
current.baseDomain = SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.defaultValue
} else if (sanitized !== current.baseDomain) {
updates.push({
key: SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.key,
value: sanitized,
})
current.baseDomain = sanitized
}
}
if (updates.length === 0) {
return current
}

View File

@@ -5,6 +5,7 @@ export interface SuperAdminSettings {
allowRegistration: boolean
maxRegistrableUsers: number | null
localProviderEnabled: boolean
baseDomain: string
}
export type SuperAdminSettingValueMap = {

View File

@@ -35,6 +35,18 @@ export const SUPER_ADMIN_SETTING_UI_SCHEMA: UiSchema<SuperAdminSettingField> = {
type: 'switch',
},
},
{
type: 'field',
id: 'platform-base-domain',
title: '平台基础域名',
description: '用于解析子域名租户,如 example.{{value}}。更新后请确保证书和 DNS 已正确配置。',
helperText: '留空使用默认域名 afilmory.art。',
key: 'baseDomain',
component: {
type: 'text',
placeholder: 'afilmory.art',
},
},
{
type: 'field',
id: 'registration-max-users',

View File

@@ -0,0 +1,76 @@
import { injectable } from 'tsyringe'
import { z } from 'zod'
import type { SocialProvidersConfig } from '../auth/auth.config'
import { SettingService } from '../setting/setting.service'
export const TENANT_AUTH_CONFIG_SETTING_KEY = 'auth.tenant.config'
export interface TenantAuthOptions {
localProviderEnabled: boolean
socialProviders: SocialProvidersConfig
}
const providerConfigSchema = z.object({
clientId: z.string().min(1),
clientSecret: z.string().min(1),
redirectUri: z.string().min(1).optional(),
})
const tenantAuthConfigSchema = z
.object({
localProviderEnabled: z.boolean().default(true),
socialProviders: z
.object({
google: providerConfigSchema.optional(),
github: providerConfigSchema.optional(),
zoom: providerConfigSchema.optional(),
})
.partial()
.default({}),
})
.default({ localProviderEnabled: true, socialProviders: {} })
type TenantAuthConfig = z.infer<typeof tenantAuthConfigSchema>
function normalizeConfig(raw: TenantAuthConfig): TenantAuthOptions {
const socialProviders: SocialProvidersConfig = {}
if (raw.socialProviders?.google) {
socialProviders.google = raw.socialProviders.google
}
if (raw.socialProviders?.github) {
socialProviders.github = raw.socialProviders.github
}
if (raw.socialProviders?.zoom) {
socialProviders.zoom = raw.socialProviders.zoom
}
return {
localProviderEnabled: raw.localProviderEnabled ?? true,
socialProviders,
}
}
@injectable()
export class TenantAuthConfigService {
constructor(private readonly settingService: SettingService) {}
async getOptions(tenantId: string): Promise<TenantAuthOptions> {
const rawValue = await this.settingService.get(TENANT_AUTH_CONFIG_SETTING_KEY, { tenantId })
if (!rawValue) {
return normalizeConfig({ localProviderEnabled: true, socialProviders: {} })
}
try {
const parsed = tenantAuthConfigSchema.parse(JSON.parse(rawValue))
return normalizeConfig(parsed)
} catch {
// Fall back to defaults if parsing fails; tenant admins can fix configuration later.
return normalizeConfig({ localProviderEnabled: true, socialProviders: {} })
}
}
}

View File

@@ -0,0 +1,124 @@
import { Body, ContextParam, Controller, Get, Post } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { requireTenantContext } from 'core/modules/tenant/tenant.context'
import type { Context } from 'hono'
import { TenantAuthConfigService } from './tenant-auth.config'
import { TenantAuthProvider } from './tenant-auth.provider'
type TenantAuthEmailPayload = {
email: string
password: string
name?: string
}
@Controller('tenant-auth')
export class TenantAuthController {
constructor(
private readonly tenantAuthProvider: TenantAuthProvider,
private readonly tenantAuthConfig: TenantAuthConfigService,
) {}
@Get('/session')
async getSession(@ContextParam() context: Context) {
const tenant = requireTenantContext()
const auth = await this.tenantAuthProvider.getAuth(tenant.tenant.id)
const session = await auth.api.getSession({ headers: context.req.raw.headers })
if (!session) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
}
context.header('x-tenant-id', tenant.tenant.id)
context.header('x-tenant-slug', tenant.tenant.slug)
return { user: session.user, session: session.session, source: 'tenant' as const }
}
@Post('/sign-in/email')
async signInEmail(@ContextParam() context: Context, @Body() body: TenantAuthEmailPayload) {
const tenant = requireTenantContext()
const email = body?.email?.trim() ?? ''
const password = body?.password ?? ''
if (!email) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '邮箱不能为空' })
}
if (!password) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '密码不能为空' })
}
const config = await this.tenantAuthConfig.getOptions(tenant.tenant.id)
if (!config.localProviderEnabled) {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '当前租户已关闭邮箱密码登录,请联系管理员获取访问权限。',
})
}
const auth = await this.tenantAuthProvider.getAuth(tenant.tenant.id)
const response = await auth.api.signInEmail({
body: { email, password },
headers: context.req.raw.headers,
asResponse: true,
})
context.header('x-tenant-id', tenant.tenant.id)
context.header('x-tenant-slug', tenant.tenant.slug)
return response
}
@Post('/sign-up/email')
async signUpEmail(@ContextParam() context: Context, @Body() body: TenantAuthEmailPayload) {
const tenant = requireTenantContext()
const email = body?.email?.trim() ?? ''
const password = body?.password ?? ''
const name = body?.name?.trim() || email
if (!email) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '邮箱不能为空' })
}
if (!password) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '密码不能为空' })
}
const config = await this.tenantAuthConfig.getOptions(tenant.tenant.id)
if (!config.localProviderEnabled) {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '当前租户已关闭邮箱注册,请联系管理员开启后再试。',
})
}
const auth = await this.tenantAuthProvider.getAuth(tenant.tenant.id)
const response = await auth.api.signUpEmail({
body: {
email,
password,
name,
},
headers: context.req.raw.headers,
asResponse: true,
})
context.header('x-tenant-id', tenant.tenant.id)
context.header('x-tenant-slug', tenant.tenant.slug)
return response
}
@Get('/*')
async passthroughGet(@ContextParam() context: Context) {
const tenant = requireTenantContext()
return await this.tenantAuthProvider.handler(context, tenant.tenant.id)
}
@Post('/*')
async passthroughPost(@ContextParam() context: Context) {
const tenant = requireTenantContext()
return await this.tenantAuthProvider.handler(context, tenant.tenant.id)
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@afilmory/framework'
import { DatabaseModule } from '../../database/database.module'
import { SettingModule } from '../setting/setting.module'
import { TenantAuthConfigService } from './tenant-auth.config'
import { TenantAuthController } from './tenant-auth.controller'
import { TenantAuthProvider } from './tenant-auth.provider'
@Module({
imports: [DatabaseModule, SettingModule],
controllers: [TenantAuthController],
providers: [TenantAuthProvider, TenantAuthConfigService],
})
export class TenantAuthModule {}

View File

@@ -0,0 +1,135 @@
import { generateId, tenantAuthAccounts, tenantAuthSessions, tenantAuthUsers } from '@afilmory/db'
import type { OnModuleDestroy, OnModuleInit } from '@afilmory/framework'
import { EventEmitterService } from '@afilmory/framework'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { createAuthMiddleware } from 'better-auth/api'
import type { Context } from 'hono'
import { injectable } from 'tsyringe'
import { DrizzleProvider } from '../../database/database.provider'
import { TENANT_AUTH_CONFIG_SETTING_KEY, TenantAuthConfigService } from './tenant-auth.config'
export type TenantBetterAuthInstance = ReturnType<typeof betterAuth>
export type TenantAuthSession = TenantBetterAuthInstance['$Infer']['Session']
@injectable()
export class TenantAuthProvider implements OnModuleInit, OnModuleDestroy {
private readonly cache = new Map<string, TenantBetterAuthInstance>()
constructor(
private readonly drizzleProvider: DrizzleProvider,
private readonly configService: TenantAuthConfigService,
private readonly eventEmitter: EventEmitterService,
) {}
async onModuleInit(): Promise<void> {
this.eventEmitter.on('setting.updated', this.handleSettingUpdated)
this.eventEmitter.on('setting.deleted', this.handleSettingDeleted)
}
async onModuleDestroy(): Promise<void> {
this.eventEmitter.off('setting.updated', this.handleSettingUpdated)
this.eventEmitter.off('setting.deleted', this.handleSettingDeleted)
this.cache.clear()
}
private readonly handleSettingUpdated = ({ tenantId, key }: { tenantId: string; key: string }) => {
if (key !== TENANT_AUTH_CONFIG_SETTING_KEY) {
return
}
this.cache.delete(tenantId)
}
private readonly handleSettingDeleted = ({ tenantId, key }: { tenantId: string; key: string }) => {
if (key !== TENANT_AUTH_CONFIG_SETTING_KEY) {
return
}
this.cache.delete(tenantId)
}
async getAuth(tenantId: string): Promise<TenantBetterAuthInstance> {
const cached = this.cache.get(tenantId)
if (cached) {
return cached
}
const db = this.drizzleProvider.getDb()
const tenantOptions = await this.configService.getOptions(tenantId)
const instance = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
user: tenantAuthUsers,
session: tenantAuthSessions,
account: tenantAuthAccounts,
},
}),
emailAndPassword: { enabled: tenantOptions.localProviderEnabled },
socialProviders: tenantOptions.socialProviders,
user: {
additionalFields: {
tenantId: { type: 'string', input: false },
role: { type: 'string', input: false },
},
},
databaseHooks: {
user: {
create: {
before: async (user) => ({
data: {
...user,
tenantId,
role: user.role ?? 'guest',
},
}),
},
},
session: {
create: {
before: async (session) => ({
data: {
...session,
tenantId,
},
}),
},
},
account: {
create: {
before: async (account) => ({
data: {
...account,
tenantId,
},
}),
},
},
},
advanced: {
database: {
generateId: () => generateId(),
},
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path === '/sign-up/email' && !tenantOptions.localProviderEnabled) {
throw new Response(JSON.stringify({ message: '当前租户未启用邮件注册,请联系管理员获取访问权限。' }), {
status: 403,
headers: { 'content-type': 'application/json' },
})
}
}),
},
})
this.cache.set(tenantId, instance)
return instance
}
async handler(context: Context, tenantId: string): Promise<Response> {
const auth = await this.getAuth(tenantId)
return auth.handler(context.req.raw)
}
}

View File

@@ -0,0 +1,181 @@
import { HttpContext } from '@afilmory/framework'
import { DEFAULT_BASE_DOMAIN, isTenantSlugReserved } from '@afilmory/utils'
import { BizException, ErrorCode } from 'core/errors'
import type { Context } from 'hono'
import { injectable } from 'tsyringe'
import { logger } from '../../helpers/logger.helper'
import { OnboardingService } from '../onboarding/onboarding.service'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { TenantService } from './tenant.service'
import type { TenantContext } from './tenant.types'
const HEADER_TENANT_ID = 'x-tenant-id'
const HEADER_TENANT_SLUG = 'x-tenant-slug'
export interface TenantResolutionOptions {
throwOnMissing?: boolean
setResponseHeaders?: boolean
skipInitializationCheck?: boolean
}
@injectable()
export class TenantContextResolver {
private readonly log = logger.extend('TenantResolver')
constructor(
private readonly tenantService: TenantService,
private readonly onboardingService: OnboardingService,
private readonly superAdminSettingService: SuperAdminSettingService,
) {}
async resolve(context: Context, options: TenantResolutionOptions = {}): Promise<TenantContext | null> {
const existing = this.getExistingContext()
if (existing) {
if (options.setResponseHeaders !== false) {
this.applyTenantHeaders(context, existing)
}
return existing
}
if (!options.skipInitializationCheck) {
const initialized = await this.onboardingService.isInitialized()
if (!initialized) {
this.log.info(`Application not initialized yet, skip tenant resolution for ${context.req.path}`)
return null
}
}
const forwardedHost = context.req.header('x-forwarded-host')
const origin = context.req.header('origin')
const hostHeader = context.req.header('host')
const host = this.normalizeHost(forwardedHost ?? hostHeader ?? null, origin)
const tenantId = this.normalizeString(context.req.header(HEADER_TENANT_ID))
const tenantSlugHeader = this.normalizeSlug(context.req.header(HEADER_TENANT_SLUG))
this.log.verbose(
`Resolve tenant for request ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'}, id=${tenantId ?? 'n/a'}, slug=${tenantSlugHeader ?? 'n/a'})`,
)
const baseDomain = await this.getBaseDomain()
let derivedSlug = tenantSlugHeader
if (!derivedSlug && host) {
derivedSlug = this.extractSlugFromHost(host, baseDomain)
}
if (derivedSlug && isTenantSlugReserved(derivedSlug)) {
this.log.verbose(`Host ${host} matched reserved slug ${derivedSlug}, skipping tenant resolution.`)
return null
}
const tenantContext = await this.tenantService.resolve(
{
tenantId,
slug: derivedSlug,
},
true,
)
if (!tenantContext) {
if (options.throwOnMissing && (tenantId || derivedSlug)) {
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
}
return null
}
HttpContext.setValue('tenant', tenantContext)
if (options.setResponseHeaders !== false) {
this.applyTenantHeaders(context, tenantContext)
}
return tenantContext
}
private getExistingContext(): TenantContext | null {
try {
return (HttpContext.getValue('tenant') as TenantContext | undefined) ?? null
} catch {
return null
}
}
private applyTenantHeaders(context: Context, tenantContext: TenantContext): void {
context.header(HEADER_TENANT_ID, tenantContext.tenant.id)
context.header(HEADER_TENANT_SLUG, tenantContext.tenant.slug)
}
private async getBaseDomain(): Promise<string> {
if (process.env.NODE_ENV === 'development') {
return 'localhost'
}
const settings = await this.superAdminSettingService.getSettings()
return settings.baseDomain || DEFAULT_BASE_DOMAIN
}
private normalizeHost(host: string | null | undefined, origin: string | null | undefined): string | null {
const source = host ?? this.extractHostFromOrigin(origin)
if (!source) {
return null
}
return source.trim().toLowerCase()
}
private extractHostFromOrigin(origin: string | null | undefined): string | null {
if (!origin) {
return null
}
try {
const url = new URL(origin)
return url.host
} catch {
return null
}
}
private normalizeString(value: string | null | undefined): string | undefined {
if (!value) {
return undefined
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
private normalizeSlug(value: string | null | undefined): string | undefined {
const normalized = this.normalizeString(value)
return normalized ? normalized.toLowerCase() : undefined
}
private extractSlugFromHost(host: string, baseDomain: string): string | undefined {
const hostname = host.split(':')[0]
if (!hostname) {
return undefined
}
if (hostname.endsWith('.localhost')) {
const parts = hostname.split('.localhost')[0]
return parts ? parts.trim().toLowerCase() : undefined
}
const normalizedBase = baseDomain.toLowerCase()
if (hostname === normalizedBase) {
return undefined
}
if (hostname.endsWith(`.${normalizedBase}`)) {
const candidate = hostname.slice(0, hostname.length - normalizedBase.length - 1)
if (!candidate || candidate.includes('.')) {
return undefined
}
return candidate.toLowerCase()
}
return undefined
}
}

View File

@@ -5,9 +5,10 @@ import { DatabaseModule } from 'core/database/database.module'
import { TenantRepository } from './tenant.repository'
import { TenantService } from './tenant.service'
import { TenantContextResolver } from './tenant-context-resolver.service'
@Module({
imports: [DatabaseModule],
providers: [TenantRepository, TenantService],
providers: [TenantRepository, TenantService, TenantContextResolver],
})
export class TenantModule {}

View File

@@ -1,9 +1,9 @@
import { generateId, tenantDomains, tenants } from '@afilmory/db'
import { and, eq } from 'drizzle-orm'
import { generateId, tenants } from '@afilmory/db'
import { eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { DbAccessor } from '../../database/database.provider'
import type { TenantAggregate, TenantDomainMatch } from './tenant.types'
import type { TenantAggregate } from './tenant.types'
@injectable()
export class TenantRepository {
@@ -15,64 +15,20 @@ export class TenantRepository {
if (!tenant) {
return null
}
const domains = await db.select().from(tenantDomains).where(eq(tenantDomains.tenantId, tenant.id))
return { tenant, domains }
return { tenant }
}
async findBySlug(slug: string): Promise<TenantAggregate | null> {
const db = this.dbAccessor.get()
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, slug)).limit(1)
if (!tenant) {
return null
}
const domains = await db.select().from(tenantDomains).where(eq(tenantDomains.tenantId, tenant.id))
return { tenant, domains }
return { tenant }
}
async findPrimary(): Promise<TenantAggregate | null> {
const db = this.dbAccessor.get()
const [tenant] = await db
.select()
.from(tenants)
.where(and(eq(tenants.isPrimary, true), eq(tenants.status, 'active')))
.limit(1)
if (!tenant) {
return null
}
const domains = await db.select().from(tenantDomains).where(eq(tenantDomains.tenantId, tenant.id))
return { tenant, domains }
}
async findByDomain(domain: string): Promise<TenantDomainMatch | null> {
const normalized = domain.trim().toLowerCase()
if (!normalized) {
return null
}
const db = this.dbAccessor.get()
const [matchedDomain] = await db.select().from(tenantDomains).where(eq(tenantDomains.domain, normalized)).limit(1)
if (!matchedDomain) {
return null
}
const aggregate = await this.findById(matchedDomain.tenantId)
if (!aggregate) {
return null
}
return {
...aggregate,
matchedDomain,
}
}
async createTenant(payload: {
name: string
slug: string
domain?: string | null
isPrimary?: boolean
}): Promise<TenantAggregate> {
async createTenant(payload: { name: string; slug: string }): Promise<TenantAggregate> {
const db = this.dbAccessor.get()
const tenantId = generateId()
const tenantRecord: typeof tenants.$inferInsert = {
@@ -80,29 +36,10 @@ export class TenantRepository {
name: payload.name,
slug: payload.slug,
status: 'active',
primaryDomain: payload.domain ?? null,
isPrimary: payload.isPrimary ?? false,
}
if (tenantRecord.isPrimary) {
await db
.update(tenants)
.set({ isPrimary: false })
.where(and(eq(tenants.isPrimary, true), eq(tenants.status, 'active')))
}
await db.insert(tenants).values(tenantRecord)
if (payload.domain) {
const domainRecord: typeof tenantDomains.$inferInsert = {
id: generateId(),
tenantId,
domain: payload.domain,
isPrimary: true,
}
await db.insert(tenantDomains).values(domainRecord)
}
return await this.findById(tenantId).then((aggregate) => {
if (!aggregate) {
throw new Error('Failed to create tenant')
@@ -110,4 +47,9 @@ export class TenantRepository {
return aggregate
})
}
async deleteById(id: string): Promise<void> {
const db = this.dbAccessor.get()
await db.delete(tenants).where(eq(tenants.id, id))
}
}

View File

@@ -1,33 +1,30 @@
import { createLogger } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { injectable } from 'tsyringe'
import { TenantRepository } from './tenant.repository'
import type { TenantAggregate, TenantContext, TenantDomainMatch, TenantResolutionInput } from './tenant.types'
import type { TenantAggregate, TenantContext, TenantResolutionInput } from './tenant.types'
@injectable()
export class TenantService {
private readonly logger = createLogger('TenantService')
constructor(private readonly repository: TenantRepository) {}
async createTenant(payload: {
name: string
slug: string
domain?: string | null
isPrimary?: boolean
}): Promise<TenantAggregate> {
async createTenant(payload: { name: string; slug: string }): Promise<TenantAggregate> {
return await this.repository.createTenant(payload)
}
async resolve(input: TenantResolutionInput, noThrow: boolean): Promise<TenantContext | null>
async resolve(input: TenantResolutionInput): Promise<TenantContext>
async resolve(input: TenantResolutionInput, noThrow = false): Promise<TenantContext | null> {
const fallbackToPrimary = input.fallbackToPrimary ?? true
const tenantId = this.normalizeString(input.tenantId)
const slug = this.normalizeSlug(input.slug)
const domain = this.normalizeDomain(input.domain)
let aggregate: TenantAggregate | TenantDomainMatch | null = null
let aggregate: TenantAggregate | null = null
if (!tenantId && !slug) {
if (noThrow) {
return null
}
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
}
if (tenantId) {
aggregate = await this.repository.findById(tenantId)
@@ -37,44 +34,6 @@ export class TenantService {
aggregate = await this.repository.findBySlug(slug)
}
if (!aggregate && domain) {
aggregate = await this.repository.findByDomain(domain)
}
if (!aggregate && fallbackToPrimary) {
aggregate = await this.repository.findPrimary()
if (aggregate) {
this.logger.warn('Tenant resolution fallback to primary tenant', {
fallback: 'primary',
requested: {
tenantId: tenantId ?? undefined,
slug: slug ?? undefined,
domain: domain ?? undefined,
},
resolvedTenantId: aggregate.tenant.id,
resolvedTenantSlug: aggregate.tenant.slug,
})
}
}
if (!aggregate && fallbackToPrimary) {
aggregate = await this.repository.findPrimary()
if (aggregate) {
this.logger.warn('Tenant resolution fallback to default tenant slug', {
fallback: 'defaultSlug',
requested: {
tenantId: tenantId ?? undefined,
slug: slug ?? undefined,
domain: domain ?? undefined,
},
resolvedTenantId: aggregate.tenant.id,
})
}
}
if (!aggregate) {
if (noThrow) {
return null
@@ -84,12 +43,8 @@ export class TenantService {
this.ensureTenantIsActive(aggregate.tenant)
const matchedDomain = this.extractMatchedDomain(aggregate)
return {
tenant: aggregate.tenant,
domains: aggregate.domains,
matchedDomain,
}
}
@@ -116,6 +71,10 @@ export class TenantService {
return aggregate
}
async deleteTenant(id: string): Promise<void> {
await this.repository.deleteById(id)
}
private ensureTenantIsActive(tenant: TenantAggregate['tenant']): void {
if (tenant.status === 'suspended') {
throw new BizException(ErrorCode.TENANT_SUSPENDED)
@@ -138,22 +97,4 @@ export class TenantService {
const normalized = this.normalizeString(value)
return normalized ? normalized.toLowerCase() : null
}
private normalizeDomain(value?: string | null): string | null {
const normalized = this.normalizeString(value)
if (!normalized) {
return null
}
return normalized.replace(/:\d+$/, '').toLowerCase()
}
private extractMatchedDomain(
aggregate: TenantAggregate | TenantDomainMatch,
): TenantDomainMatch['matchedDomain'] | null {
if ('matchedDomain' in aggregate && aggregate.matchedDomain) {
return aggregate.matchedDomain
}
return null
}
}

View File

@@ -1,31 +1,20 @@
import type { tenantDomains, tenants, tenantStatusEnum } from '@afilmory/db'
import type { tenants, tenantStatusEnum } from '@afilmory/db'
export type TenantRecord = typeof tenants.$inferSelect
export type TenantDomainRecord = typeof tenantDomains.$inferSelect
export type TenantStatus = (typeof tenantStatusEnum.enumValues)[number]
export interface TenantAggregate {
tenant: TenantRecord
domains: TenantDomainRecord[]
}
export interface TenantDomainMatch extends TenantAggregate {
matchedDomain: TenantDomainRecord
}
export interface TenantContext extends TenantAggregate {
matchedDomain?: TenantDomainRecord | null
}
export type TenantContext = TenantAggregate
export interface TenantResolutionInput {
tenantId?: string | null
slug?: string | null
domain?: string | null
fallbackToPrimary?: boolean
}
export interface TenantCacheEntry {
aggregate: TenantAggregate
matchedDomain?: TenantDomainRecord | null
cachedAt: number
}

View File

@@ -6,20 +6,21 @@ import { useLocation, useNavigate } from 'react-router'
import { useSetAuthUser } from '~/atoms/auth'
import type { SessionResponse } from '~/modules/auth/api/session'
import { AUTH_SESSION_QUERY_KEY, fetchSession } from '~/modules/auth/api/session'
import { signOut } from '~/modules/auth/auth-client'
import { signOutBySource } from '~/modules/auth/auth-client'
import { getOnboardingStatus } from '~/modules/onboarding/api'
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()
@@ -54,7 +55,7 @@ export function usePageRedirect() {
const logout = useCallback(async () => {
try {
await signOut()
await signOutBySource(sessionQuery.data?.source)
} catch (error) {
console.error('Logout error:', error)
} finally {
@@ -63,7 +64,7 @@ export function usePageRedirect() {
setAuthUser(null)
navigate(DEFAULT_LOGIN_PATH, { replace: true })
}
}, [navigate, queryClient, setAuthUser])
}, [navigate, queryClient, sessionQuery.data?.source, setAuthUser])
// Sync auth user to atom
useEffect(() => {

View File

@@ -1,6 +1,7 @@
import { QueryClient } from '@tanstack/react-query'
import { FetchError } from 'ofetch'
const ignoreStatuses = new Set([400, 401, 403, 404, 409, 422, 402])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -8,6 +9,9 @@ const queryClient = new QueryClient({
retryDelay: 1000,
retry(failureCount, error) {
console.error(error)
if (error instanceof FetchError && ignoreStatuses.has(error.statusCode!)) {
return false
}
if (error instanceof FetchError && error.statusCode === undefined) {
return false
}

View File

@@ -126,7 +126,7 @@ function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
>
<div className="bg-fill/15 relative flex h-40 w-full items-end overflow-hidden rounded-md">
<div
className="bg-accent/70 group-hover:bg-accent absolute bottom-0 left-0 right-0 rounded-md transition-colors duration-200"
className="bg-accent/70 group-hover:bg-accent absolute right-0 bottom-0 left-0 rounded-md transition-colors duration-200"
style={{ height: `${heightPercent}%` }}
/>
</div>
@@ -295,7 +295,7 @@ export function DashboardAnalytics() {
) : data?.uploadTrends?.length ? (
<>
{uploadTrendStats ? (
<div className="border-fill/20 mt-5 grid gap-3 rounded-lg border bg-fill/5 p-4 text-sm">
<div className="border-fill/20 bg-fill/5 mt-5 grid gap-3 rounded-lg border p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">
@@ -364,7 +364,7 @@ export function DashboardAnalytics() {
return (
<>
<div className="border-fill/20 mt-5 grid gap-3 rounded-lg border bg-fill/5 p-4 text-sm">
<div className="border-fill/20 bg-fill/5 mt-5 grid gap-3 rounded-lg border p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">{formatBytes(storageUsage.totalBytes)}</span>

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

@@ -1,3 +1,5 @@
import { FetchError } from 'ofetch'
import { coreApi } from '~/lib/api-client'
import type { BetterAuthSession, BetterAuthUser } from '../types'
@@ -5,12 +7,28 @@ import type { BetterAuthSession, BetterAuthUser } from '../types'
export type SessionResponse = {
user: BetterAuthUser
session: BetterAuthSession
source?: 'global' | 'tenant'
}
export const AUTH_SESSION_QUERY_KEY = ['auth', 'session'] as const
export async function fetchSession() {
return await coreApi<SessionResponse>('/auth/session', {
method: 'GET',
})
const fallbackStatus = new Set([401, 403, 404])
try {
const tenantSession = await coreApi<SessionResponse>('/tenant-auth/session', { method: 'GET' })
return { ...tenantSession, source: tenantSession.source ?? 'tenant' }
} catch (error) {
if (!(error instanceof FetchError)) {
throw error
}
const status = error.statusCode ?? error.response?.status ?? null
if (!status || !fallbackStatus.has(status)) {
throw error
}
}
const globalSession = await coreApi<SessionResponse>('/auth/session', { method: 'GET' })
return { ...globalSession, source: globalSession.source ?? 'global' }
}

View File

@@ -1,16 +1,66 @@
import { createAuthClient } from 'better-auth/react'
import { FetchError } from 'ofetch'
const apiBase = `${import.meta.env.VITE_APP_API_BASE?.replace(/\/$/, '') || '/api'}/auth`
const apiBase = import.meta.env.VITE_APP_API_BASE?.replace(/\/$/, '') || '/api'
const resolvedApiBase = resolveUrl(apiBase)
export const authClient = createAuthClient({
baseURL: resolvedApiBase,
const globalAuthBase = resolveUrl(`${apiBase}/auth`)
const tenantAuthBase = resolveUrl(`${apiBase}/tenant-auth`)
const commonOptions = {
fetchOptions: {
credentials: 'include',
credentials: 'include' as const,
},
}
export const globalAuthClient = createAuthClient({
baseURL: globalAuthBase,
...commonOptions,
})
export const { signIn, signOut, useSession } = authClient
export const tenantAuthClient = createAuthClient({
baseURL: tenantAuthBase,
...commonOptions,
})
const { useSession } = globalAuthClient
const { signIn: globalRawSignIn, signOut: globalRawSignOut } = globalAuthClient
const { signIn: tenantRawSignIn, signOut: tenantRawSignOut } = tenantAuthClient
export { useSession }
export const signInGlobal = globalRawSignIn
export const signInTenant = tenantRawSignIn
export const signOutGlobal = globalRawSignOut
export const signOutTenant = tenantRawSignOut
export async function signOutBySource(source?: 'global' | 'tenant') {
const targets: Array<'global' | 'tenant'> = source ? [source] : ['tenant', 'global']
let lastError: unknown = null
const recoverableStatuses = new Set([401, 403, 404])
for (const target of targets) {
try {
if (target === 'tenant') {
await tenantAuthClient.signOut()
} else {
await globalAuthClient.signOut()
}
} catch (error) {
if (error instanceof FetchError) {
const status = error.statusCode ?? error.response?.status ?? null
if (status && recoverableStatuses.has(status)) {
continue
}
}
lastError = error
}
}
if (lastError) {
throw lastError
}
}
function resolveUrl(url: string): string {
if (url.startsWith('/')) {

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

@@ -6,7 +6,7 @@ import { useNavigate } from 'react-router'
import { useSetAuthUser } from '~/atoms/auth'
import { AUTH_SESSION_QUERY_KEY, fetchSession } from '~/modules/auth/api/session'
import { signIn } from '../auth-client'
import { signInGlobal, signInTenant } from '../auth-client'
export interface LoginRequest {
email: string
@@ -22,15 +22,42 @@ export function useLogin() {
const loginMutation = useMutation({
mutationFn: async (data: LoginRequest) => {
// Use Better Auth endpoint via backend controller
// Backend forwards headers to Better Auth and returns the Response
// We don't need the response body here; cookies are set via Set-Cookie
await signIn.email({
const rememberMe = data.rememberMe ?? true
const fallbackStatuses = new Set([400, 401, 403, 404])
const attemptTenant = async () => {
try {
await signInTenant.email({
email: data.email,
password: data.password,
rememberMe,
})
return await queryClient.fetchQuery({
queryKey: AUTH_SESSION_QUERY_KEY,
queryFn: fetchSession,
})
} catch (error) {
if (error instanceof FetchError) {
const status = error.statusCode ?? error.response?.status ?? null
if (status && fallbackStatuses.has(status)) {
return null
}
}
throw error
}
}
const tenantSession = await attemptTenant()
if (tenantSession) {
return tenantSession
}
await signInGlobal.email({
email: data.email,
password: data.password,
rememberMe: data.rememberMe ?? true,
rememberMe,
})
// After login, refetch session
return await queryClient.fetchQuery({
queryKey: AUTH_SESSION_QUERY_KEY,
queryFn: fetchSession,
@@ -47,13 +74,14 @@ export function useLogin() {
onError: (error: Error) => {
if (error instanceof FetchError) {
const status = error.statusCode ?? error.response?.status
const serverMessage = (error.data as any)?.message
switch (status) {
case 401: {
setErrorMessage('Invalid email or password')
setErrorMessage(serverMessage || 'Invalid email or password')
break
}
case 403: {
setErrorMessage('Access denied')
setErrorMessage(serverMessage || 'Access denied')
break
}
case 429: {
@@ -61,7 +89,7 @@ export function useLogin() {
break
}
default: {
setErrorMessage((error.data as any)?.message || error.message || 'Login failed. Please try again')
setErrorMessage(serverMessage || error.message || 'Login failed. Please try again')
}
}
} else {

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

@@ -1,4 +1,4 @@
export type BetterAuthUserRole = 'user' | 'admin' | 'superadmin'
export type BetterAuthUserRole = 'guest' | 'user' | 'admin' | 'superadmin'
export interface BetterAuthUser {
id: string
@@ -22,4 +22,5 @@ export interface BetterAuthSession {
export interface AuthState {
user: BetterAuthUser
session: BetterAuthSession
source?: 'global' | 'tenant'
}

View File

@@ -15,7 +15,6 @@ export type OnboardingInitPayload = {
tenant: {
name: string
slug: string
domain?: string
}
settings?: Array<{
key: OnboardingSettingKey

View File

@@ -36,8 +36,6 @@ export const OnboardingWizard: FC = () => {
errors,
updateTenantName,
updateTenantSlug,
updateTenantDomain,
updateAdminField,
toggleSetting,
updateSettingValue,
@@ -122,13 +120,7 @@ export const OnboardingWizard: FC = () => {
const stepContent: Record<typeof currentStep.id, ReactNode> = {
welcome: <WelcomeStep />,
tenant: (
<TenantStep
tenant={tenant}
errors={errors}
onNameChange={updateTenantName}
onSlugChange={updateTenantSlug}
onDomainChange={updateTenantDomain}
/>
<TenantStep tenant={tenant} errors={errors} onNameChange={updateTenantName} onSlugChange={updateTenantSlug} />
),
admin: <AdminStep admin={admin} errors={errors} onChange={updateAdminField} />,
settings: (

View File

@@ -39,10 +39,6 @@ export const ReviewStep: FC<ReviewStepProps> = ({
<dt className="text-text font-semibold">Slug</dt>
<dd className="mt-1">{tenant.slug || '—'}</dd>
</div>
<div>
<dt className="text-text font-semibold">Domain</dt>
<dd className="mt-1">{tenant.domain || 'Not configured'}</dd>
</div>
</dl>
</div>

View File

@@ -8,10 +8,9 @@ type TenantStepProps = {
errors: OnboardingErrors
onNameChange: (value: string) => void
onSlugChange: (value: string) => void
onDomainChange: (value: string) => void
}
export const TenantStep: FC<TenantStepProps> = ({ tenant, errors, onNameChange, onSlugChange, onDomainChange }) => (
export const TenantStep: FC<TenantStepProps> = ({ tenant, errors, onNameChange, onSlugChange }) => (
<form className="space-y-6" onSubmit={(event) => event.preventDefault()}>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
@@ -42,21 +41,5 @@ export const TenantStep: FC<TenantStepProps> = ({ tenant, errors, onNameChange,
<FormError>{errors['tenant.slug']}</FormError>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-domain">Custom domain (optional)</Label>
<Input
id="tenant-domain"
value={tenant.domain}
onInput={(event) => onDomainChange(event.currentTarget.value)}
placeholder="gallery.afilmory.art"
error={!!errors['tenant.domain']}
autoComplete="off"
/>
<FormError>{errors['tenant.domain']}</FormError>
<p className="text-text-tertiary text-xs">
Domains enable automatic routing for tenant-specific dashboards. Configure DNS separately after initialization.
</p>
</div>
</form>
)

View File

@@ -139,7 +139,7 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
{
id: 'tenant',
title: 'Tenant Profile',
description: 'Name your workspace and optional domain.',
description: 'Name your workspace and choose a slug.',
},
{
id: 'admin',

View File

@@ -17,7 +17,6 @@ export function useOnboardingWizard() {
const [tenant, setTenant] = useState<TenantFormState>({
name: '',
slug: '',
domain: '',
})
const [slugLocked, setSlugLocked] = useState(false)
const [admin, setAdmin] = useState<AdminFormState>({
@@ -95,14 +94,6 @@ export function useOnboardingWizard() {
setFieldError('tenant.slug', null)
}
const domain = tenant.domain.trim()
if (domain && !/^[a-z0-9.-]+$/.test(domain)) {
setFieldError('tenant.domain', 'Use lowercase letters, numbers, dot, or hyphen')
valid = false
} else {
setFieldError('tenant.domain', null)
}
return valid
}
@@ -190,12 +181,10 @@ export function useOnboardingWizard() {
}
const submitInitialization = () => {
const trimmedDomain = tenant.domain.trim()
const payload: OnboardingInitPayload = {
tenant: {
name: tenant.name.trim(),
slug: tenant.slug.trim(),
...(trimmedDomain ? { domain: trimmedDomain } : {}),
},
admin: {
name: admin.name.trim(),
@@ -258,11 +247,6 @@ export function useOnboardingWizard() {
setFieldError('tenant.slug', null)
}
const updateTenantDomain = (value: string) => {
setTenant((prev) => ({ ...prev, domain: value }))
setFieldError('tenant.domain', null)
}
const updateAdminField = (field: keyof AdminFormState, value: string) => {
setAdmin((prev) => ({ ...prev, [field]: value }))
setFieldError(`admin.${field}`, null)
@@ -316,8 +300,6 @@ export function useOnboardingWizard() {
errors,
updateTenantName,
updateTenantSlug,
updateTenantDomain,
updateAdminField,
toggleSetting,
updateSettingValue,

View File

@@ -3,7 +3,6 @@ import type { OnboardingSettingKey } from './constants'
export type TenantFormState = {
name: string
slug: string
domain: string
}
export type AdminFormState = {

View File

@@ -4,7 +4,7 @@ import { clsxm, Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useMemo } from 'react'
const formatBytes = (bytes: number) => {
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) return '未知大小'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
@@ -29,13 +29,13 @@ const IMAGE_EXTENSIONS = new Set([
'dng',
])
const getFileExtension = (name: string) => {
function getFileExtension(name: string) {
const normalized = name.toLowerCase()
const lastDotIndex = normalized.lastIndexOf('.')
return lastDotIndex === -1 ? '' : normalized.slice(lastDotIndex + 1)
}
const getBaseName = (name: string) => {
function getBaseName(name: string) {
const normalized = name.toLowerCase()
const lastDotIndex = normalized.lastIndexOf('.')
return lastDotIndex === -1 ? normalized : normalized.slice(0, lastDotIndex)

View File

@@ -1,5 +1,5 @@
import { Button } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { DEFAULT_BASE_DOMAIN, Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -19,11 +19,24 @@ type FormState = Record<SuperAdminSettingField, SchemaFormValue>
const BOOLEAN_FIELDS = new Set<SuperAdminSettingField>(['allowRegistration', 'localProviderEnabled'])
function asString(value: SchemaFormValue): string {
if (value === undefined || value === null) {
return ''
}
if (typeof value === 'string') {
return value
}
return String(value)
}
function toFormState(settings: SuperAdminSettings): FormState {
return {
allowRegistration: settings.allowRegistration,
localProviderEnabled: settings.localProviderEnabled,
maxRegistrableUsers: settings.maxRegistrableUsers === null ? '' : String(settings.maxRegistrableUsers),
baseDomain: (settings.baseDomain ?? DEFAULT_BASE_DOMAIN) || DEFAULT_BASE_DOMAIN,
}
}
@@ -39,7 +52,8 @@ function areFormStatesEqual(left: FormState | null, right: FormState | null): bo
return (
left.allowRegistration === right.allowRegistration &&
left.localProviderEnabled === right.localProviderEnabled &&
left.maxRegistrableUsers === right.maxRegistrableUsers
left.maxRegistrableUsers === right.maxRegistrableUsers &&
asString(left.baseDomain).trim() === asString(right.baseDomain).trim()
)
}
@@ -60,6 +74,7 @@ type PossiblySnakeCaseSettings = Partial<
allow_registration: boolean
local_provider_enabled: boolean
max_registrable_users: number | null
base_domain: string
}
>
@@ -86,6 +101,7 @@ function normalizeServerSettings(input: PossiblySnakeCaseSettings | null): Super
allowRegistration: input.allowRegistration ?? false,
localProviderEnabled: input.localProviderEnabled ?? false,
maxRegistrableUsers: coerceMaxUsers(input.maxRegistrableUsers),
baseDomain: ((input.baseDomain ?? DEFAULT_BASE_DOMAIN) || DEFAULT_BASE_DOMAIN).toString(),
}
}
@@ -94,6 +110,7 @@ function normalizeServerSettings(input: PossiblySnakeCaseSettings | null): Super
allowRegistration: input.allow_registration ?? false,
localProviderEnabled: input.local_provider_enabled ?? false,
maxRegistrableUsers: coerceMaxUsers(input.max_registrable_users),
baseDomain: ((input.base_domain ?? DEFAULT_BASE_DOMAIN) || DEFAULT_BASE_DOMAIN).toString(),
}
}
@@ -212,6 +229,11 @@ export function SuperAdminSettingsForm() {
}
}
if (asString(formState.baseDomain).trim() !== asString(initialState.baseDomain).trim()) {
const value = asString(formState.baseDomain).trim()
payload.baseDomain = (value.length > 0 ? value : DEFAULT_BASE_DOMAIN).toLowerCase()
}
return Object.keys(payload).length > 0 ? payload : null
}

View File

@@ -4,6 +4,7 @@ export interface SuperAdminSettings {
allowRegistration: boolean
localProviderEnabled: boolean
maxRegistrableUsers: number | null
baseDomain: string
}
export type SuperAdminSettingField = keyof SuperAdminSettings
@@ -32,4 +33,5 @@ export type UpdateSuperAdminSettingsPayload = Partial<{
allowRegistration: boolean
localProviderEnabled: boolean
maxRegistrableUsers: number | null
baseDomain: string
}>

View File

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

View File

@@ -49,7 +49,24 @@ export default defineConfig({
'/api': {
target: API_TARGET,
changeOrigin: true,
xfwd: true,
// keep path as-is so /api -> backend /api
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
const originalHost = req.headers.host
if (originalHost) {
const normalizedHost = Array.isArray(originalHost) ? originalHost[0] : originalHost
// Preserve SPA host for tenant resolution regardless of changeOrigin behaviour
proxyReq.setHeader('host', normalizedHost)
proxyReq.setHeader('x-forwarded-host', normalizedHost)
}
const originHeader = req.headers.origin
if (originHeader) {
proxyReq.setHeader('origin', Array.isArray(originHeader) ? originHeader[0] : originHeader)
}
})
},
},
},
},

View File

@@ -12,6 +12,7 @@ export default defineConfig(
},
},
rules: {
'unicorn/no-abusive-eslint-disable': 0,
'unicorn/no-useless-undefined': 0,
'@typescript-eslint/no-unsafe-function-type': 0,
},

View File

@@ -0,0 +1,56 @@
CREATE TABLE "tenant_auth_account" (
"id" text PRIMARY KEY NOT NULL,
"tenant_id" text NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tenant_auth_session" (
"id" text PRIMARY KEY NOT NULL,
"tenant_id" text NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "tenant_auth_session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "tenant_auth_user" (
"id" text PRIMARY KEY NOT NULL,
"tenant_id" text NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"role" text DEFAULT 'guest' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"two_factor_enabled" boolean DEFAULT false NOT NULL,
"username" text,
"display_username" text,
"banned" boolean DEFAULT false NOT NULL,
"ban_reason" text,
"ban_expires_at" timestamp,
CONSTRAINT "uq_tenant_auth_user_tenant_email" UNIQUE("tenant_id","email")
);
--> statement-breakpoint
ALTER TABLE "tenant_auth_account" ADD CONSTRAINT "tenant_auth_account_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tenant_auth_account" ADD CONSTRAINT "tenant_auth_account_user_id_tenant_auth_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."tenant_auth_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tenant_auth_session" ADD CONSTRAINT "tenant_auth_session_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tenant_auth_session" ADD CONSTRAINT "tenant_auth_session_user_id_tenant_auth_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."tenant_auth_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tenant_auth_user" ADD CONSTRAINT "tenant_auth_user_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_reactions_tenant_ref_key" ON "reactions" USING btree ("tenant_id","ref_key");--> statement-breakpoint
ALTER TABLE "reactions" ADD CONSTRAINT "uq_reactions_tenant_ref_key" UNIQUE("tenant_id","ref_key");

View File

@@ -0,0 +1 @@
ALTER TABLE "tenant" DROP COLUMN IF EXISTS "is_primary";

View File

@@ -0,0 +1,2 @@
ALTER TABLE "tenant" DROP COLUMN IF EXISTS "primary_domain";
DROP TABLE IF EXISTS "tenant_domain";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,27 @@
"when": 1761917402844,
"tag": "0003_lovely_wendell_rand",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1762176641407,
"tag": "0004_sleepy_avengers",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1762350000000,
"tag": "0005_remove_primary_tenant",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1762353600000,
"tag": "0006_remove_tenant_domains",
"breakpoints": true
}
]
}

View File

@@ -50,29 +50,12 @@ export const tenants = pgTable(
slug: text('slug').notNull(),
name: text('name').notNull(),
status: tenantStatusEnum('status').notNull().default('inactive'),
primaryDomain: text('primary_domain'),
isPrimary: boolean('is_primary').notNull().default(false),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
},
(t) => [unique('uq_tenant_slug').on(t.slug)],
)
export const tenantDomains = pgTable(
'tenant_domain',
{
id: snowflakeId,
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
domain: text('domain').notNull(),
isPrimary: boolean('is_primary').notNull().default(false),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
},
(t) => [unique('uq_tenant_domain_domain').on(t.domain)],
)
// Custom users table (Better Auth: user)
export const authUsers = pgTable('auth_user', {
id: text('id').primaryKey(),
@@ -126,6 +109,67 @@ export const authAccounts = pgTable('auth_account', {
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
})
export const tenantAuthUsers = pgTable(
'tenant_auth_user',
{
id: text('id').primaryKey(),
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
email: text('email').notNull(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
role: text('role').default('guest').notNull(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
twoFactorEnabled: boolean('two_factor_enabled').default(false).notNull(),
username: text('username'),
displayUsername: text('display_username'),
banned: boolean('banned').default(false).notNull(),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires_at', { mode: 'string' }),
},
(t) => [unique('uq_tenant_auth_user_tenant_email').on(t.tenantId, t.email)],
)
export const tenantAuthSessions = pgTable('tenant_auth_session', {
id: text('id').primaryKey(),
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { mode: 'string' }).notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => tenantAuthUsers.id, { onDelete: 'cascade' }),
})
export const tenantAuthAccounts = pgTable('tenant_auth_account', {
id: text('id').primaryKey(),
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => tenantAuthUsers.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at', { mode: 'string' }),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { mode: 'string' }),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
})
export const settings = pgTable(
'settings',
{
@@ -206,10 +250,12 @@ export const photoAssets = pgTable(
export const dbSchema = {
tenants,
tenantDomains,
authUsers,
authSessions,
authAccounts,
tenantAuthUsers,
tenantAuthSessions,
tenantAuthAccounts,
settings,
systemSettings,
reactions,

View File

@@ -41,6 +41,7 @@ const hyobanConfig = await defineConfig(
},
},
rules: {
'unicorn/no-abusive-eslint-disable': 0,
'@typescript-eslint/triple-slash-reference': 0,
'unicorn/prefer-math-trunc': 'off',
'unicorn/no-static-only-class': 'off',

View File

@@ -3,4 +3,5 @@ export * from './cn'
export * from './semaphore'
export * from './spring'
export * from './storage-provider'
export * from './tenant'
export * from './u8array'

View File

@@ -0,0 +1,12 @@
const RESERVED_SLUGS = ['admin', 'docs', 'support', 'status', 'api', 'assets', 'static', 'www'] as const
export const RESERVED_TENANT_SLUGS = RESERVED_SLUGS
export type ReservedTenantSlug = (typeof RESERVED_TENANT_SLUGS)[number]
export function isTenantSlugReserved(slug: string): boolean {
const normalized = slug.trim().toLowerCase()
return RESERVED_TENANT_SLUGS.includes(normalized as ReservedTenantSlug)
}
export const DEFAULT_BASE_DOMAIN = 'afilmory.art'