mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
refactor(server): tanant auth (#148)
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
24
be/apps/core/src/interceptors/tenant-resolver.decorator.ts
Normal file
24
be/apps/core/src/interceptors/tenant-resolver.decorator.ts
Normal 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
|
||||
}
|
||||
54
be/apps/core/src/interceptors/tenant-resolver.interceptor.ts
Normal file
54
be/apps/core/src/interceptors/tenant-resolver.interceptor.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
162
be/apps/core/src/modules/auth/auth-registration.service.ts
Normal file
162
be/apps/core/src/modules/auth/auth-registration.service.ts
Normal 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: '无法生成唯一的租户标识,请尝试使用不同的名称',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 ?? []),
|
||||
}),
|
||||
|
||||
@@ -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})`)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '至少需要更新一项设置',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface SuperAdminSettings {
|
||||
allowRegistration: boolean
|
||||
maxRegistrableUsers: number | null
|
||||
localProviderEnabled: boolean
|
||||
baseDomain: string
|
||||
}
|
||||
|
||||
export type SuperAdminSettingValueMap = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
76
be/apps/core/src/modules/tenant-auth/tenant-auth.config.ts
Normal file
76
be/apps/core/src/modules/tenant-auth/tenant-auth.config.ts
Normal 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: {} })
|
||||
}
|
||||
}
|
||||
}
|
||||
124
be/apps/core/src/modules/tenant-auth/tenant-auth.controller.ts
Normal file
124
be/apps/core/src/modules/tenant-auth/tenant-auth.controller.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
14
be/apps/core/src/modules/tenant-auth/tenant-auth.module.ts
Normal file
14
be/apps/core/src/modules/tenant-auth/tenant-auth.module.ts
Normal 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 {}
|
||||
135
be/apps/core/src/modules/tenant-auth/tenant-auth.provider.ts
Normal file
135
be/apps/core/src/modules/tenant-auth/tenant-auth.provider.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
26
be/apps/dashboard/src/modules/auth/api/registerTenant.ts
Normal file
26
be/apps/dashboard/src/modules/auth/api/registerTenant.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { FetchResponse } from 'ofetch'
|
||||
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
|
||||
export interface RegisterTenantAccountPayload {
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface RegisterTenantPayload {
|
||||
account: RegisterTenantAccountPayload
|
||||
tenant: {
|
||||
name: string
|
||||
slug: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type RegisterTenantResult = FetchResponse<unknown>
|
||||
|
||||
export async function registerTenant(payload: RegisterTenantPayload): Promise<RegisterTenantResult> {
|
||||
return await coreApi.raw('/auth/tenants/sign-up', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
@@ -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('/')) {
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
import { Button, Checkbox, FormError, Input, Label, ScrollArea } from '@afilmory/ui'
|
||||
import { cx, Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import type { FC, KeyboardEvent } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { useRegisterTenant } from '~/modules/auth/hooks/useRegisterTenant'
|
||||
import type { TenantRegistrationFormState } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { LinearBorderContainer } from '~/modules/onboarding/components/LinearBorderContainer'
|
||||
|
||||
const REGISTRATION_STEPS = [
|
||||
{
|
||||
id: 'workspace',
|
||||
title: 'Workspace details',
|
||||
description: 'Give your workspace a recognizable name and choose a slug for tenant URLs.',
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
title: 'Administrator account',
|
||||
description: 'Set up the primary administrator who will manage the workspace after creation.',
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
title: 'Review & confirm',
|
||||
description: 'Verify everything looks right and accept the terms before provisioning the workspace.',
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: 'workspace' | 'admin' | 'review'
|
||||
title: string
|
||||
description: string
|
||||
}>
|
||||
|
||||
const STEP_FIELDS: Record<(typeof REGISTRATION_STEPS)[number]['id'], Array<keyof TenantRegistrationFormState>> = {
|
||||
workspace: ['tenantName', 'tenantSlug'],
|
||||
admin: ['accountName', 'email', 'password', 'confirmPassword'],
|
||||
review: ['termsAccepted'],
|
||||
}
|
||||
|
||||
const progressForStep = (index: number) => Math.round((index / (REGISTRATION_STEPS.length - 1 || 1)) * 100)
|
||||
|
||||
type SidebarProps = {
|
||||
currentStepIndex: number
|
||||
canNavigateTo: (index: number) => boolean
|
||||
onStepSelect: (index: number) => void
|
||||
}
|
||||
|
||||
const RegistrationSidebar: FC<SidebarProps> = ({ currentStepIndex, canNavigateTo, onStepSelect }) => (
|
||||
<aside className="hidden min-h-full flex-col gap-6 p-6 lg:flex">
|
||||
<div>
|
||||
<p className="text-accent text-xs font-medium">Workspace Setup</p>
|
||||
<h2 className="text-text mt-2 text-base font-semibold">Create your tenant</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1">
|
||||
{REGISTRATION_STEPS.map((step, index) => {
|
||||
const status: 'done' | 'current' | 'pending' =
|
||||
index < currentStepIndex ? 'done' : index === currentStepIndex ? 'current' : 'pending'
|
||||
const isLast = index === REGISTRATION_STEPS.length - 1
|
||||
const isClickable = canNavigateTo(index)
|
||||
|
||||
return (
|
||||
<div key={step.id} className="relative flex gap-3">
|
||||
{!isLast && (
|
||||
<div className="absolute top-7 bottom-0 left-[13px] w-[1.5px]">
|
||||
{status === 'done' && <div className="bg-accent h-full w-full" />}
|
||||
{status === 'current' && (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, var(--color-accent) 0%, var(--color-accent) 35%, color-mix(in srgb, var(--color-text) 15%, transparent) 100%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{status === 'pending' && <div className="bg-text/15 h-full w-full" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
'group relative flex w-full items-start gap-3 pb-6 text-left transition-all duration-200',
|
||||
isClickable ? 'cursor-pointer' : 'cursor-default',
|
||||
!isClickable && 'opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isClickable) onStepSelect(index)
|
||||
}}
|
||||
disabled={!isClickable}
|
||||
>
|
||||
<div className="relative z-10 shrink-0 pt-0.5">
|
||||
<div
|
||||
className={cx(
|
||||
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-all duration-200',
|
||||
status === 'done' && 'bg-accent text-white ring-4 ring-accent/10',
|
||||
status === 'current' && 'bg-accent text-white ring-4 ring-accent/25',
|
||||
status === 'pending' && 'border-[1.5px] border-text/20 bg-background text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{status === 'done' ? <i className="i-mingcute-check-fill text-sm" /> : <span>{index + 1}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p
|
||||
className={cx(
|
||||
'text-sm font-medium transition-colors duration-200',
|
||||
status === 'done' && 'text-text',
|
||||
status === 'current' && 'text-accent',
|
||||
status === 'pending' && 'text-text-tertiary',
|
||||
isClickable && status !== 'current' && 'group-hover:text-text',
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
<p
|
||||
className={cx(
|
||||
'mt-0.5 text-xs transition-colors duration-200',
|
||||
status === 'done' && 'text-text-secondary',
|
||||
status === 'current' && 'text-text-secondary',
|
||||
status === 'pending' && 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<div className="via-text/20 mb-4 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
<div className="text-text-tertiary mb-2 flex items-center justify-between text-xs">
|
||||
<span>Progress</span>
|
||||
<span className="text-accent font-medium">{progressForStep(currentStepIndex)}%</span>
|
||||
</div>
|
||||
<div className="bg-fill-tertiary relative h-1.5 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-accent absolute top-0 left-0 h-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${progressForStep(currentStepIndex)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
type HeaderProps = {
|
||||
currentStepIndex: number
|
||||
}
|
||||
|
||||
const RegistrationHeader: FC<HeaderProps> = ({ currentStepIndex }) => {
|
||||
const step = REGISTRATION_STEPS[currentStepIndex]
|
||||
return (
|
||||
<header className="p-8 pb-6">
|
||||
<div className="bg-accent/10 text-accent inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium">
|
||||
Step {currentStepIndex + 1} of {REGISTRATION_STEPS.length}
|
||||
</div>
|
||||
<h1 className="text-text mt-4 text-3xl font-bold">{step.title}</h1>
|
||||
<p className="text-text-secondary mt-2 max-w-2xl text-sm">{step.description}</p>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
type FooterProps = {
|
||||
disableBack: boolean
|
||||
isSubmitting: boolean
|
||||
isLastStep: boolean
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
const RegistrationFooter: FC<FooterProps> = ({ disableBack, isSubmitting, isLastStep, onBack, onNext }) => (
|
||||
<footer className="flex flex-col gap-3 p-8 pt-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
{!disableBack ? (
|
||||
<div className="text-text-tertiary text-xs">
|
||||
Adjustments are always possible—use the sidebar or go back to modify earlier details.
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{!disableBack && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
className="text-text-secondary hover:text-text hover:bg-fill/50 min-w-[140px]"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="primary" size="md" className="min-w-40" onClick={onNext} isLoading={isSubmitting}>
|
||||
{isLastStep ? 'Create workspace' : 'Continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
type StepCommonProps = {
|
||||
values: TenantRegistrationFormState
|
||||
errors: Partial<Record<keyof TenantRegistrationFormState, string>>
|
||||
onFieldChange: <Field extends keyof TenantRegistrationFormState>(
|
||||
field: Field,
|
||||
value: TenantRegistrationFormState[Field],
|
||||
) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const WorkspaceStep: FC<StepCommonProps> = ({ values, errors, onFieldChange, isLoading }) => (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Workspace basics</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
This information appears in navigation, invitations, and other tenant-facing areas.
|
||||
</p>
|
||||
</section>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-name">Workspace name</Label>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
value={values.tenantName}
|
||||
onChange={(event) => onFieldChange('tenantName', event.currentTarget.value)}
|
||||
placeholder="Acme Studio"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.tenantName)}
|
||||
autoComplete="organization"
|
||||
/>
|
||||
<FormError>{errors.tenantName}</FormError>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug">Workspace slug</Label>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
value={values.tenantSlug}
|
||||
onChange={(event) => onFieldChange('tenantSlug', event.currentTarget.value)}
|
||||
placeholder="acme"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.tenantSlug)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
Lowercase letters, numbers, and hyphen are allowed. We'll ensure the slug is unique.
|
||||
</p>
|
||||
<FormError>{errors.tenantSlug}</FormError>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AdminStep: FC<StepCommonProps> = ({ values, errors, onFieldChange, isLoading }) => (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Administrator</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
The first user becomes the workspace administrator and can invite additional members later.
|
||||
</p>
|
||||
</section>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-name">Full name</Label>
|
||||
<Input
|
||||
id="account-name"
|
||||
value={values.accountName}
|
||||
onChange={(event) => onFieldChange('accountName', event.currentTarget.value)}
|
||||
placeholder="Jane Doe"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.accountName)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
<FormError>{errors.accountName}</FormError>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-email">Work email</Label>
|
||||
<Input
|
||||
id="account-email"
|
||||
type="email"
|
||||
value={values.email}
|
||||
onChange={(event) => onFieldChange('email', event.currentTarget.value)}
|
||||
placeholder="jane@acme.studio"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.email)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
<FormError>{errors.email}</FormError>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-password">Password</Label>
|
||||
<Input
|
||||
id="account-password"
|
||||
type="password"
|
||||
value={values.password}
|
||||
onChange={(event) => onFieldChange('password', event.currentTarget.value)}
|
||||
placeholder="Create a strong password"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.password)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<FormError>{errors.password}</FormError>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-confirm-password">Confirm password</Label>
|
||||
<Input
|
||||
id="account-confirm-password"
|
||||
type="password"
|
||||
value={values.confirmPassword}
|
||||
onChange={(event) => onFieldChange('confirmPassword', event.currentTarget.value)}
|
||||
placeholder="Repeat your password"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<FormError>{errors.confirmPassword}</FormError>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
We recommend using a secure password manager to store credentials for critical roles like the administrator.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
type ReviewStepProps = Omit<StepCommonProps, 'onFieldChange'> & {
|
||||
onToggleTerms: (value: boolean) => void
|
||||
serverError: string | null
|
||||
}
|
||||
|
||||
const ReviewStep: FC<ReviewStepProps> = ({ values, errors, onToggleTerms, isLoading, serverError }) => (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Confirm workspace configuration</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Double-check the details below. You can go back to make adjustments before creating the workspace.
|
||||
</p>
|
||||
</section>
|
||||
<dl className="bg-fill/40 border border-white/5 grid gap-x-6 gap-y-4 rounded-2xl p-6 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace name</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.tenantName || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace slug</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.tenantSlug || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator name</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.accountName || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator email</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.email || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{serverError && (
|
||||
<m.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={Spring.presets.snappy}
|
||||
className="border-red/60 bg-red/10 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<p className="text-red text-sm">{serverError}</p>
|
||||
</m.div>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-text text-base font-semibold">Policies</h3>
|
||||
<p className="text-text-tertiary text-sm">
|
||||
Creating a workspace means you agree to comply with our usage guidelines and privacy practices.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label className="text-text flex items-center gap-3 text-sm">
|
||||
<Checkbox
|
||||
checked={values.termsAccepted}
|
||||
onCheckedChange={(checked) => onToggleTerms(checked === true)}
|
||||
disabled={isLoading}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-text-secondary">
|
||||
I agree to the{' '}
|
||||
<a href="/terms" target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href="/privacy" target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
<FormError>{errors.termsAccepted}</FormError>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const RegistrationWizard: FC = () => {
|
||||
const { values, errors, updateValue, validate, getFieldError } = useRegistrationForm()
|
||||
const { registerTenant, isLoading, error, clearError } = useRegisterTenant()
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
||||
const [maxVisitedIndex, setMaxVisitedIndex] = useState(0)
|
||||
const contentRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const root = contentRef.current
|
||||
if (!root) return
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const selector = [
|
||||
'input:not([type="hidden"]):not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
const candidates = Array.from(root.querySelectorAll<HTMLElement>(selector))
|
||||
const firstVisible = candidates.find((el) => {
|
||||
if (el.getAttribute('aria-hidden') === 'true') return false
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return false
|
||||
if ((el as HTMLInputElement).disabled) return false
|
||||
return true
|
||||
})
|
||||
|
||||
firstVisible?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [currentStepIndex])
|
||||
|
||||
const canNavigateTo = useCallback((index: number) => index <= maxVisitedIndex, [maxVisitedIndex])
|
||||
|
||||
const jumpToStep = useCallback(
|
||||
(index: number) => {
|
||||
if (isLoading) return
|
||||
if (index === currentStepIndex) return
|
||||
if (!canNavigateTo(index)) return
|
||||
if (error) clearError()
|
||||
setCurrentStepIndex(index)
|
||||
setMaxVisitedIndex((prev) => Math.max(prev, index))
|
||||
},
|
||||
[canNavigateTo, clearError, currentStepIndex, error, isLoading],
|
||||
)
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
<Field extends keyof TenantRegistrationFormState>(field: Field, value: TenantRegistrationFormState[Field]) => {
|
||||
updateValue(field, value)
|
||||
if (error) clearError()
|
||||
},
|
||||
[clearError, error, updateValue],
|
||||
)
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (isLoading) return
|
||||
if (currentStepIndex === 0) return
|
||||
if (error) clearError()
|
||||
setCurrentStepIndex((prev) => Math.max(0, prev - 1))
|
||||
}, [clearError, currentStepIndex, error, isLoading])
|
||||
|
||||
const focusFirstInvalidStep = useCallback(() => {
|
||||
const invalidStepIndex = REGISTRATION_STEPS.findIndex((step) =>
|
||||
STEP_FIELDS[step.id].some((field) => Boolean(getFieldError(field))),
|
||||
)
|
||||
|
||||
if (invalidStepIndex !== -1 && invalidStepIndex !== currentStepIndex) {
|
||||
setCurrentStepIndex(invalidStepIndex)
|
||||
setMaxVisitedIndex((prev) => Math.max(prev, invalidStepIndex))
|
||||
}
|
||||
}, [currentStepIndex, getFieldError])
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (isLoading) return
|
||||
|
||||
const step = REGISTRATION_STEPS[currentStepIndex]
|
||||
const fields = STEP_FIELDS[step.id]
|
||||
|
||||
const isStepValid = validate(fields)
|
||||
if (!isStepValid) {
|
||||
focusFirstInvalidStep()
|
||||
return
|
||||
}
|
||||
|
||||
if (step.id === 'review') {
|
||||
const formIsValid = validate()
|
||||
if (!formIsValid) {
|
||||
focusFirstInvalidStep()
|
||||
return
|
||||
}
|
||||
|
||||
if (error) clearError()
|
||||
|
||||
registerTenant({
|
||||
tenantName: values.tenantName,
|
||||
tenantSlug: values.tenantSlug,
|
||||
accountName: values.accountName,
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentStepIndex((prev) => {
|
||||
const nextIndex = Math.min(REGISTRATION_STEPS.length - 1, prev + 1)
|
||||
setMaxVisitedIndex((visited) => Math.max(visited, nextIndex))
|
||||
return nextIndex
|
||||
})
|
||||
}, [
|
||||
clearError,
|
||||
currentStepIndex,
|
||||
error,
|
||||
focusFirstInvalidStep,
|
||||
isLoading,
|
||||
registerTenant,
|
||||
validate,
|
||||
values.accountName,
|
||||
values.email,
|
||||
values.password,
|
||||
values.tenantName,
|
||||
values.tenantSlug,
|
||||
])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key !== 'Enter') return
|
||||
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return
|
||||
const nativeEvent = event.nativeEvent as unknown as { isComposing?: boolean }
|
||||
if (nativeEvent?.isComposing) return
|
||||
|
||||
const target = event.target as HTMLElement
|
||||
if (target.isContentEditable) return
|
||||
if (target.tagName === 'TEXTAREA') return
|
||||
if (target.tagName === 'BUTTON' || target.tagName === 'A') return
|
||||
if (target.tagName === 'INPUT') {
|
||||
const { type } = target as HTMLInputElement
|
||||
if (type === 'checkbox' || type === 'radio') return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
handleNext()
|
||||
},
|
||||
[handleNext],
|
||||
)
|
||||
|
||||
const StepComponent = useMemo(() => {
|
||||
const step = REGISTRATION_STEPS[currentStepIndex]
|
||||
switch (step.id) {
|
||||
case 'workspace': {
|
||||
return <WorkspaceStep values={values} errors={errors} onFieldChange={handleFieldChange} isLoading={isLoading} />
|
||||
}
|
||||
case 'admin': {
|
||||
return <AdminStep values={values} errors={errors} onFieldChange={handleFieldChange} isLoading={isLoading} />
|
||||
}
|
||||
case 'review': {
|
||||
return (
|
||||
<ReviewStep
|
||||
values={values}
|
||||
errors={errors}
|
||||
onToggleTerms={(accepted) => handleFieldChange('termsAccepted', accepted)}
|
||||
isLoading={isLoading}
|
||||
serverError={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}, [currentStepIndex, error, errors, handleFieldChange, isLoading, values])
|
||||
|
||||
const isLastStep = currentStepIndex === REGISTRATION_STEPS.length - 1
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-4 py-10">
|
||||
<LinearBorderContainer className="bg-background-tertiary h-[85vh] w-full max-w-5xl">
|
||||
<div className="grid h-full lg:grid-cols-[280px_1fr]">
|
||||
<div className="relative h-full">
|
||||
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
|
||||
<RegistrationSidebar
|
||||
currentStepIndex={currentStepIndex}
|
||||
canNavigateTo={canNavigateTo}
|
||||
onStepSelect={jumpToStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main className="flex h-full w-[700px] flex-col">
|
||||
<div className="shrink-0">
|
||||
<RegistrationHeader currentStepIndex={currentStepIndex} />
|
||||
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-0 flex-1">
|
||||
<ScrollArea rootClassName="absolute! inset-0 h-full w-full">
|
||||
<section ref={contentRef} className="p-12" onKeyDown={handleKeyDown}>
|
||||
{StepComponent}
|
||||
</section>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
<RegistrationFooter
|
||||
disableBack={currentStepIndex === 0}
|
||||
isSubmitting={isLoading}
|
||||
isLastStep={isLastStep}
|
||||
onBack={handleBack}
|
||||
onNext={handleNext}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</LinearBorderContainer>
|
||||
|
||||
<p className="text-text-tertiary mt-6 text-sm">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-accent hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
154
be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts
Normal file
154
be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { FetchError } from 'ofetch'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { RegisterTenantPayload } from '~/modules/auth/api/registerTenant'
|
||||
import { registerTenant } from '~/modules/auth/api/registerTenant'
|
||||
|
||||
interface TenantRegistrationRequest {
|
||||
tenantName: string
|
||||
tenantSlug: string
|
||||
accountName: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const SECOND_LEVEL_PUBLIC_SUFFIXES = new Set(['ac', 'co', 'com', 'edu', 'gov', 'net', 'org'])
|
||||
|
||||
function resolveBaseDomain(hostname: string): string {
|
||||
const envValue = (import.meta.env as Record<string, unknown> | undefined)?.VITE_APP_TENANT_BASE_DOMAIN
|
||||
if (typeof envValue === 'string' && envValue.trim().length > 0) {
|
||||
return envValue.trim().replace(/^\./, '').toLowerCase()
|
||||
}
|
||||
|
||||
if (!hostname) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
||||
return 'localhost'
|
||||
}
|
||||
|
||||
const parts = hostname.split('.').filter(Boolean)
|
||||
if (parts.length <= 2) {
|
||||
return hostname
|
||||
}
|
||||
|
||||
const tld = parts.at(-1) ?? ''
|
||||
const secondLevel = parts.at(-2) ?? ''
|
||||
|
||||
if (tld.length === 2 && SECOND_LEVEL_PUBLIC_SUFFIXES.has(secondLevel) && parts.length >= 3) {
|
||||
return parts.slice(-3).join('.').toLowerCase()
|
||||
}
|
||||
|
||||
return parts.slice(-2).join('.').toLowerCase()
|
||||
}
|
||||
|
||||
function buildTenantLoginUrl(slug: string): string {
|
||||
const normalizedSlug = slug.trim().toLowerCase()
|
||||
if (!normalizedSlug) {
|
||||
throw new Error('Registration succeeded but a workspace slug was not returned.')
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
const baseDomain = resolveBaseDomain(hostname)
|
||||
|
||||
if (!baseDomain) {
|
||||
throw new Error('Unable to resolve base domain for workspace login redirect.')
|
||||
}
|
||||
|
||||
const shouldAppendPort = Boolean(
|
||||
port && (baseDomain === 'localhost' || hostname === baseDomain || hostname.endsWith(`.${baseDomain}`)),
|
||||
)
|
||||
|
||||
const portSegment = shouldAppendPort ? `:${port}` : ''
|
||||
const scheme = protocol || 'https:'
|
||||
|
||||
return `${scheme}//${normalizedSlug}.${baseDomain}${portSegment}/login`
|
||||
}
|
||||
|
||||
export function useRegisterTenant() {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: TenantRegistrationRequest) => {
|
||||
const payload: RegisterTenantPayload = {
|
||||
tenant: {
|
||||
name: data.tenantName.trim(),
|
||||
slug: data.tenantSlug.trim(),
|
||||
},
|
||||
account: {
|
||||
name: data.accountName.trim() || data.email.trim(),
|
||||
email: data.email.trim(),
|
||||
password: data.password,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await registerTenant(payload)
|
||||
|
||||
const headerSlug = response.headers.get('x-tenant-slug')?.trim().toLowerCase() ?? null
|
||||
const submittedSlug = payload.tenant.slug?.trim().toLowerCase() ?? ''
|
||||
const finalSlug = headerSlug && headerSlug.length > 0 ? headerSlug : submittedSlug
|
||||
|
||||
if (!finalSlug) {
|
||||
throw new Error('Registration succeeded but the workspace slug could not be determined.')
|
||||
}
|
||||
|
||||
return {
|
||||
slug: finalSlug,
|
||||
}
|
||||
},
|
||||
onSuccess: ({ slug }) => {
|
||||
try {
|
||||
const loginUrl = buildTenantLoginUrl(slug)
|
||||
setErrorMessage(null)
|
||||
window.location.replace(loginUrl)
|
||||
} catch (redirectError) {
|
||||
if (redirectError instanceof Error) {
|
||||
setErrorMessage(redirectError.message)
|
||||
} else {
|
||||
setErrorMessage('Registration succeeded but redirect failed. Please use your workspace URL to sign in.')
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error instanceof FetchError) {
|
||||
const status = error.statusCode ?? error.response?.status
|
||||
const serverMessage = (error.data as any)?.message
|
||||
|
||||
switch (status) {
|
||||
case 400: {
|
||||
setErrorMessage(serverMessage || 'Please verify your inputs and try again')
|
||||
break
|
||||
}
|
||||
case 403: {
|
||||
setErrorMessage(serverMessage || 'Registration is currently disabled')
|
||||
break
|
||||
}
|
||||
case 409: {
|
||||
setErrorMessage(serverMessage || 'An account or workspace with these details already exists')
|
||||
break
|
||||
}
|
||||
case 429: {
|
||||
setErrorMessage('Too many attempts. Please try again later')
|
||||
break
|
||||
}
|
||||
default: {
|
||||
setErrorMessage(serverMessage || error.message || 'Registration failed. Please try again')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setErrorMessage(error.message || 'An unexpected error occurred. Please try again')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const clearError = () => setErrorMessage(null)
|
||||
|
||||
return {
|
||||
registerTenant: mutation.mutate,
|
||||
isLoading: mutation.isPending,
|
||||
error: errorMessage,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
156
be/apps/dashboard/src/modules/auth/hooks/useRegistrationForm.ts
Normal file
156
be/apps/dashboard/src/modules/auth/hooks/useRegistrationForm.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { isLikelyEmail, slugify } from '~/modules/onboarding/utils'
|
||||
|
||||
export interface TenantRegistrationFormState {
|
||||
tenantName: string
|
||||
tenantSlug: string
|
||||
accountName: string
|
||||
email: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
termsAccepted: boolean
|
||||
}
|
||||
|
||||
const REQUIRED_PASSWORD_LENGTH = 8
|
||||
const ALL_FIELDS: Array<keyof TenantRegistrationFormState> = [
|
||||
'tenantName',
|
||||
'tenantSlug',
|
||||
'accountName',
|
||||
'email',
|
||||
'password',
|
||||
'confirmPassword',
|
||||
'termsAccepted',
|
||||
]
|
||||
|
||||
export function useRegistrationForm(initial?: Partial<TenantRegistrationFormState>) {
|
||||
const [values, setValues] = useState<TenantRegistrationFormState>({
|
||||
tenantName: initial?.tenantName ?? '',
|
||||
tenantSlug: initial?.tenantSlug ?? '',
|
||||
accountName: initial?.accountName ?? '',
|
||||
email: initial?.email ?? '',
|
||||
password: initial?.password ?? '',
|
||||
confirmPassword: initial?.confirmPassword ?? '',
|
||||
termsAccepted: initial?.termsAccepted ?? false,
|
||||
})
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof TenantRegistrationFormState, string>>>({})
|
||||
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
|
||||
|
||||
const updateValue = <K extends keyof TenantRegistrationFormState>(
|
||||
field: K,
|
||||
value: TenantRegistrationFormState[K],
|
||||
) => {
|
||||
setValues((prev) => {
|
||||
if (field === 'tenantName' && !slugManuallyEdited) {
|
||||
return {
|
||||
...prev,
|
||||
tenantName: value as string,
|
||||
tenantSlug: slugify(value as string),
|
||||
}
|
||||
}
|
||||
|
||||
if (field === 'tenantSlug') {
|
||||
setSlugManuallyEdited(true)
|
||||
}
|
||||
|
||||
return { ...prev, [field]: value }
|
||||
})
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[field]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const fieldError = (field: keyof TenantRegistrationFormState): string | undefined => {
|
||||
switch (field) {
|
||||
case 'tenantName': {
|
||||
return values.tenantName.trim() ? undefined : 'Workspace name is required'
|
||||
}
|
||||
case 'tenantSlug': {
|
||||
const slug = values.tenantSlug.trim()
|
||||
if (!slug) return 'Slug is required'
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) return 'Use lowercase letters, numbers, and hyphen only'
|
||||
return undefined
|
||||
}
|
||||
case 'email': {
|
||||
const email = values.email.trim()
|
||||
if (!email) return 'Email is required'
|
||||
if (!isLikelyEmail(email)) return 'Enter a valid email address'
|
||||
return undefined
|
||||
}
|
||||
case 'accountName': {
|
||||
return values.accountName.trim() ? undefined : 'Administrator name is required'
|
||||
}
|
||||
case 'password': {
|
||||
if (!values.password) return 'Password is required'
|
||||
if (values.password.length < REQUIRED_PASSWORD_LENGTH) {
|
||||
return `Password must be at least ${REQUIRED_PASSWORD_LENGTH} characters`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
case 'confirmPassword': {
|
||||
if (!values.confirmPassword) return 'Confirm your password'
|
||||
if (values.confirmPassword !== values.password) return 'Passwords do not match'
|
||||
return undefined
|
||||
}
|
||||
case 'termsAccepted': {
|
||||
return values.termsAccepted ? undefined : 'You must accept the terms to continue'
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const validate = (fields?: Array<keyof TenantRegistrationFormState>) => {
|
||||
const fieldsToValidate = fields ?? ALL_FIELDS
|
||||
const stepErrors: Partial<Record<keyof TenantRegistrationFormState, string>> = {}
|
||||
let hasErrors = false
|
||||
|
||||
for (const field of fieldsToValidate) {
|
||||
const error = fieldError(field)
|
||||
if (error) {
|
||||
stepErrors[field] = error
|
||||
hasErrors = true
|
||||
}
|
||||
}
|
||||
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const field of fieldsToValidate) {
|
||||
const error = stepErrors[field]
|
||||
if (error) {
|
||||
next[field] = error
|
||||
} else {
|
||||
delete next[field]
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
return !hasErrors
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setValues({
|
||||
tenantName: '',
|
||||
tenantSlug: '',
|
||||
accountName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
termsAccepted: false,
|
||||
})
|
||||
setErrors({})
|
||||
setSlugManuallyEdited(false)
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
updateValue,
|
||||
validate,
|
||||
getFieldError: fieldError,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export type OnboardingInitPayload = {
|
||||
tenant: {
|
||||
name: string
|
||||
slug: string
|
||||
domain?: string
|
||||
}
|
||||
settings?: Array<{
|
||||
key: OnboardingSettingKey
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { OnboardingSettingKey } from './constants'
|
||||
export type TenantFormState = {
|
||||
name: string
|
||||
slug: string
|
||||
domain: string
|
||||
}
|
||||
|
||||
export type AdminFormState = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}>
|
||||
|
||||
5
be/apps/dashboard/src/pages/(onboarding)/register.tsx
Normal file
5
be/apps/dashboard/src/pages/(onboarding)/register.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { RegistrationWizard } from '~/modules/auth/components/RegistrationWizard'
|
||||
|
||||
export function Component() {
|
||||
return <RegistrationWizard />
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
56
be/packages/db/migrations/0004_sleepy_avengers.sql
Normal file
56
be/packages/db/migrations/0004_sleepy_avengers.sql
Normal 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");
|
||||
1
be/packages/db/migrations/0005_remove_primary_tenant.sql
Normal file
1
be/packages/db/migrations/0005_remove_primary_tenant.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "tenant" DROP COLUMN IF EXISTS "is_primary";
|
||||
2
be/packages/db/migrations/0006_remove_tenant_domains.sql
Normal file
2
be/packages/db/migrations/0006_remove_tenant_domains.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "tenant" DROP COLUMN IF EXISTS "primary_domain";
|
||||
DROP TABLE IF EXISTS "tenant_domain";
|
||||
1199
be/packages/db/migrations/meta/0004_snapshot.json
Normal file
1199
be/packages/db/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1192
be/packages/db/migrations/meta/0005_snapshot.json
Normal file
1192
be/packages/db/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1118
be/packages/db/migrations/meta/0006_snapshot.json
Normal file
1118
be/packages/db/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from './cn'
|
||||
export * from './semaphore'
|
||||
export * from './spring'
|
||||
export * from './storage-provider'
|
||||
export * from './tenant'
|
||||
export * from './u8array'
|
||||
|
||||
12
packages/utils/src/tenant.ts
Normal file
12
packages/utils/src/tenant.ts
Normal 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'
|
||||
Reference in New Issue
Block a user