mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: implement placeholder tenant support across guards and decorators
- Introduced AllowPlaceholderTenant decorator to manage placeholder tenant access. - Added PlaceholderTenantGuard to enforce access rules for placeholder tenants. - Enhanced AuthGuard to handle placeholder tenant contexts and permissions. - Updated roles handling to support inheritance and added RolesGuard for role-based access control. - Integrated placeholder tenant logic into various controllers and services for consistent behavior. - Improved tenant context resolution to fallback to placeholder tenant when necessary. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
31
be/apps/core/src/decorators/allow-placeholder.decorator.ts
Normal file
31
be/apps/core/src/decorators/allow-placeholder.decorator.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
const ALLOW_PLACEHOLDER_TENANT_METADATA = Symbol.for('core.tenant.allow-placeholder')
|
||||
|
||||
type DecoratorTarget = object | Function
|
||||
|
||||
function setAllowPlaceholderMetadata(target: DecoratorTarget): void {
|
||||
Reflect.defineMetadata(ALLOW_PLACEHOLDER_TENANT_METADATA, true, target)
|
||||
}
|
||||
|
||||
export function AllowPlaceholderTenant(): ClassDecorator & MethodDecorator {
|
||||
return ((target: DecoratorTarget, _propertyKey?: string | symbol, descriptor?: PropertyDescriptor) => {
|
||||
if (descriptor?.value && typeof descriptor.value === 'function') {
|
||||
setAllowPlaceholderMetadata(descriptor.value)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
setAllowPlaceholderMetadata(target)
|
||||
return descriptor
|
||||
}) as unknown as ClassDecorator & MethodDecorator
|
||||
}
|
||||
|
||||
export function isPlaceholderTenantAllowed(target: DecoratorTarget | undefined): boolean {
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return (Reflect.getMetadata(ALLOW_PLACEHOLDER_TENANT_METADATA, target) ?? false) === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,24 @@ import { HttpContext } from '@afilmory/framework'
|
||||
import type { HttpContextAuth } from 'core/context/http-context.values'
|
||||
import { applyTenantIsolationContext, DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { getTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import type { AuthSession } from 'core/modules/platform/auth/auth.provider'
|
||||
import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { TenantService } from 'core/modules/platform/tenant/tenant.service'
|
||||
import type { TenantContext } from 'core/modules/platform/tenant/tenant.types'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { shouldSkipTenant } from '../decorators/skip-tenant.decorator'
|
||||
import { logger } from '../helpers/logger.helper'
|
||||
import type { AuthSession } from '../modules/auth/auth.provider'
|
||||
import { getAllowedRoleMask, roleNameToBit } from './roles.decorator'
|
||||
|
||||
@injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private readonly log = logger.extend('AuthGuard')
|
||||
|
||||
constructor(private readonly dbAccessor: DbAccessor) {}
|
||||
constructor(
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
private readonly tenantService: TenantService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const store = context.getContext()
|
||||
@@ -36,16 +39,23 @@ export class AuthGuard implements CanActivate {
|
||||
|
||||
this.log.verbose(`Evaluating guard for ${method} ${path}`)
|
||||
|
||||
const tenantContext = this.requireTenantContext(method, path)
|
||||
const tenantContext = await this.requireTenantContext(method, path)
|
||||
if (isPlaceholderTenantContext(tenantContext) && !this.isPlaceholderAllowedPath(path)) {
|
||||
this.log.warn(`Denied access: placeholder tenant cannot access ${method} ${path}`)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
|
||||
}
|
||||
|
||||
await this.enforceTenantOwnership(authContext, tenantContext, method, path)
|
||||
this.enforceRoleRequirements(handler, authContext, method, path)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private requireTenantContext(method: string, path: string) {
|
||||
const tenantContext = getTenantContext()
|
||||
private async requireTenantContext(method: string, path: string): Promise<TenantContext> {
|
||||
let tenantContext = getTenantContext()
|
||||
if (!tenantContext && this.isPlaceholderAllowedPath(path)) {
|
||||
tenantContext = (await this.createPlaceholderContext(method, path)) as TenantContext
|
||||
}
|
||||
|
||||
if (!tenantContext) {
|
||||
this.log.warn(`Tenant context not resolved for ${method} ${path}`)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
|
||||
@@ -120,35 +130,42 @@ export class AuthGuard implements CanActivate {
|
||||
return sessionTenantId
|
||||
}
|
||||
|
||||
private enforceRoleRequirements(
|
||||
handler: ReturnType<ExecutionContext['getHandler']>,
|
||||
authContext: HttpContextAuth | undefined,
|
||||
method: string,
|
||||
path: string,
|
||||
): void {
|
||||
const requiredMask = getAllowedRoleMask(handler)
|
||||
if (requiredMask === 0) {
|
||||
return
|
||||
private isPlaceholderAllowedPath(path: string): boolean {
|
||||
const normalizedPath = path?.trim() || ''
|
||||
if (!normalizedPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!authContext?.user || !authContext.session) {
|
||||
this.log.warn(`Denied access: missing session for protected resource ${method} ${path}`)
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
|
||||
if (normalizedPath === '/auth' || normalizedPath === '/auth/') {
|
||||
return true
|
||||
}
|
||||
if (normalizedPath.startsWith('/auth/')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const userRoleName = (authContext.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 ${(authContext.user as { id?: string }).id ?? 'unknown'} role=${userRoleName ?? 'n/a'} lacks permission mask=${requiredMask} on ${method} ${path}`,
|
||||
)
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN)
|
||||
if (normalizedPath === '/api/auth' || normalizedPath === '/api/auth/') {
|
||||
return true
|
||||
}
|
||||
if (normalizedPath.startsWith('/api/auth/')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private async createPlaceholderContext(method: string, path: string): Promise<TenantContext | null> {
|
||||
try {
|
||||
const placeholder = await this.tenantService.ensurePlaceholderTenant()
|
||||
const context: TenantContext = {
|
||||
tenant: placeholder.tenant,
|
||||
isPlaceholder: true,
|
||||
}
|
||||
HttpContext.setValue('tenant', context)
|
||||
this.log.verbose(`Placeholder tenant context injected for ${method} ${path}`)
|
||||
return context
|
||||
} catch (error) {
|
||||
this.log.error('Failed to inject placeholder tenant context', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
be/apps/core/src/guards/placeholder-tenant.guard.ts
Normal file
33
be/apps/core/src/guards/placeholder-tenant.guard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { CanActivate, ExecutionContext } from '@afilmory/framework'
|
||||
import { isPlaceholderTenantAllowed } from 'core/decorators/allow-placeholder.decorator'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { logger } from 'core/helpers/logger.helper'
|
||||
import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
@injectable()
|
||||
export class PlaceholderTenantGuard implements CanActivate {
|
||||
private readonly log = logger.extend('PlaceholderTenantGuard')
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const handler = context.getHandler()
|
||||
const targetClass = context.getClass()
|
||||
|
||||
if (isPlaceholderTenantAllowed(handler) || isPlaceholderTenantAllowed(targetClass)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tenantContext = getTenantContext()
|
||||
if (!tenantContext || !isPlaceholderTenantContext(tenantContext)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const store = context.getContext()
|
||||
const { hono } = store
|
||||
const { method, path } = hono.req
|
||||
this.log.warn(`Denied placeholder tenant access for ${method} ${path}`)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD, {
|
||||
message: 'Tenant context not available for this operation.',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import { applyDecorators } from '@afilmory/framework'
|
||||
export const ROLES_METADATA = Symbol.for('core.auth.allowed_roles')
|
||||
|
||||
export enum RoleBit {
|
||||
GUEST = 0,
|
||||
USER = 1 << 0,
|
||||
ADMIN = 1 << 1,
|
||||
SUPERADMIN = 1 << 2,
|
||||
GUEST = 1 << 0,
|
||||
USER = 1 << 1,
|
||||
ADMIN = 1 << 2,
|
||||
SUPERADMIN = 1 << 3,
|
||||
}
|
||||
|
||||
export type RoleName = 'user' | 'admin' | 'superadmin' | (string & {})
|
||||
@@ -14,14 +14,32 @@ export type RoleName = 'user' | 'admin' | 'superadmin' | (string & {})
|
||||
export function roleNameToBit(name?: RoleName): RoleBit {
|
||||
switch (name) {
|
||||
case 'superadmin': {
|
||||
return RoleBit.SUPERADMIN | RoleBit.ADMIN | RoleBit.USER | RoleBit.GUEST
|
||||
return RoleBit.SUPERADMIN
|
||||
}
|
||||
|
||||
case 'admin': {
|
||||
return RoleBit.ADMIN | RoleBit.USER | RoleBit.GUEST
|
||||
return RoleBit.ADMIN
|
||||
}
|
||||
|
||||
case 'user': {
|
||||
return RoleBit.USER
|
||||
}
|
||||
|
||||
default: {
|
||||
return RoleBit.GUEST
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function roleBitWithInheritance(bit: RoleBit): number {
|
||||
switch (bit) {
|
||||
case RoleBit.SUPERADMIN: {
|
||||
return RoleBit.SUPERADMIN | RoleBit.ADMIN | RoleBit.USER | RoleBit.GUEST
|
||||
}
|
||||
case RoleBit.ADMIN: {
|
||||
return RoleBit.ADMIN | RoleBit.USER | RoleBit.GUEST
|
||||
}
|
||||
case RoleBit.USER: {
|
||||
return RoleBit.USER | RoleBit.GUEST
|
||||
}
|
||||
|
||||
|
||||
56
be/apps/core/src/guards/roles.guard.ts
Normal file
56
be/apps/core/src/guards/roles.guard.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { CanActivate, ExecutionContext } from '@afilmory/framework'
|
||||
import { HttpContext } from '@afilmory/framework'
|
||||
import type { HttpContextAuth } from 'core/context/http-context.values'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { logger } from 'core/helpers/logger.helper'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { getAllowedRoleMask, roleBitWithInheritance, roleNameToBit } from './roles.decorator'
|
||||
|
||||
@injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
private readonly log = logger.extend('RolesGuard')
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const handler = context.getHandler()
|
||||
const targetClass = context.getClass()
|
||||
const store = context.getContext()
|
||||
const method = store?.hono?.req?.method ?? 'UNKNOWN'
|
||||
const path = store?.hono?.req?.path ?? 'UNKNOWN'
|
||||
const requiredMask = this.resolveRequiredMask(handler, targetClass)
|
||||
if (requiredMask === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const authContext = HttpContext.getValue('auth') as HttpContextAuth | undefined
|
||||
if (!authContext?.user || !authContext.session) {
|
||||
this.log.warn(`Denied access: missing session for role-protected resource ${method} ${path}`)
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const userRoleName = (authContext.user as { role?: string }).role as
|
||||
| 'user'
|
||||
| 'admin'
|
||||
| 'superadmin'
|
||||
| 'guest'
|
||||
| undefined
|
||||
const userMask = roleBitWithInheritance(roleNameToBit(userRoleName))
|
||||
const hasRole = (requiredMask & userMask) !== 0
|
||||
if (!hasRole) {
|
||||
this.log.warn(
|
||||
`Denied access: user ${(authContext.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 resolveRequiredMask(handler: ReturnType<ExecutionContext['getHandler']>, targetClass: object): number {
|
||||
const handlerMask = getAllowedRoleMask(handler)
|
||||
if (handlerMask !== 0) {
|
||||
return handlerMask
|
||||
}
|
||||
return getAllowedRoleMask(targetClass)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ export class RequestContextMiddleware implements HttpMiddleware {
|
||||
) {}
|
||||
|
||||
async use(context: Context, next: Next): Promise<Response | void> {
|
||||
await Promise.all([this.ensureTenantContext(context), this.ensureAuthContext(context)])
|
||||
await this.ensureTenantContext(context)
|
||||
await this.ensureAuthContext(context)
|
||||
return await next()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Controller, Get } from '@afilmory/framework'
|
||||
import { AllowPlaceholderTenant } from 'core/decorators/allow-placeholder.decorator'
|
||||
import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator'
|
||||
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
|
||||
|
||||
@@ -9,6 +10,7 @@ import { SiteSettingService } from './site-setting.service'
|
||||
export class SiteSettingPublicController {
|
||||
constructor(private readonly siteSettingService: SiteSettingService) {}
|
||||
|
||||
@AllowPlaceholderTenant()
|
||||
@Get('/welcome-schema')
|
||||
@BypassResponseTransform()
|
||||
async getWelcomeSchema() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, EventModule, Module } from '@afilmory/framework'
|
||||
import { AuthGuard } from 'core/guards/auth.guard'
|
||||
import { PlaceholderTenantGuard } from 'core/guards/placeholder-tenant.guard'
|
||||
import { RolesGuard } from 'core/guards/roles.guard'
|
||||
import { TenantResolverInterceptor } from 'core/interceptors/tenant-resolver.interceptor'
|
||||
import { CorsMiddleware } from 'core/middlewares/cors.middleware'
|
||||
import { DatabaseContextMiddleware } from 'core/middlewares/database-context.middleware'
|
||||
@@ -74,10 +76,18 @@ function createEventModuleOptions(redis: RedisAccessor) {
|
||||
useClass: DatabaseContextMiddleware,
|
||||
},
|
||||
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: PlaceholderTenantGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: AuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RolesGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: TenantResolverInterceptor,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SystemSettingService } from 'core/modules/configuration/system-setting/
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { getTenantContext } from '../tenant/tenant.context'
|
||||
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
|
||||
import { TenantRepository } from '../tenant/tenant.repository'
|
||||
import { TenantService } from '../tenant/tenant.service'
|
||||
import type { TenantRecord } from '../tenant/tenant.types'
|
||||
@@ -65,6 +65,7 @@ export class AuthRegistrationService {
|
||||
await this.systemSettings.ensureRegistrationAllowed()
|
||||
|
||||
const tenantContext = getTenantContext()
|
||||
const effectiveTenantContext = isPlaceholderTenantContext(tenantContext) ? null : tenantContext
|
||||
const account = input.account ? this.normalizeAccountInput(input.account) : null
|
||||
const useSessionAccount = input.useSessionAccount ?? false
|
||||
const sessionUser = this.getSessionUser()
|
||||
@@ -73,14 +74,14 @@ export class AuthRegistrationService {
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED, { message: '请先登录后再创建工作区' })
|
||||
}
|
||||
|
||||
if (tenantContext) {
|
||||
if (effectiveTenantContext) {
|
||||
if (useSessionAccount) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前租户上下文下不支持会话注册' })
|
||||
}
|
||||
if (!account) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少注册账号信息' })
|
||||
}
|
||||
return await this.registerExistingTenantMember(account, headers, tenantContext.tenant)
|
||||
return await this.registerExistingTenantMember(account, headers, effectiveTenantContext.tenant)
|
||||
}
|
||||
|
||||
if (!input.tenant) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { authUsers } from '@afilmory/db'
|
||||
import { Body, ContextParam, Controller, Get, HttpContext, Post } from '@afilmory/framework'
|
||||
import { freshSessionMiddleware } from 'better-auth/api'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { AllowPlaceholderTenant } from 'core/decorators/allow-placeholder.decorator'
|
||||
import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { RoleBit, Roles } from 'core/guards/roles.decorator'
|
||||
@@ -10,7 +11,8 @@ import { SystemSettingService } from 'core/modules/configuration/system-setting/
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { Context } from 'hono'
|
||||
|
||||
import { getTenantContext } from '../tenant/tenant.context'
|
||||
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
|
||||
import { TenantService } from '../tenant/tenant.service'
|
||||
import type { SocialProvidersConfig } from './auth.config'
|
||||
import { AuthProvider } from './auth.provider'
|
||||
import { AuthRegistrationService } from './auth-registration.service'
|
||||
@@ -103,25 +105,51 @@ export class AuthController {
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
private readonly systemSettings: SystemSettingService,
|
||||
private readonly registration: AuthRegistrationService,
|
||||
private readonly tenantService: TenantService,
|
||||
) {}
|
||||
|
||||
@AllowPlaceholderTenant()
|
||||
@Get('/session')
|
||||
@SkipTenantGuard()
|
||||
async getSession(@ContextParam() _context: Context) {
|
||||
const tenant = HttpContext.getValue('tenant')
|
||||
if (!tenant) {
|
||||
return null
|
||||
}
|
||||
let tenantContext = getTenantContext()
|
||||
const authContext = HttpContext.getValue('auth')
|
||||
|
||||
if (!authContext?.user || !authContext.session) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!tenantContext || isPlaceholderTenantContext(tenantContext)) {
|
||||
const {tenantId} = (authContext.user as { tenantId?: string | null })
|
||||
if (tenantId) {
|
||||
try {
|
||||
const aggregate = await this.tenantService.getById(tenantId)
|
||||
tenantContext = {
|
||||
tenant: aggregate.tenant,
|
||||
isPlaceholder: false,
|
||||
}
|
||||
} catch {
|
||||
// ignore; fallback to placeholder context if resolution fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!tenantContext) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
user: authContext.user,
|
||||
session: authContext.session,
|
||||
tenant: {
|
||||
id: tenantContext.tenant.id,
|
||||
slug: tenantContext.tenant.slug ?? null,
|
||||
isPlaceholder: isPlaceholderTenantContext(tenantContext),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@AllowPlaceholderTenant()
|
||||
@Get('/social/providers')
|
||||
@BypassResponseTransform()
|
||||
@SkipTenantGuard()
|
||||
@@ -201,6 +229,19 @@ export class AuthController {
|
||||
return result
|
||||
}
|
||||
|
||||
@Get('/permissions/dashboard')
|
||||
@Roles(RoleBit.ADMIN)
|
||||
checkDashboardPermission() {
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
@Get('/permissions/superadmin')
|
||||
@Roles(RoleBit.SUPERADMIN)
|
||||
checkSuperAdminPermission() {
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
@AllowPlaceholderTenant()
|
||||
@Post('/sign-in/email')
|
||||
async signInEmail(@ContextParam() context: Context, @Body() body: { email: string; password: string }) {
|
||||
const email = body.email.trim()
|
||||
@@ -237,6 +278,7 @@ export class AuthController {
|
||||
return response
|
||||
}
|
||||
|
||||
@AllowPlaceholderTenant()
|
||||
@Post('/social')
|
||||
async signInSocial(@ContextParam() context: Context, @Body() body: SocialSignInRequest) {
|
||||
const provider = body?.provider?.trim()
|
||||
@@ -269,6 +311,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@SkipTenantGuard()
|
||||
@AllowPlaceholderTenant()
|
||||
@Post('/sign-up/email')
|
||||
async signUpEmail(@ContextParam() context: Context, @Body() body: TenantSignUpRequest) {
|
||||
const useSessionAccount = body?.useSessionAccount ?? false
|
||||
@@ -278,10 +321,11 @@ export class AuthController {
|
||||
}
|
||||
|
||||
const tenantContext = getTenantContext()
|
||||
if (!tenantContext && !body.tenant) {
|
||||
const isPlaceholderTenant = isPlaceholderTenantContext(tenantContext)
|
||||
if ((!tenantContext || isPlaceholderTenant) && !body.tenant) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少租户信息' })
|
||||
}
|
||||
if (tenantContext && useSessionAccount) {
|
||||
if (tenantContext && !isPlaceholderTenant && useSessionAccount) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前操作不支持使用已登录账号' })
|
||||
}
|
||||
|
||||
@@ -324,11 +368,13 @@ export class AuthController {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@AllowPlaceholderTenant()
|
||||
@Get('/*')
|
||||
async passthroughGet(@ContextParam() context: Context) {
|
||||
return await this.auth.handler(context)
|
||||
}
|
||||
|
||||
@AllowPlaceholderTenant()
|
||||
@Post('/*')
|
||||
async passthroughPost(@ContextParam() context: Context) {
|
||||
return await this.auth.handler(context)
|
||||
|
||||
@@ -7,8 +7,9 @@ import { AppStateService } from 'core/modules/infrastructure/app-state/app-state
|
||||
import type { Context } from 'hono'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { PLACEHOLDER_TENANT_SLUG } from './tenant.constants'
|
||||
import { TenantService } from './tenant.service'
|
||||
import type { TenantContext } from './tenant.types'
|
||||
import type { TenantAggregate, TenantContext } from './tenant.types'
|
||||
|
||||
const HEADER_TENANT_ID = 'x-tenant-id'
|
||||
const HEADER_TENANT_SLUG = 'x-tenant-slug'
|
||||
@@ -65,7 +66,7 @@ export class TenantContextResolver {
|
||||
`Resolve tenant for request ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'}, id=${tenantId ?? 'n/a'}, slug=${derivedSlug ?? 'n/a'})`,
|
||||
)
|
||||
|
||||
const tenantContext = await this.tenantService.resolve(
|
||||
let tenantContext = await this.tenantService.resolve(
|
||||
{
|
||||
tenantId,
|
||||
slug: derivedSlug,
|
||||
@@ -73,6 +74,16 @@ export class TenantContextResolver {
|
||||
true,
|
||||
)
|
||||
|
||||
if (!tenantContext && this.shouldFallbackToPlaceholder(tenantId, derivedSlug)) {
|
||||
const placeholder = await this.tenantService.ensurePlaceholderTenant()
|
||||
tenantContext = this.asTenantContext(placeholder, true)
|
||||
this.log.verbose(
|
||||
`Applied placeholder tenant context for ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'})`,
|
||||
)
|
||||
} else if (tenantContext) {
|
||||
tenantContext = this.asTenantContext(tenantContext, tenantContext.tenant.slug === PLACEHOLDER_TENANT_SLUG)
|
||||
}
|
||||
|
||||
if (!tenantContext) {
|
||||
if (options.throwOnMissing && (tenantId || derivedSlug)) {
|
||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||
@@ -170,4 +181,15 @@ export class TenantContextResolver {
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private shouldFallbackToPlaceholder(tenantId?: string, slug?: string): boolean {
|
||||
return !(tenantId && tenantId.length > 0) && !(slug && slug.length > 0)
|
||||
}
|
||||
|
||||
private asTenantContext(source: TenantAggregate, isPlaceholder: boolean): TenantContext {
|
||||
return {
|
||||
tenant: source.tenant,
|
||||
isPlaceholder,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
|
||||
|
||||
export const PLACEHOLDER_TENANT_NAME = 'Pending Workspace'
|
||||
|
||||
export {PLACEHOLDER_TENANT_SLUG} from '@afilmory/utils'
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpContext } from '@afilmory/framework'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
|
||||
import { PLACEHOLDER_TENANT_SLUG } from './tenant.constants'
|
||||
import type { TenantContext } from './tenant.types'
|
||||
|
||||
export function getTenantContext<TRequired extends boolean = false>(options?: {
|
||||
@@ -16,3 +17,14 @@ export function getTenantContext<TRequired extends boolean = false>(options?: {
|
||||
export function requireTenantContext(): TenantContext {
|
||||
return getTenantContext({ required: true })
|
||||
}
|
||||
|
||||
export function isPlaceholderTenantContext(context?: TenantContext | null): boolean {
|
||||
if (!context) {
|
||||
return false
|
||||
}
|
||||
if (context.isPlaceholder) {
|
||||
return true
|
||||
}
|
||||
const slug = context.tenant.slug?.toLowerCase()
|
||||
return slug === PLACEHOLDER_TENANT_SLUG
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@ import { BizException, ErrorCode } from 'core/errors'
|
||||
import { AppStateService } from 'core/modules/infrastructure/app-state/app-state.service'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { PLACEHOLDER_TENANT_NAME, PLACEHOLDER_TENANT_SLUG } from './tenant.constants'
|
||||
import { TenantRepository } from './tenant.repository'
|
||||
import type { TenantAggregate, TenantContext, TenantResolutionInput } from './tenant.types'
|
||||
|
||||
const PLACEHOLDER_TENANT_SLUG = 'holding'
|
||||
const PLACEHOLDER_TENANT_NAME = 'Pending Workspace'
|
||||
|
||||
@injectable()
|
||||
export class TenantService {
|
||||
constructor(
|
||||
|
||||
@@ -7,7 +7,9 @@ export interface TenantAggregate {
|
||||
tenant: TenantRecord
|
||||
}
|
||||
|
||||
export type TenantContext = TenantAggregate
|
||||
export interface TenantContext extends TenantAggregate {
|
||||
readonly isPlaceholder?: boolean
|
||||
}
|
||||
|
||||
export interface TenantResolutionInput {
|
||||
tenantId?: string | null
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { FC } from 'react'
|
||||
import { Outlet } from 'react-router'
|
||||
import type {FC} from 'react';
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router'
|
||||
|
||||
import { useAccessDeniedValue } from '~/atoms/access-denied'
|
||||
import { ROUTE_PATHS } from '~/constants/routes'
|
||||
import { usePageRedirect } from '~/hooks/usePageRedirect'
|
||||
import { useRoutePermission } from '~/hooks/useRoutePermission'
|
||||
|
||||
import { RootProviders } from './providers/root-providers'
|
||||
|
||||
@@ -14,12 +18,41 @@ export const App: FC = () => {
|
||||
}
|
||||
|
||||
function AppLayer() {
|
||||
usePageRedirect()
|
||||
const pageRedirect = usePageRedirect()
|
||||
useRoutePermission({
|
||||
session: pageRedirect.sessionQuery.data ?? null,
|
||||
isLoading: pageRedirect.sessionQuery.isPending,
|
||||
})
|
||||
useAccessDeniedRedirect()
|
||||
|
||||
const appIsReady = true
|
||||
return appIsReady ? <Outlet /> : <AppSkeleton />
|
||||
}
|
||||
|
||||
function useAccessDeniedRedirect() {
|
||||
const accessDenied = useAccessDeniedValue()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessDenied?.active) {
|
||||
return
|
||||
}
|
||||
if (location.pathname === ROUTE_PATHS.NO_ACCESS) {
|
||||
return
|
||||
}
|
||||
|
||||
navigate(ROUTE_PATHS.NO_ACCESS, {
|
||||
replace: true,
|
||||
state: {
|
||||
from: accessDenied.path ?? location.pathname,
|
||||
reason: accessDenied.reason ?? null,
|
||||
status: accessDenied.status ?? 403,
|
||||
},
|
||||
})
|
||||
}, [accessDenied, location.pathname, navigate])
|
||||
}
|
||||
|
||||
function AppSkeleton() {
|
||||
return null
|
||||
}
|
||||
|
||||
24
be/apps/dashboard/src/atoms/access-denied.ts
Normal file
24
be/apps/dashboard/src/atoms/access-denied.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
import { createAtomHooks } from '~/lib/jotai'
|
||||
|
||||
export type AccessDeniedState = {
|
||||
active: boolean
|
||||
status?: number
|
||||
path?: string
|
||||
scope?: 'admin' | 'superadmin' | string
|
||||
reason?: string | null
|
||||
source?: 'route' | 'api'
|
||||
timestamp: number
|
||||
} | null
|
||||
|
||||
const baseAccessDeniedAtom = atom<AccessDeniedState>(null)
|
||||
|
||||
export const [
|
||||
accessDeniedAtom,
|
||||
useAccessDenied,
|
||||
useAccessDeniedValue,
|
||||
useSetAccessDenied,
|
||||
getAccessDenied,
|
||||
setAccessDenied,
|
||||
] = createAtomHooks(baseAccessDeniedAtom)
|
||||
16
be/apps/dashboard/src/constants/routes.ts
Normal file
16
be/apps/dashboard/src/constants/routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const ROUTE_PATHS = {
|
||||
LOGIN: '/login',
|
||||
WELCOME: '/welcome',
|
||||
TENANT_MISSING: '/tenant-missing',
|
||||
DEFAULT_AUTHENTICATED: '/',
|
||||
SUPERADMIN_ROOT: '/superadmin',
|
||||
SUPERADMIN_DEFAULT: '/superadmin/settings',
|
||||
NO_ACCESS: '/no-access',
|
||||
} as const
|
||||
|
||||
export const PUBLIC_ROUTES = new Set<string>([
|
||||
ROUTE_PATHS.LOGIN,
|
||||
ROUTE_PATHS.WELCOME,
|
||||
ROUTE_PATHS.TENANT_MISSING,
|
||||
ROUTE_PATHS.NO_ACCESS,
|
||||
])
|
||||
@@ -4,23 +4,23 @@ import { useCallback, useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
|
||||
import { useSetAuthUser } from '~/atoms/auth'
|
||||
import { PUBLIC_ROUTES, ROUTE_PATHS } from '~/constants/routes'
|
||||
import type { SessionResponse } from '~/modules/auth/api/session'
|
||||
import { AUTH_SESSION_QUERY_KEY, fetchSession } from '~/modules/auth/api/session'
|
||||
import { signOutBySource } from '~/modules/auth/auth-client'
|
||||
|
||||
const DEFAULT_LOGIN_PATH = '/login'
|
||||
const DEFAULT_WELCOME_PATH = '/welcome'
|
||||
const TENANT_MISSING_PATH = '/tenant-missing'
|
||||
const DEFAULT_AUTHENTICATED_PATH = '/'
|
||||
const SUPERADMIN_ROOT_PATH = '/superadmin'
|
||||
const SUPERADMIN_DEFAULT_PATH = '/superadmin/settings'
|
||||
import { buildTenantUrl, getTenantSlugFromHost } from '~/modules/auth/utils/domain'
|
||||
|
||||
const AUTH_FAILURE_STATUSES = new Set([401, 403, 419])
|
||||
const AUTH_TENANT_NOT_FOUND_ERROR_CODE = 12
|
||||
const TENANT_NOT_FOUND_ERROR_CODE = 20
|
||||
const TENANT_MISSING_ERROR_CODES = new Set([AUTH_TENANT_NOT_FOUND_ERROR_CODE, TENANT_NOT_FOUND_ERROR_CODE])
|
||||
|
||||
const PUBLIC_PATHS = new Set([DEFAULT_LOGIN_PATH, DEFAULT_WELCOME_PATH, TENANT_MISSING_PATH])
|
||||
const {
|
||||
LOGIN: DEFAULT_LOGIN_PATH,
|
||||
TENANT_MISSING: TENANT_MISSING_PATH,
|
||||
DEFAULT_AUTHENTICATED: DEFAULT_AUTHENTICATED_PATH,
|
||||
SUPERADMIN_ROOT: SUPERADMIN_ROOT_PATH,
|
||||
SUPERADMIN_DEFAULT: SUPERADMIN_DEFAULT_PATH,
|
||||
} = ROUTE_PATHS
|
||||
|
||||
type BizErrorPayload = { code?: number | string }
|
||||
type FetchErrorWithPayload = FetchError<BizErrorPayload> & {
|
||||
@@ -143,7 +143,7 @@ export function usePageRedirect() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!session && !PUBLIC_PATHS.has(pathname)) {
|
||||
if (!session && !PUBLIC_ROUTES.has(pathname)) {
|
||||
navigate(DEFAULT_LOGIN_PATH, { replace: true })
|
||||
return
|
||||
}
|
||||
@@ -153,6 +153,38 @@ export function usePageRedirect() {
|
||||
}
|
||||
}, [location, location.pathname, navigate, sessionQuery.data, sessionQuery.isError, sessionQuery.isPending])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionQuery.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
const session = sessionQuery.data
|
||||
if (!session || session.user.role === 'superadmin') {
|
||||
return
|
||||
}
|
||||
|
||||
const {tenant} = session
|
||||
if (!tenant || tenant.isPlaceholder || !tenant.slug) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const currentSlug = getTenantSlugFromHost(window.location.hostname)
|
||||
if (currentSlug && currentSlug === tenant.slug) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const targetUrl = buildTenantUrl(tenant.slug, '/')
|
||||
window.location.replace(targetUrl)
|
||||
} catch (error) {
|
||||
console.error('Failed to redirect to tenant workspace', error)
|
||||
}
|
||||
}, [sessionQuery.data, sessionQuery.isPending])
|
||||
|
||||
return {
|
||||
sessionQuery,
|
||||
|
||||
|
||||
99
be/apps/dashboard/src/hooks/useRoutePermission.ts
Normal file
99
be/apps/dashboard/src/hooks/useRoutePermission.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { FetchError } from 'ofetch'
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
import { useSetAccessDenied } from '~/atoms/access-denied'
|
||||
import { PUBLIC_ROUTES, ROUTE_PATHS } from '~/constants/routes'
|
||||
import { checkDashboardAccess, checkSuperAdminAccess } from '~/modules/auth/api/permissions'
|
||||
import type { SessionResponse } from '~/modules/auth/api/session'
|
||||
|
||||
type PermissionScope = 'admin' | 'superadmin'
|
||||
|
||||
const permissionCheckers: Record<PermissionScope, () => Promise<unknown>> = {
|
||||
admin: checkDashboardAccess,
|
||||
superadmin: checkSuperAdminAccess,
|
||||
}
|
||||
|
||||
function getPermissionScope(pathname: string): PermissionScope | null {
|
||||
if (!pathname) {
|
||||
return null
|
||||
}
|
||||
if (PUBLIC_ROUTES.has(pathname)) {
|
||||
return null
|
||||
}
|
||||
if (pathname.startsWith(ROUTE_PATHS.SUPERADMIN_ROOT)) {
|
||||
return 'superadmin'
|
||||
}
|
||||
return 'admin'
|
||||
}
|
||||
|
||||
type UseRoutePermissionArgs = {
|
||||
session: SessionResponse | null
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function useRoutePermission({ session, isLoading }: UseRoutePermissionArgs) {
|
||||
const location = useLocation()
|
||||
const setAccessDenied = useSetAccessDenied()
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
|
||||
const pathname = location.pathname || '/'
|
||||
if (pathname === ROUTE_PATHS.NO_ACCESS) {
|
||||
return
|
||||
}
|
||||
|
||||
const scope = getPermissionScope(pathname)
|
||||
if (!scope) {
|
||||
setAccessDenied((prev) => (prev?.source === 'api' ? prev : null))
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
|
||||
permissionCheckers[scope]()
|
||||
.then(() => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setAccessDenied((prev) => {
|
||||
if (prev?.source === 'api') {
|
||||
return prev
|
||||
}
|
||||
return null
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
if (error instanceof FetchError && error.statusCode === 403) {
|
||||
const reason =
|
||||
(error.data as { message?: string } | undefined)?.message ??
|
||||
error.response?._data?.message ??
|
||||
'您没有权限访问该页面'
|
||||
setAccessDenied({
|
||||
active: true,
|
||||
status: 403,
|
||||
path: pathname,
|
||||
scope,
|
||||
reason,
|
||||
source: 'route',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
console.error('Failed to verify route permission', error)
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [isLoading, location.pathname, session, setAccessDenied])
|
||||
}
|
||||
@@ -1,8 +1,27 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
import { getAccessDenied, setAccessDenied } from '~/atoms/access-denied'
|
||||
|
||||
export const coreApiBaseURL = import.meta.env.VITE_APP_API_BASE?.replace(/\/$/, '') || '/api'
|
||||
|
||||
export const coreApi = $fetch.create({
|
||||
baseURL: coreApiBaseURL,
|
||||
credentials: 'include',
|
||||
onResponseError({ response }) {
|
||||
if (response?.status !== 403) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = getAccessDenied()
|
||||
const detail = (response._data as { message?: string } | undefined)?.message ?? current?.reason ?? null
|
||||
setAccessDenied({
|
||||
active: true,
|
||||
status: 403,
|
||||
path: typeof window !== 'undefined' ? window.location.pathname : current?.path,
|
||||
scope: current?.scope ?? 'api',
|
||||
reason: detail,
|
||||
source: 'api',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
13
be/apps/dashboard/src/modules/auth/api/permissions.ts
Normal file
13
be/apps/dashboard/src/modules/auth/api/permissions.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
|
||||
type PermissionResponse = {
|
||||
allowed: boolean
|
||||
}
|
||||
|
||||
export async function checkDashboardAccess(): Promise<PermissionResponse> {
|
||||
return coreApi<PermissionResponse>('/auth/permissions/dashboard', { method: 'GET' })
|
||||
}
|
||||
|
||||
export async function checkSuperAdminAccess(): Promise<PermissionResponse> {
|
||||
return coreApi<PermissionResponse>('/auth/permissions/superadmin', { method: 'GET' })
|
||||
}
|
||||
@@ -2,9 +2,16 @@ import { coreApi } from '~/lib/api-client'
|
||||
|
||||
import type { BetterAuthSession, BetterAuthUser } from '../types'
|
||||
|
||||
export interface SessionTenant {
|
||||
id: string
|
||||
slug: string | null
|
||||
isPlaceholder: boolean
|
||||
}
|
||||
|
||||
export type SessionResponse = {
|
||||
user: BetterAuthUser
|
||||
session: BetterAuthSession
|
||||
tenant: SessionTenant | null
|
||||
}
|
||||
|
||||
export const AUTH_SESSION_QUERY_KEY = ['auth', 'session'] as const
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router'
|
||||
|
||||
import { useSetAuthUser } from '~/atoms/auth'
|
||||
import { AUTH_SESSION_QUERY_KEY, fetchSession } from '~/modules/auth/api/session'
|
||||
import { buildTenantUrl, getTenantSlugFromHost } from '~/modules/auth/utils/domain'
|
||||
|
||||
import { signInAuth } from '../auth-client'
|
||||
|
||||
@@ -40,7 +41,24 @@ export function useLogin() {
|
||||
queryClient.setQueryData(AUTH_SESSION_QUERY_KEY, session)
|
||||
setAuthUser(session.user)
|
||||
setErrorMessage(null)
|
||||
const destination = session.user.role === 'superadmin' ? '/superadmin/settings' : '/'
|
||||
|
||||
const {tenant} = session
|
||||
const isSuperAdmin = session.user.role === 'superadmin'
|
||||
|
||||
if (tenant && !tenant.isPlaceholder && tenant.slug) {
|
||||
const currentSlug = typeof window !== 'undefined' ? getTenantSlugFromHost(window.location.hostname) : null
|
||||
if (!isSuperAdmin && tenant.slug !== currentSlug) {
|
||||
try {
|
||||
const targetUrl = buildTenantUrl(tenant.slug, '/')
|
||||
window.location.replace(targetUrl)
|
||||
return
|
||||
} catch (redirectError) {
|
||||
console.error('Failed to redirect to tenant workspace after login:', redirectError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const destination = isSuperAdmin ? '/superadmin/settings' : '/'
|
||||
navigate(destination, { replace: true })
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react'
|
||||
|
||||
import type { RegisterTenantPayload } from '~/modules/auth/api/registerTenant'
|
||||
import { registerTenant } from '~/modules/auth/api/registerTenant'
|
||||
import { resolveBaseDomain } from '~/modules/auth/utils/domain'
|
||||
import { buildTenantUrl } from '~/modules/auth/utils/domain'
|
||||
|
||||
import type { TenantSiteFieldKey } from './useRegistrationForm'
|
||||
|
||||
@@ -14,29 +14,6 @@ interface TenantRegistrationRequest {
|
||||
settings: Array<{ key: TenantSiteFieldKey; value: string }>
|
||||
}
|
||||
|
||||
function buildTenantLoginUrl(slug: string): string {
|
||||
const normalizedSlug = slug.trim().toLowerCase()
|
||||
if (!normalizedSlug) {
|
||||
throw new Error('Registration succeeded but a workspace slug was not returned.')
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
const baseDomain = resolveBaseDomain(hostname)
|
||||
|
||||
if (!baseDomain) {
|
||||
throw new Error('Unable to resolve base domain for workspace login redirect.')
|
||||
}
|
||||
|
||||
const shouldAppendPort = Boolean(
|
||||
port && (baseDomain === 'localhost' || hostname === baseDomain || hostname.endsWith(`.${baseDomain}`)),
|
||||
)
|
||||
|
||||
const portSegment = shouldAppendPort ? `:${port}` : ''
|
||||
const scheme = protocol || 'https:'
|
||||
|
||||
return `${scheme}//${normalizedSlug}.${baseDomain}${portSegment}/login`
|
||||
}
|
||||
|
||||
export function useRegisterTenant() {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
@@ -70,7 +47,7 @@ export function useRegisterTenant() {
|
||||
},
|
||||
onSuccess: ({ slug }) => {
|
||||
try {
|
||||
const loginUrl = buildTenantLoginUrl(slug)
|
||||
const loginUrl = buildTenantUrl(slug, '/login')
|
||||
setErrorMessage(null)
|
||||
window.location.replace(loginUrl)
|
||||
} catch (redirectError) {
|
||||
|
||||
@@ -58,3 +58,31 @@ export function getTenantSlugFromHost(hostname: string): string | null {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildTenantUrl(slug: string, path = '/'): string {
|
||||
const normalizedSlug = slug?.trim().toLowerCase() ?? ''
|
||||
if (!normalizedSlug) {
|
||||
throw new Error('Workspace slug is required to build tenant URL.')
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
throw new TypeError('Cannot build tenant URL outside the browser environment.')
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
const baseDomain = resolveBaseDomain(hostname)
|
||||
|
||||
if (!baseDomain) {
|
||||
throw new Error('Unable to resolve base domain for tenant URL.')
|
||||
}
|
||||
|
||||
const shouldAppendPort = Boolean(
|
||||
port && (baseDomain === 'localhost' || hostname === baseDomain || hostname.endsWith(`.${baseDomain}`)),
|
||||
)
|
||||
|
||||
const portSegment = shouldAppendPort ? `:${port}` : ''
|
||||
const scheme = protocol || 'https:'
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
|
||||
return `${scheme}//${normalizedSlug}.${baseDomain}${portSegment}${normalizedPath}`
|
||||
}
|
||||
|
||||
90
be/apps/dashboard/src/pages/no-access.tsx
Normal file
90
be/apps/dashboard/src/pages/no-access.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Button } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { m } from 'motion/react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
|
||||
import { useAccessDeniedValue, useSetAccessDenied } from '~/atoms/access-denied'
|
||||
import { useSetAuthUser } from '~/atoms/auth'
|
||||
import { ROUTE_PATHS } from '~/constants/routes'
|
||||
import { AUTH_SESSION_QUERY_KEY } from '~/modules/auth/api/session'
|
||||
import { signOutBySource } from '~/modules/auth/auth-client'
|
||||
import { LinearBorderContainer } from '~/modules/welcome/components/LinearBorderContainer'
|
||||
|
||||
export const Component: FC = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const accessDenied = useAccessDeniedValue()
|
||||
const setAccessDenied = useSetAccessDenied()
|
||||
const setAuthUser = useSetAuthUser()
|
||||
|
||||
const state = (location.state ?? {}) as { from?: string; reason?: string | null; status?: number }
|
||||
const originPath = state.from ?? accessDenied?.path ?? ROUTE_PATHS.DEFAULT_AUTHENTICATED
|
||||
const status = state.status ?? accessDenied?.status ?? 403
|
||||
const reason = state.reason ?? accessDenied?.reason
|
||||
|
||||
const title = status === 403 ? '权限不足' : '无法访问'
|
||||
const description =
|
||||
reason ?? '你当前的账号没有访问该功能所需的权限,请联系工作区管理员,或切换到拥有权限的账号后重试。'
|
||||
|
||||
const hint = useMemo(() => {
|
||||
if (!originPath || originPath === ROUTE_PATHS.NO_ACCESS) {
|
||||
return null
|
||||
}
|
||||
return originPath
|
||||
}, [originPath])
|
||||
|
||||
const handleBackToLogin = async () => {
|
||||
try {
|
||||
await signOutBySource()
|
||||
} catch (error) {
|
||||
console.error('Failed to sign out before returning to login', error)
|
||||
} finally {
|
||||
queryClient.setQueryData(AUTH_SESSION_QUERY_KEY, null)
|
||||
void queryClient.invalidateQueries({ queryKey: AUTH_SESSION_QUERY_KEY })
|
||||
setAuthUser(null)
|
||||
setAccessDenied(null)
|
||||
navigate(ROUTE_PATHS.LOGIN, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
setAccessDenied(null)
|
||||
navigate(hint ?? ROUTE_PATHS.DEFAULT_AUTHENTICATED, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-1 flex-col">
|
||||
<div className="bg-background flex flex-1 items-center justify-center">
|
||||
<LinearBorderContainer>
|
||||
<div className="bg-background-tertiary relative w-[600px]">
|
||||
<div className="p-12">
|
||||
<m.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={Spring.presets.smooth}>
|
||||
<h1 className="text-text mb-4 text-3xl font-bold">{title}</h1>
|
||||
<p className="text-text-secondary mb-6 text-base leading-relaxed">{description}</p>
|
||||
{hint && (
|
||||
<div className="bg-material-medium border-fill-tertiary mb-6 rounded-lg border px-4 py-3">
|
||||
<p className="text-text-secondary text-sm">
|
||||
请求路径: <span className="text-text font-medium">{hint}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button variant="primary" className="flex-1" onClick={handleRetry}>
|
||||
重新尝试
|
||||
</Button>
|
||||
<Button variant="ghost" className="flex-1" onClick={handleBackToLogin}>
|
||||
返回登录
|
||||
</Button>
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
</div>
|
||||
</LinearBorderContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -53,3 +53,5 @@ export function isTenantSlugReserved(slug: string): boolean {
|
||||
}
|
||||
|
||||
export const DEFAULT_BASE_DOMAIN = 'afilmory.art'
|
||||
|
||||
export const PLACEHOLDER_TENANT_SLUG = 'holding'
|
||||
|
||||
Reference in New Issue
Block a user