diff --git a/be/apps/core/src/app.factory.ts b/be/apps/core/src/app.factory.ts index a3cfca2e..dfa3f622 100644 --- a/be/apps/core/src/app.factory.ts +++ b/be/apps/core/src/app.factory.ts @@ -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 { 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 } diff --git a/be/apps/core/src/guards/auth.guard.ts b/be/apps/core/src/guards/auth.guard.ts index 4432f2e3..6e0c19fb 100644 --- a/be/apps/core/src/guards/auth.guard.ts +++ b/be/apps/core/src/guards/auth.guard.ts @@ -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 { 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 + } } diff --git a/be/apps/core/src/interceptors/tenant-resolver.decorator.ts b/be/apps/core/src/interceptors/tenant-resolver.decorator.ts new file mode 100644 index 00000000..a0b6b950 --- /dev/null +++ b/be/apps/core/src/interceptors/tenant-resolver.decorator.ts @@ -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 +} diff --git a/be/apps/core/src/interceptors/tenant-resolver.interceptor.ts b/be/apps/core/src/interceptors/tenant-resolver.interceptor.ts new file mode 100644 index 00000000..7f3c6df2 --- /dev/null +++ b/be/apps/core/src/interceptors/tenant-resolver.interceptor.ts @@ -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 = { + 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 { + 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() + } +} diff --git a/be/apps/core/src/middlewares/cors.middleware.ts b/be/apps/core/src/middlewares/cors.middleware.ts index 43645938..9006184f 100644 --- a/be/apps/core/src/middlewares/cors.middleware.ts +++ b/be/apps/core/src/middlewares/cors.middleware.ts @@ -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() - 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 { - 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 { + ['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 diff --git a/be/apps/core/src/middlewares/tenant-resolver.middleware.ts b/be/apps/core/src/middlewares/tenant-resolver.middleware.ts deleted file mode 100644 index f5ea2c8b..00000000 --- a/be/apps/core/src/middlewares/tenant-resolver.middleware.ts +++ /dev/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 { - 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, - }) - } -} diff --git a/be/apps/core/src/modules/auth/auth-registration.service.ts b/be/apps/core/src/modules/auth/auth-registration.service.ts new file mode 100644 index 00000000..85f4441c --- /dev/null +++ b/be/apps/core/src/modules/auth/auth-registration.service.ts @@ -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 { + 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 { + 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: '无法生成唯一的租户标识,请尝试使用不同的名称', + }) + } +} diff --git a/be/apps/core/src/modules/auth/auth.controller.ts b/be/apps/core/src/modules/auth/auth.controller.ts index 6ca66ed0..74850464 100644 --- a/be/apps/core/src/modules/auth/auth.controller.ts +++ b/be/apps/core/src/modules/auth/auth.controller.ts @@ -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) { diff --git a/be/apps/core/src/modules/auth/auth.module.ts b/be/apps/core/src/modules/auth/auth.module.ts index 667fb691..85597632 100644 --- a/be/apps/core/src/modules/auth/auth.module.ts +++ b/be/apps/core/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/be/apps/core/src/modules/index.module.ts b/be/apps/core/src/modules/index.module.ts index 199a5a04..bf3137ad 100644 --- a/be/apps/core/src/modules/index.module.ts +++ b/be/apps/core/src/modules/index.module.ts @@ -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 {} diff --git a/be/apps/core/src/modules/onboarding/onboarding.dto.ts b/be/apps/core/src/modules/onboarding/onboarding.dto.ts index b603c248..c5e76eec 100644 --- a/be/apps/core/src/modules/onboarding/onboarding.dto.ts +++ b/be/apps/core/src/modules/onboarding/onboarding.dto.ts @@ -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 ?? []), }), diff --git a/be/apps/core/src/modules/onboarding/onboarding.service.ts b/be/apps/core/src/modules/onboarding/onboarding.service.ts index caa340ee..b6bc8eee 100644 --- a/be/apps/core/src/modules/onboarding/onboarding.service.ts +++ b/be/apps/core/src/modules/onboarding/onboarding.service.ts @@ -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})`) diff --git a/be/apps/core/src/modules/setting/setting.constant.ts b/be/apps/core/src/modules/setting/setting.constant.ts index a0307a3b..f4e9dc7b 100644 --- a/be/apps/core/src/modules/setting/setting.constant.ts +++ b/be/apps/core/src/modules/setting/setting.constant.ts @@ -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) => { diff --git a/be/apps/core/src/modules/setting/setting.service.ts b/be/apps/core/src/modules/setting/setting.service.ts index a5aa8556..69368e49 100644 --- a/be/apps/core/src/modules/setting/setting.service.ts +++ b/be/apps/core/src/modules/setting/setting.service.ts @@ -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 { diff --git a/be/apps/core/src/modules/super-admin/super-admin.dto.ts b/be/apps/core/src/modules/super-admin/super-admin.dto.ts index e03a7a78..2495df8a 100644 --- a/be/apps/core/src/modules/super-admin/super-admin.dto.ts +++ b/be/apps/core/src/modules/super-admin/super-admin.dto.ts @@ -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: '至少需要更新一项设置', diff --git a/be/apps/core/src/modules/system-setting/super-admin-setting.constants.ts b/be/apps/core/src/modules/system-setting/super-admin-setting.constants.ts index 2988e179..12dc53c0 100644 --- a/be/apps/core/src/modules/system-setting/super-admin-setting.constants.ts +++ b/be/apps/core/src/modules/system-setting/super-admin-setting.constants.ts @@ -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 diff --git a/be/apps/core/src/modules/system-setting/super-admin-setting.service.ts b/be/apps/core/src/modules/system-setting/super-admin-setting.service.ts index bd9ca5ce..212c2b01 100644 --- a/be/apps/core/src/modules/system-setting/super-admin-setting.service.ts +++ b/be/apps/core/src/modules/system-setting/super-admin-setting.service.ts @@ -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 } diff --git a/be/apps/core/src/modules/system-setting/super-admin-setting.types.ts b/be/apps/core/src/modules/system-setting/super-admin-setting.types.ts index 00037768..cfd9205d 100644 --- a/be/apps/core/src/modules/system-setting/super-admin-setting.types.ts +++ b/be/apps/core/src/modules/system-setting/super-admin-setting.types.ts @@ -5,6 +5,7 @@ export interface SuperAdminSettings { allowRegistration: boolean maxRegistrableUsers: number | null localProviderEnabled: boolean + baseDomain: string } export type SuperAdminSettingValueMap = { diff --git a/be/apps/core/src/modules/system-setting/super-admin-setting.ui-schema.ts b/be/apps/core/src/modules/system-setting/super-admin-setting.ui-schema.ts index a6b61508..82eae5df 100644 --- a/be/apps/core/src/modules/system-setting/super-admin-setting.ui-schema.ts +++ b/be/apps/core/src/modules/system-setting/super-admin-setting.ui-schema.ts @@ -35,6 +35,18 @@ export const SUPER_ADMIN_SETTING_UI_SCHEMA: UiSchema = { 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', diff --git a/be/apps/core/src/modules/tenant-auth/tenant-auth.config.ts b/be/apps/core/src/modules/tenant-auth/tenant-auth.config.ts new file mode 100644 index 00000000..03deabe8 --- /dev/null +++ b/be/apps/core/src/modules/tenant-auth/tenant-auth.config.ts @@ -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 + +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 { + 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: {} }) + } + } +} diff --git a/be/apps/core/src/modules/tenant-auth/tenant-auth.controller.ts b/be/apps/core/src/modules/tenant-auth/tenant-auth.controller.ts new file mode 100644 index 00000000..2394285c --- /dev/null +++ b/be/apps/core/src/modules/tenant-auth/tenant-auth.controller.ts @@ -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) + } +} diff --git a/be/apps/core/src/modules/tenant-auth/tenant-auth.module.ts b/be/apps/core/src/modules/tenant-auth/tenant-auth.module.ts new file mode 100644 index 00000000..83c7a4c7 --- /dev/null +++ b/be/apps/core/src/modules/tenant-auth/tenant-auth.module.ts @@ -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 {} diff --git a/be/apps/core/src/modules/tenant-auth/tenant-auth.provider.ts b/be/apps/core/src/modules/tenant-auth/tenant-auth.provider.ts new file mode 100644 index 00000000..cd787df5 --- /dev/null +++ b/be/apps/core/src/modules/tenant-auth/tenant-auth.provider.ts @@ -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 +export type TenantAuthSession = TenantBetterAuthInstance['$Infer']['Session'] + +@injectable() +export class TenantAuthProvider implements OnModuleInit, OnModuleDestroy { + private readonly cache = new Map() + + constructor( + private readonly drizzleProvider: DrizzleProvider, + private readonly configService: TenantAuthConfigService, + private readonly eventEmitter: EventEmitterService, + ) {} + + async onModuleInit(): Promise { + this.eventEmitter.on('setting.updated', this.handleSettingUpdated) + this.eventEmitter.on('setting.deleted', this.handleSettingDeleted) + } + + async onModuleDestroy(): Promise { + 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 { + 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 { + const auth = await this.getAuth(tenantId) + return auth.handler(context.req.raw) + } +} diff --git a/be/apps/core/src/modules/tenant/tenant-context-resolver.service.ts b/be/apps/core/src/modules/tenant/tenant-context-resolver.service.ts new file mode 100644 index 00000000..c376c793 --- /dev/null +++ b/be/apps/core/src/modules/tenant/tenant-context-resolver.service.ts @@ -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 { + 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 { + 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 + } +} diff --git a/be/apps/core/src/modules/tenant/tenant.module.ts b/be/apps/core/src/modules/tenant/tenant.module.ts index 191173d2..6aa7971f 100644 --- a/be/apps/core/src/modules/tenant/tenant.module.ts +++ b/be/apps/core/src/modules/tenant/tenant.module.ts @@ -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 {} diff --git a/be/apps/core/src/modules/tenant/tenant.repository.ts b/be/apps/core/src/modules/tenant/tenant.repository.ts index d8ad6ecd..4deb72c7 100644 --- a/be/apps/core/src/modules/tenant/tenant.repository.ts +++ b/be/apps/core/src/modules/tenant/tenant.repository.ts @@ -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 { 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 { - 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 { - 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 { + async createTenant(payload: { name: string; slug: string }): Promise { 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 { + const db = this.dbAccessor.get() + await db.delete(tenants).where(eq(tenants.id, id)) + } } diff --git a/be/apps/core/src/modules/tenant/tenant.service.ts b/be/apps/core/src/modules/tenant/tenant.service.ts index b4fdfbe7..9520198c 100644 --- a/be/apps/core/src/modules/tenant/tenant.service.ts +++ b/be/apps/core/src/modules/tenant/tenant.service.ts @@ -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 { + async createTenant(payload: { name: string; slug: string }): Promise { return await this.repository.createTenant(payload) } async resolve(input: TenantResolutionInput, noThrow: boolean): Promise async resolve(input: TenantResolutionInput): Promise async resolve(input: TenantResolutionInput, noThrow = false): Promise { - 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 { + 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 - } } diff --git a/be/apps/core/src/modules/tenant/tenant.types.ts b/be/apps/core/src/modules/tenant/tenant.types.ts index 1cb0f46e..9fa23bb6 100644 --- a/be/apps/core/src/modules/tenant/tenant.types.ts +++ b/be/apps/core/src/modules/tenant/tenant.types.ts @@ -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 } diff --git a/be/apps/dashboard/src/hooks/usePageRedirect.ts b/be/apps/dashboard/src/hooks/usePageRedirect.ts index fe740c93..f332e800 100644 --- a/be/apps/dashboard/src/hooks/usePageRedirect.ts +++ b/be/apps/dashboard/src/hooks/usePageRedirect.ts @@ -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(() => { diff --git a/be/apps/dashboard/src/lib/query-client.ts b/be/apps/dashboard/src/lib/query-client.ts index d66f4f61..214a23d7 100644 --- a/be/apps/dashboard/src/lib/query-client.ts +++ b/be/apps/dashboard/src/lib/query-client.ts @@ -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 } diff --git a/be/apps/dashboard/src/modules/analytics/components/AnalyticsPage.tsx b/be/apps/dashboard/src/modules/analytics/components/AnalyticsPage.tsx index 0b98f9b9..3fc1899c 100644 --- a/be/apps/dashboard/src/modules/analytics/components/AnalyticsPage.tsx +++ b/be/apps/dashboard/src/modules/analytics/components/AnalyticsPage.tsx @@ -126,7 +126,7 @@ function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) { >
@@ -295,7 +295,7 @@ export function DashboardAnalytics() { ) : data?.uploadTrends?.length ? ( <> {uploadTrendStats ? ( -
+
累计上传 @@ -364,7 +364,7 @@ export function DashboardAnalytics() { return ( <> -
+
总占用 {formatBytes(storageUsage.totalBytes)} diff --git a/be/apps/dashboard/src/modules/auth/api/registerTenant.ts b/be/apps/dashboard/src/modules/auth/api/registerTenant.ts new file mode 100644 index 00000000..bca1dfaa --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/api/registerTenant.ts @@ -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 + +export async function registerTenant(payload: RegisterTenantPayload): Promise { + return await coreApi.raw('/auth/tenants/sign-up', { + method: 'POST', + body: payload, + }) +} diff --git a/be/apps/dashboard/src/modules/auth/api/session.ts b/be/apps/dashboard/src/modules/auth/api/session.ts index 03ebba9e..5c08bc12 100644 --- a/be/apps/dashboard/src/modules/auth/api/session.ts +++ b/be/apps/dashboard/src/modules/auth/api/session.ts @@ -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('/auth/session', { - method: 'GET', - }) + const fallbackStatus = new Set([401, 403, 404]) + + try { + const tenantSession = await coreApi('/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('/auth/session', { method: 'GET' }) + return { ...globalSession, source: globalSession.source ?? 'global' } } diff --git a/be/apps/dashboard/src/modules/auth/auth-client.ts b/be/apps/dashboard/src/modules/auth/auth-client.ts index 2736640d..0254251c 100644 --- a/be/apps/dashboard/src/modules/auth/auth-client.ts +++ b/be/apps/dashboard/src/modules/auth/auth-client.ts @@ -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('/')) { diff --git a/be/apps/dashboard/src/modules/auth/components/RegistrationWizard.tsx b/be/apps/dashboard/src/modules/auth/components/RegistrationWizard.tsx new file mode 100644 index 00000000..0023ebd0 --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/components/RegistrationWizard.tsx @@ -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> = { + 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 = ({ currentStepIndex, canNavigateTo, onStepSelect }) => ( + +) + +type HeaderProps = { + currentStepIndex: number +} + +const RegistrationHeader: FC = ({ currentStepIndex }) => { + const step = REGISTRATION_STEPS[currentStepIndex] + return ( +
+
+ Step {currentStepIndex + 1} of {REGISTRATION_STEPS.length} +
+

{step.title}

+

{step.description}

+
+ ) +} + +type FooterProps = { + disableBack: boolean + isSubmitting: boolean + isLastStep: boolean + onBack: () => void + onNext: () => void +} + +const RegistrationFooter: FC = ({ disableBack, isSubmitting, isLastStep, onBack, onNext }) => ( +
+ {!disableBack ? ( +
+ Adjustments are always possible—use the sidebar or go back to modify earlier details. +
+ ) : ( +
+ )} +
+ {!disableBack && ( + + )} + +
+
+) + +type StepCommonProps = { + values: TenantRegistrationFormState + errors: Partial> + onFieldChange: ( + field: Field, + value: TenantRegistrationFormState[Field], + ) => void + isLoading: boolean +} + +const WorkspaceStep: FC = ({ values, errors, onFieldChange, isLoading }) => ( +
+
+

Workspace basics

+

+ This information appears in navigation, invitations, and other tenant-facing areas. +

+
+
+
+ + onFieldChange('tenantName', event.currentTarget.value)} + placeholder="Acme Studio" + disabled={isLoading} + error={Boolean(errors.tenantName)} + autoComplete="organization" + /> + {errors.tenantName} +
+
+ + onFieldChange('tenantSlug', event.currentTarget.value)} + placeholder="acme" + disabled={isLoading} + error={Boolean(errors.tenantSlug)} + autoComplete="off" + /> +

+ Lowercase letters, numbers, and hyphen are allowed. We'll ensure the slug is unique. +

+ {errors.tenantSlug} +
+
+
+) + +const AdminStep: FC = ({ values, errors, onFieldChange, isLoading }) => ( +
+
+

Administrator

+

+ The first user becomes the workspace administrator and can invite additional members later. +

+
+
+
+ + onFieldChange('accountName', event.currentTarget.value)} + placeholder="Jane Doe" + disabled={isLoading} + error={Boolean(errors.accountName)} + autoComplete="name" + /> + {errors.accountName} +
+
+ + onFieldChange('email', event.currentTarget.value)} + placeholder="jane@acme.studio" + disabled={isLoading} + error={Boolean(errors.email)} + autoComplete="email" + /> + {errors.email} +
+
+ + onFieldChange('password', event.currentTarget.value)} + placeholder="Create a strong password" + disabled={isLoading} + error={Boolean(errors.password)} + autoComplete="new-password" + /> + {errors.password} +
+
+ + onFieldChange('confirmPassword', event.currentTarget.value)} + placeholder="Repeat your password" + disabled={isLoading} + error={Boolean(errors.confirmPassword)} + autoComplete="new-password" + /> + {errors.confirmPassword} +
+
+

+ We recommend using a secure password manager to store credentials for critical roles like the administrator. +

+
+) + +type ReviewStepProps = Omit & { + onToggleTerms: (value: boolean) => void + serverError: string | null +} + +const ReviewStep: FC = ({ values, errors, onToggleTerms, isLoading, serverError }) => ( +
+
+

Confirm workspace configuration

+

+ Double-check the details below. You can go back to make adjustments before creating the workspace. +

+
+
+
+
Workspace name
+
{values.tenantName || '—'}
+
+
+
Workspace slug
+
{values.tenantSlug || '—'}
+
+
+
Administrator name
+
{values.accountName || '—'}
+
+
+
Administrator email
+
{values.email || '—'}
+
+
+ + {serverError && ( + +

{serverError}

+
+ )} + +
+

Policies

+

+ Creating a workspace means you agree to comply with our usage guidelines and privacy practices. +

+
+ + {errors.termsAccepted} +
+
+
+) + +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(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(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: 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 + } + case 'admin': { + return + } + case 'review': { + return ( + handleFieldChange('termsAccepted', accepted)} + isLoading={isLoading} + serverError={error} + /> + ) + } + default: { + return null + } + } + }, [currentStepIndex, error, errors, handleFieldChange, isLoading, values]) + + const isLastStep = currentStepIndex === REGISTRATION_STEPS.length - 1 + + return ( +
+ +
+
+
+ +
+ +
+
+ +
+
+ +
+ +
+ {StepComponent} +
+
+
+ +
+
+ +
+
+
+ + +

+ Already have an account?{' '} + + Sign in + + . +

+
+ ) +} diff --git a/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts b/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts index a33a5ce3..6b4af364 100644 --- a/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts +++ b/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts @@ -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 { diff --git a/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts b/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts new file mode 100644 index 00000000..deed59ca --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts @@ -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 | 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(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, + } +} diff --git a/be/apps/dashboard/src/modules/auth/hooks/useRegistrationForm.ts b/be/apps/dashboard/src/modules/auth/hooks/useRegistrationForm.ts new file mode 100644 index 00000000..e17cb64d --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/hooks/useRegistrationForm.ts @@ -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 = [ + 'tenantName', + 'tenantSlug', + 'accountName', + 'email', + 'password', + 'confirmPassword', + 'termsAccepted', +] + +export function useRegistrationForm(initial?: Partial) { + const [values, setValues] = useState({ + 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>>({}) + const [slugManuallyEdited, setSlugManuallyEdited] = useState(false) + + const updateValue = ( + 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) => { + const fieldsToValidate = fields ?? ALL_FIELDS + const stepErrors: Partial> = {} + 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, + } +} diff --git a/be/apps/dashboard/src/modules/auth/types.ts b/be/apps/dashboard/src/modules/auth/types.ts index ebfb1c1d..21f35753 100644 --- a/be/apps/dashboard/src/modules/auth/types.ts +++ b/be/apps/dashboard/src/modules/auth/types.ts @@ -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' } diff --git a/be/apps/dashboard/src/modules/onboarding/api.ts b/be/apps/dashboard/src/modules/onboarding/api.ts index bebe9957..c8b8ffbd 100644 --- a/be/apps/dashboard/src/modules/onboarding/api.ts +++ b/be/apps/dashboard/src/modules/onboarding/api.ts @@ -15,7 +15,6 @@ export type OnboardingInitPayload = { tenant: { name: string slug: string - domain?: string } settings?: Array<{ key: OnboardingSettingKey diff --git a/be/apps/dashboard/src/modules/onboarding/components/OnboardingWizard.tsx b/be/apps/dashboard/src/modules/onboarding/components/OnboardingWizard.tsx index c48a92d6..6a4beeba 100644 --- a/be/apps/dashboard/src/modules/onboarding/components/OnboardingWizard.tsx +++ b/be/apps/dashboard/src/modules/onboarding/components/OnboardingWizard.tsx @@ -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 = { welcome: , tenant: ( - + ), admin: , settings: ( diff --git a/be/apps/dashboard/src/modules/onboarding/components/steps/ReviewStep.tsx b/be/apps/dashboard/src/modules/onboarding/components/steps/ReviewStep.tsx index b5a5b4e3..e8e4e1f3 100644 --- a/be/apps/dashboard/src/modules/onboarding/components/steps/ReviewStep.tsx +++ b/be/apps/dashboard/src/modules/onboarding/components/steps/ReviewStep.tsx @@ -39,10 +39,6 @@ export const ReviewStep: FC = ({
Slug
{tenant.slug || '—'}
-
-
Domain
-
{tenant.domain || 'Not configured'}
-
diff --git a/be/apps/dashboard/src/modules/onboarding/components/steps/TenantStep.tsx b/be/apps/dashboard/src/modules/onboarding/components/steps/TenantStep.tsx index 80179dcc..f38efb73 100644 --- a/be/apps/dashboard/src/modules/onboarding/components/steps/TenantStep.tsx +++ b/be/apps/dashboard/src/modules/onboarding/components/steps/TenantStep.tsx @@ -8,10 +8,9 @@ type TenantStepProps = { errors: OnboardingErrors onNameChange: (value: string) => void onSlugChange: (value: string) => void - onDomainChange: (value: string) => void } -export const TenantStep: FC = ({ tenant, errors, onNameChange, onSlugChange, onDomainChange }) => ( +export const TenantStep: FC = ({ tenant, errors, onNameChange, onSlugChange }) => (
event.preventDefault()}>
@@ -42,21 +41,5 @@ export const TenantStep: FC = ({ tenant, errors, onNameChange, {errors['tenant.slug']}
- -
- - onDomainChange(event.currentTarget.value)} - placeholder="gallery.afilmory.art" - error={!!errors['tenant.domain']} - autoComplete="off" - /> - {errors['tenant.domain']} -

- Domains enable automatic routing for tenant-specific dashboards. Configure DNS separately after initialization. -

-
) diff --git a/be/apps/dashboard/src/modules/onboarding/constants.ts b/be/apps/dashboard/src/modules/onboarding/constants.ts index 9759f552..64512c88 100644 --- a/be/apps/dashboard/src/modules/onboarding/constants.ts +++ b/be/apps/dashboard/src/modules/onboarding/constants.ts @@ -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', diff --git a/be/apps/dashboard/src/modules/onboarding/hooks/useOnboardingWizard.ts b/be/apps/dashboard/src/modules/onboarding/hooks/useOnboardingWizard.ts index 77d6ac38..359a3028 100644 --- a/be/apps/dashboard/src/modules/onboarding/hooks/useOnboardingWizard.ts +++ b/be/apps/dashboard/src/modules/onboarding/hooks/useOnboardingWizard.ts @@ -17,7 +17,6 @@ export function useOnboardingWizard() { const [tenant, setTenant] = useState({ name: '', slug: '', - domain: '', }) const [slugLocked, setSlugLocked] = useState(false) const [admin, setAdmin] = useState({ @@ -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, diff --git a/be/apps/dashboard/src/modules/onboarding/types.ts b/be/apps/dashboard/src/modules/onboarding/types.ts index c3984a88..bd18cd10 100644 --- a/be/apps/dashboard/src/modules/onboarding/types.ts +++ b/be/apps/dashboard/src/modules/onboarding/types.ts @@ -3,7 +3,6 @@ import type { OnboardingSettingKey } from './constants' export type TenantFormState = { name: string slug: string - domain: string } export type AdminFormState = { diff --git a/be/apps/dashboard/src/modules/photos/components/library/PhotoUploadConfirmModal.tsx b/be/apps/dashboard/src/modules/photos/components/library/PhotoUploadConfirmModal.tsx index 433923df..de6958d6 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/PhotoUploadConfirmModal.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/PhotoUploadConfirmModal.tsx @@ -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) diff --git a/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx b/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx index a264a259..d1b0e777 100644 --- a/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx +++ b/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx @@ -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 const BOOLEAN_FIELDS = new Set(['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 } diff --git a/be/apps/dashboard/src/modules/super-admin/types.ts b/be/apps/dashboard/src/modules/super-admin/types.ts index 22da054a..2e9e1df5 100644 --- a/be/apps/dashboard/src/modules/super-admin/types.ts +++ b/be/apps/dashboard/src/modules/super-admin/types.ts @@ -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 }> diff --git a/be/apps/dashboard/src/pages/(onboarding)/register.tsx b/be/apps/dashboard/src/pages/(onboarding)/register.tsx new file mode 100644 index 00000000..ab6434bc --- /dev/null +++ b/be/apps/dashboard/src/pages/(onboarding)/register.tsx @@ -0,0 +1,5 @@ +import { RegistrationWizard } from '~/modules/auth/components/RegistrationWizard' + +export function Component() { + return +} diff --git a/be/apps/dashboard/vite.config.ts b/be/apps/dashboard/vite.config.ts index ef54597f..5510e2a1 100644 --- a/be/apps/dashboard/vite.config.ts +++ b/be/apps/dashboard/vite.config.ts @@ -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) + } + }) + }, }, }, }, diff --git a/be/eslint.config.ts b/be/eslint.config.ts index 1c55e1aa..57248bd1 100644 --- a/be/eslint.config.ts +++ b/be/eslint.config.ts @@ -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, }, diff --git a/be/packages/db/migrations/0004_sleepy_avengers.sql b/be/packages/db/migrations/0004_sleepy_avengers.sql new file mode 100644 index 00000000..8787c637 --- /dev/null +++ b/be/packages/db/migrations/0004_sleepy_avengers.sql @@ -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"); \ No newline at end of file diff --git a/be/packages/db/migrations/0005_remove_primary_tenant.sql b/be/packages/db/migrations/0005_remove_primary_tenant.sql new file mode 100644 index 00000000..dfb27a80 --- /dev/null +++ b/be/packages/db/migrations/0005_remove_primary_tenant.sql @@ -0,0 +1 @@ +ALTER TABLE "tenant" DROP COLUMN IF EXISTS "is_primary"; \ No newline at end of file diff --git a/be/packages/db/migrations/0006_remove_tenant_domains.sql b/be/packages/db/migrations/0006_remove_tenant_domains.sql new file mode 100644 index 00000000..1577e3aa --- /dev/null +++ b/be/packages/db/migrations/0006_remove_tenant_domains.sql @@ -0,0 +1,2 @@ +ALTER TABLE "tenant" DROP COLUMN IF EXISTS "primary_domain"; +DROP TABLE IF EXISTS "tenant_domain"; diff --git a/be/packages/db/migrations/meta/0004_snapshot.json b/be/packages/db/migrations/meta/0004_snapshot.json new file mode 100644 index 00000000..4892579d --- /dev/null +++ b/be/packages/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,1199 @@ +{ + "id": "c4c2ca27-8772-412f-b10f-5fd53410316d", + "prevId": "73c602e7-efc2-46a3-ab83-cecf071c2035", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "auth_session_tenant_id_tenant_id_fk": { + "name": "auth_session_tenant_id_tenant_id_fk", + "tableFrom": "auth_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auth_session_user_id_auth_user_id_fk": { + "name": "auth_session_user_id_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_session_token_unique": { + "name": "auth_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_user": { + "name": "auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires_at": { + "name": "ban_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_user_tenant_id_tenant_id_fk": { + "name": "auth_user_tenant_id_tenant_id_fk", + "tableFrom": "auth_user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_user_email_unique": { + "name": "auth_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photo_asset": { + "name": "photo_asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_id": { + "name": "photo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata_hash": { + "name": "metadata_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest_version": { + "name": "manifest_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'v7'" + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "photo_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "conflict_reason": { + "name": "conflict_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conflict_payload": { + "name": "conflict_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "photo_asset_tenant_id_tenant_id_fk": { + "name": "photo_asset_tenant_id_tenant_id_fk", + "tableFrom": "photo_asset", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_photo_asset_tenant_storage_key": { + "name": "uq_photo_asset_tenant_storage_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "storage_key"] + }, + "uq_photo_asset_tenant_photo_id": { + "name": "uq_photo_asset_tenant_photo_id", + "nullsNotDistinct": false, + "columns": ["tenant_id", "photo_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ref_key": { + "name": "ref_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_reactions_tenant_ref_key": { + "name": "idx_reactions_tenant_ref_key", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reactions_tenant_id_tenant_id_fk": { + "name": "reactions_tenant_id_tenant_id_fk", + "tableFrom": "reactions", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_reactions_tenant_ref_key": { + "name": "uq_reactions_tenant_ref_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "ref_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_sensitive": { + "name": "is_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_tenant_id_tenant_id_fk": { + "name": "settings_tenant_id_tenant_id_fk", + "tableFrom": "settings", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_settings_tenant_key": { + "name": "uq_settings_tenant_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_setting": { + "name": "system_setting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "is_sensitive": { + "name": "is_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_system_setting_key": { + "name": "uq_system_setting_key", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_account": { + "name": "tenant_auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_account_tenant_id_tenant_id_fk": { + "name": "tenant_auth_account_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_account", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_auth_account_user_id_tenant_auth_user_id_fk": { + "name": "tenant_auth_account_user_id_tenant_auth_user_id_fk", + "tableFrom": "tenant_auth_account", + "tableTo": "tenant_auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_session": { + "name": "tenant_auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_session_tenant_id_tenant_id_fk": { + "name": "tenant_auth_session_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_auth_session_user_id_tenant_auth_user_id_fk": { + "name": "tenant_auth_session_user_id_tenant_auth_user_id_fk", + "tableFrom": "tenant_auth_session", + "tableTo": "tenant_auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_auth_session_token_unique": { + "name": "tenant_auth_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_user": { + "name": "tenant_auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'guest'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires_at": { + "name": "ban_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_user_tenant_id_tenant_id_fk": { + "name": "tenant_auth_user_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_auth_user_tenant_email": { + "name": "uq_tenant_auth_user_tenant_email", + "nullsNotDistinct": false, + "columns": ["tenant_id", "email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_domain": { + "name": "tenant_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_domain_tenant_id_tenant_id_fk": { + "name": "tenant_domain_tenant_id_tenant_id_fk", + "tableFrom": "tenant_domain", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_domain_domain": { + "name": "uq_tenant_domain_domain", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "tenant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "primary_domain": { + "name": "primary_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_slug": { + "name": "uq_tenant_slug", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.photo_sync_status": { + "name": "photo_sync_status", + "schema": "public", + "values": ["pending", "synced", "conflict"] + }, + "public.tenant_status": { + "name": "tenant_status", + "schema": "public", + "values": ["active", "inactive", "suspended"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["user", "admin", "superadmin"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/be/packages/db/migrations/meta/0005_snapshot.json b/be/packages/db/migrations/meta/0005_snapshot.json new file mode 100644 index 00000000..cbcf2773 --- /dev/null +++ b/be/packages/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,1192 @@ +{ + "id": "5d5a2288-9a09-4fe5-843d-6e9bf137c551", + "prevId": "c4c2ca27-8772-412f-b10f-5fd53410316d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "auth_session_tenant_id_tenant_id_fk": { + "name": "auth_session_tenant_id_tenant_id_fk", + "tableFrom": "auth_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auth_session_user_id_auth_user_id_fk": { + "name": "auth_session_user_id_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_session_token_unique": { + "name": "auth_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_user": { + "name": "auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires_at": { + "name": "ban_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_user_tenant_id_tenant_id_fk": { + "name": "auth_user_tenant_id_tenant_id_fk", + "tableFrom": "auth_user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_user_email_unique": { + "name": "auth_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photo_asset": { + "name": "photo_asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_id": { + "name": "photo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata_hash": { + "name": "metadata_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest_version": { + "name": "manifest_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'v7'" + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "photo_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "conflict_reason": { + "name": "conflict_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conflict_payload": { + "name": "conflict_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "photo_asset_tenant_id_tenant_id_fk": { + "name": "photo_asset_tenant_id_tenant_id_fk", + "tableFrom": "photo_asset", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_photo_asset_tenant_storage_key": { + "name": "uq_photo_asset_tenant_storage_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "storage_key"] + }, + "uq_photo_asset_tenant_photo_id": { + "name": "uq_photo_asset_tenant_photo_id", + "nullsNotDistinct": false, + "columns": ["tenant_id", "photo_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ref_key": { + "name": "ref_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_reactions_tenant_ref_key": { + "name": "idx_reactions_tenant_ref_key", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reactions_tenant_id_tenant_id_fk": { + "name": "reactions_tenant_id_tenant_id_fk", + "tableFrom": "reactions", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_reactions_tenant_ref_key": { + "name": "uq_reactions_tenant_ref_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "ref_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_sensitive": { + "name": "is_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_tenant_id_tenant_id_fk": { + "name": "settings_tenant_id_tenant_id_fk", + "tableFrom": "settings", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_settings_tenant_key": { + "name": "uq_settings_tenant_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_setting": { + "name": "system_setting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "is_sensitive": { + "name": "is_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_system_setting_key": { + "name": "uq_system_setting_key", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_account": { + "name": "tenant_auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_account_tenant_id_tenant_id_fk": { + "name": "tenant_auth_account_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_account", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_auth_account_user_id_tenant_auth_user_id_fk": { + "name": "tenant_auth_account_user_id_tenant_auth_user_id_fk", + "tableFrom": "tenant_auth_account", + "tableTo": "tenant_auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_session": { + "name": "tenant_auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_session_tenant_id_tenant_id_fk": { + "name": "tenant_auth_session_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_auth_session_user_id_tenant_auth_user_id_fk": { + "name": "tenant_auth_session_user_id_tenant_auth_user_id_fk", + "tableFrom": "tenant_auth_session", + "tableTo": "tenant_auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_auth_session_token_unique": { + "name": "tenant_auth_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_user": { + "name": "tenant_auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'guest'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires_at": { + "name": "ban_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_user_tenant_id_tenant_id_fk": { + "name": "tenant_auth_user_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_auth_user_tenant_email": { + "name": "uq_tenant_auth_user_tenant_email", + "nullsNotDistinct": false, + "columns": ["tenant_id", "email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_domain": { + "name": "tenant_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_domain_tenant_id_tenant_id_fk": { + "name": "tenant_domain_tenant_id_tenant_id_fk", + "tableFrom": "tenant_domain", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_domain_domain": { + "name": "uq_tenant_domain_domain", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "tenant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "primary_domain": { + "name": "primary_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_slug": { + "name": "uq_tenant_slug", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.photo_sync_status": { + "name": "photo_sync_status", + "schema": "public", + "values": ["pending", "synced", "conflict"] + }, + "public.tenant_status": { + "name": "tenant_status", + "schema": "public", + "values": ["active", "inactive", "suspended"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["user", "admin", "superadmin"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/be/packages/db/migrations/meta/0006_snapshot.json b/be/packages/db/migrations/meta/0006_snapshot.json new file mode 100644 index 00000000..adc7292a --- /dev/null +++ b/be/packages/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,1118 @@ +{ + "id": "4c9bc1e3-3d3f-422f-af8f-0fb4583a2f90", + "prevId": "5d5a2288-9a09-4fe5-843d-6e9bf137c551", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "auth_session_tenant_id_tenant_id_fk": { + "name": "auth_session_tenant_id_tenant_id_fk", + "tableFrom": "auth_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auth_session_user_id_auth_user_id_fk": { + "name": "auth_session_user_id_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_session_token_unique": { + "name": "auth_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_user": { + "name": "auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires_at": { + "name": "ban_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_user_tenant_id_tenant_id_fk": { + "name": "auth_user_tenant_id_tenant_id_fk", + "tableFrom": "auth_user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_user_email_unique": { + "name": "auth_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photo_asset": { + "name": "photo_asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_id": { + "name": "photo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata_hash": { + "name": "metadata_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest_version": { + "name": "manifest_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'v7'" + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "photo_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "conflict_reason": { + "name": "conflict_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conflict_payload": { + "name": "conflict_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "photo_asset_tenant_id_tenant_id_fk": { + "name": "photo_asset_tenant_id_tenant_id_fk", + "tableFrom": "photo_asset", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_photo_asset_tenant_storage_key": { + "name": "uq_photo_asset_tenant_storage_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "storage_key"] + }, + "uq_photo_asset_tenant_photo_id": { + "name": "uq_photo_asset_tenant_photo_id", + "nullsNotDistinct": false, + "columns": ["tenant_id", "photo_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ref_key": { + "name": "ref_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_reactions_tenant_ref_key": { + "name": "idx_reactions_tenant_ref_key", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reactions_tenant_id_tenant_id_fk": { + "name": "reactions_tenant_id_tenant_id_fk", + "tableFrom": "reactions", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_reactions_tenant_ref_key": { + "name": "uq_reactions_tenant_ref_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "ref_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_sensitive": { + "name": "is_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_tenant_id_tenant_id_fk": { + "name": "settings_tenant_id_tenant_id_fk", + "tableFrom": "settings", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_settings_tenant_key": { + "name": "uq_settings_tenant_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_setting": { + "name": "system_setting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "is_sensitive": { + "name": "is_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_system_setting_key": { + "name": "uq_system_setting_key", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_account": { + "name": "tenant_auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_account_tenant_id_tenant_id_fk": { + "name": "tenant_auth_account_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_account", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_auth_account_user_id_tenant_auth_user_id_fk": { + "name": "tenant_auth_account_user_id_tenant_auth_user_id_fk", + "tableFrom": "tenant_auth_account", + "tableTo": "tenant_auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_session": { + "name": "tenant_auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_session_tenant_id_tenant_id_fk": { + "name": "tenant_auth_session_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_auth_session_user_id_tenant_auth_user_id_fk": { + "name": "tenant_auth_session_user_id_tenant_auth_user_id_fk", + "tableFrom": "tenant_auth_session", + "tableTo": "tenant_auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_auth_session_token_unique": { + "name": "tenant_auth_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_user": { + "name": "tenant_auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'guest'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires_at": { + "name": "ban_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_user_tenant_id_tenant_id_fk": { + "name": "tenant_auth_user_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_auth_user_tenant_email": { + "name": "uq_tenant_auth_user_tenant_email", + "nullsNotDistinct": false, + "columns": ["tenant_id", "email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "tenant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_slug": { + "name": "uq_tenant_slug", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.photo_sync_status": { + "name": "photo_sync_status", + "schema": "public", + "values": ["pending", "synced", "conflict"] + }, + "public.tenant_status": { + "name": "tenant_status", + "schema": "public", + "values": ["active", "inactive", "suspended"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["user", "admin", "superadmin"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/be/packages/db/migrations/meta/_journal.json b/be/packages/db/migrations/meta/_journal.json index 3879106c..cc6506c3 100644 --- a/be/packages/db/migrations/meta/_journal.json +++ b/be/packages/db/migrations/meta/_journal.json @@ -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 } ] } diff --git a/be/packages/db/src/schema.ts b/be/packages/db/src/schema.ts index 6bac6027..f8ec7fea 100644 --- a/be/packages/db/src/schema.ts +++ b/be/packages/db/src/schema.ts @@ -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, diff --git a/eslint.config.mjs b/eslint.config.mjs index 5e727c9b..a196aec2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c3b8c06d..99098e5c 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,4 +3,5 @@ export * from './cn' export * from './semaphore' export * from './spring' export * from './storage-provider' +export * from './tenant' export * from './u8array' diff --git a/packages/utils/src/tenant.ts b/packages/utils/src/tenant.ts new file mode 100644 index 00000000..dffeac75 --- /dev/null +++ b/packages/utils/src/tenant.ts @@ -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'