From 88f763d2e25f9abeda8545ddae394b6f219141da Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 11 Nov 2025 15:28:46 +0800 Subject: [PATCH] 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 --- .../decorators/allow-placeholder.decorator.ts | 31 ++++++ be/apps/core/src/guards/auth.guard.ts | 83 +++++++++------- .../src/guards/placeholder-tenant.guard.ts | 33 +++++++ be/apps/core/src/guards/roles.decorator.ts | 30 ++++-- be/apps/core/src/guards/roles.guard.ts | 56 +++++++++++ .../middlewares/request-context.middleware.ts | 3 +- .../site-setting.public.controller.ts | 2 + be/apps/core/src/modules/index.module.ts | 10 ++ .../auth/auth-registration.service.ts | 7 +- .../modules/platform/auth/auth.controller.ts | 60 +++++++++-- .../tenant/tenant-context-resolver.service.ts | 26 ++++- .../platform/tenant/tenant.constants.ts | 6 ++ .../modules/platform/tenant/tenant.context.ts | 12 +++ .../modules/platform/tenant/tenant.service.ts | 4 +- .../modules/platform/tenant/tenant.types.ts | 4 +- be/apps/dashboard/src/App.tsx | 39 +++++++- be/apps/dashboard/src/atoms/access-denied.ts | 24 +++++ be/apps/dashboard/src/constants/routes.ts | 16 +++ .../dashboard/src/hooks/usePageRedirect.ts | 52 ++++++++-- .../dashboard/src/hooks/useRoutePermission.ts | 99 +++++++++++++++++++ be/apps/dashboard/src/lib/api-client.ts | 19 ++++ .../src/modules/auth/api/permissions.ts | 13 +++ .../dashboard/src/modules/auth/api/session.ts | 7 ++ .../src/modules/auth/hooks/useLogin.ts | 20 +++- .../modules/auth/hooks/useRegisterTenant.ts | 27 +---- .../src/modules/auth/utils/domain.ts | 28 ++++++ be/apps/dashboard/src/pages/no-access.tsx | 90 +++++++++++++++++ packages/utils/src/tenant.ts | 2 + 28 files changed, 708 insertions(+), 95 deletions(-) create mode 100644 be/apps/core/src/decorators/allow-placeholder.decorator.ts create mode 100644 be/apps/core/src/guards/placeholder-tenant.guard.ts create mode 100644 be/apps/core/src/guards/roles.guard.ts create mode 100644 be/apps/core/src/modules/platform/tenant/tenant.constants.ts create mode 100644 be/apps/dashboard/src/atoms/access-denied.ts create mode 100644 be/apps/dashboard/src/constants/routes.ts create mode 100644 be/apps/dashboard/src/hooks/useRoutePermission.ts create mode 100644 be/apps/dashboard/src/modules/auth/api/permissions.ts create mode 100644 be/apps/dashboard/src/pages/no-access.tsx diff --git a/be/apps/core/src/decorators/allow-placeholder.decorator.ts b/be/apps/core/src/decorators/allow-placeholder.decorator.ts new file mode 100644 index 00000000..075f7344 --- /dev/null +++ b/be/apps/core/src/decorators/allow-placeholder.decorator.ts @@ -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 + } +} diff --git a/be/apps/core/src/guards/auth.guard.ts b/be/apps/core/src/guards/auth.guard.ts index 0b850f96..ec9716de 100644 --- a/be/apps/core/src/guards/auth.guard.ts +++ b/be/apps/core/src/guards/auth.guard.ts @@ -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 { 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 { + 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, - 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 { + 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 } } } diff --git a/be/apps/core/src/guards/placeholder-tenant.guard.ts b/be/apps/core/src/guards/placeholder-tenant.guard.ts new file mode 100644 index 00000000..d9f0382e --- /dev/null +++ b/be/apps/core/src/guards/placeholder-tenant.guard.ts @@ -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 { + 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.', + }) + } +} diff --git a/be/apps/core/src/guards/roles.decorator.ts b/be/apps/core/src/guards/roles.decorator.ts index b2342194..cbee8fda 100644 --- a/be/apps/core/src/guards/roles.decorator.ts +++ b/be/apps/core/src/guards/roles.decorator.ts @@ -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 } diff --git a/be/apps/core/src/guards/roles.guard.ts b/be/apps/core/src/guards/roles.guard.ts new file mode 100644 index 00000000..dc1f2f08 --- /dev/null +++ b/be/apps/core/src/guards/roles.guard.ts @@ -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, targetClass: object): number { + const handlerMask = getAllowedRoleMask(handler) + if (handlerMask !== 0) { + return handlerMask + } + return getAllowedRoleMask(targetClass) + } +} diff --git a/be/apps/core/src/middlewares/request-context.middleware.ts b/be/apps/core/src/middlewares/request-context.middleware.ts index a59e8ce4..ae07fdfd 100644 --- a/be/apps/core/src/middlewares/request-context.middleware.ts +++ b/be/apps/core/src/middlewares/request-context.middleware.ts @@ -19,7 +19,8 @@ export class RequestContextMiddleware implements HttpMiddleware { ) {} async use(context: Context, next: Next): Promise { - await Promise.all([this.ensureTenantContext(context), this.ensureAuthContext(context)]) + await this.ensureTenantContext(context) + await this.ensureAuthContext(context) return await next() } diff --git a/be/apps/core/src/modules/configuration/site-setting/site-setting.public.controller.ts b/be/apps/core/src/modules/configuration/site-setting/site-setting.public.controller.ts index 64740ecf..9bfdd6b4 100644 --- a/be/apps/core/src/modules/configuration/site-setting/site-setting.public.controller.ts +++ b/be/apps/core/src/modules/configuration/site-setting/site-setting.public.controller.ts @@ -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() { diff --git a/be/apps/core/src/modules/index.module.ts b/be/apps/core/src/modules/index.module.ts index 000a112c..8b01fb18 100644 --- a/be/apps/core/src/modules/index.module.ts +++ b/be/apps/core/src/modules/index.module.ts @@ -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, diff --git a/be/apps/core/src/modules/platform/auth/auth-registration.service.ts b/be/apps/core/src/modules/platform/auth/auth-registration.service.ts index a4890794..199740a5 100644 --- a/be/apps/core/src/modules/platform/auth/auth-registration.service.ts +++ b/be/apps/core/src/modules/platform/auth/auth-registration.service.ts @@ -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) { diff --git a/be/apps/core/src/modules/platform/auth/auth.controller.ts b/be/apps/core/src/modules/platform/auth/auth.controller.ts index 57d923bb..fa4e87d3 100644 --- a/be/apps/core/src/modules/platform/auth/auth.controller.ts +++ b/be/apps/core/src/modules/platform/auth/auth.controller.ts @@ -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) diff --git a/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts b/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts index 2de4ed0e..44064b37 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts @@ -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, + } + } } diff --git a/be/apps/core/src/modules/platform/tenant/tenant.constants.ts b/be/apps/core/src/modules/platform/tenant/tenant.constants.ts new file mode 100644 index 00000000..b5a42938 --- /dev/null +++ b/be/apps/core/src/modules/platform/tenant/tenant.constants.ts @@ -0,0 +1,6 @@ + + + +export const PLACEHOLDER_TENANT_NAME = 'Pending Workspace' + +export {PLACEHOLDER_TENANT_SLUG} from '@afilmory/utils' \ No newline at end of file diff --git a/be/apps/core/src/modules/platform/tenant/tenant.context.ts b/be/apps/core/src/modules/platform/tenant/tenant.context.ts index d140e373..66ab9894 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.context.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.context.ts @@ -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(options?: { @@ -16,3 +17,14 @@ export function getTenantContext(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 +} diff --git a/be/apps/core/src/modules/platform/tenant/tenant.service.ts b/be/apps/core/src/modules/platform/tenant/tenant.service.ts index e93fe287..01ec6a4c 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.service.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.service.ts @@ -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( diff --git a/be/apps/core/src/modules/platform/tenant/tenant.types.ts b/be/apps/core/src/modules/platform/tenant/tenant.types.ts index 9fa23bb6..27db7f5f 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.types.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.types.ts @@ -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 diff --git a/be/apps/dashboard/src/App.tsx b/be/apps/dashboard/src/App.tsx index e15aae36..d74643f8 100644 --- a/be/apps/dashboard/src/App.tsx +++ b/be/apps/dashboard/src/App.tsx @@ -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 ? : } +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 } diff --git a/be/apps/dashboard/src/atoms/access-denied.ts b/be/apps/dashboard/src/atoms/access-denied.ts new file mode 100644 index 00000000..14697869 --- /dev/null +++ b/be/apps/dashboard/src/atoms/access-denied.ts @@ -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(null) + +export const [ + accessDeniedAtom, + useAccessDenied, + useAccessDeniedValue, + useSetAccessDenied, + getAccessDenied, + setAccessDenied, +] = createAtomHooks(baseAccessDeniedAtom) diff --git a/be/apps/dashboard/src/constants/routes.ts b/be/apps/dashboard/src/constants/routes.ts new file mode 100644 index 00000000..021aea9c --- /dev/null +++ b/be/apps/dashboard/src/constants/routes.ts @@ -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([ + ROUTE_PATHS.LOGIN, + ROUTE_PATHS.WELCOME, + ROUTE_PATHS.TENANT_MISSING, + ROUTE_PATHS.NO_ACCESS, +]) diff --git a/be/apps/dashboard/src/hooks/usePageRedirect.ts b/be/apps/dashboard/src/hooks/usePageRedirect.ts index 0641ae5d..e48d9d0a 100644 --- a/be/apps/dashboard/src/hooks/usePageRedirect.ts +++ b/be/apps/dashboard/src/hooks/usePageRedirect.ts @@ -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 & { @@ -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, diff --git a/be/apps/dashboard/src/hooks/useRoutePermission.ts b/be/apps/dashboard/src/hooks/useRoutePermission.ts new file mode 100644 index 00000000..395b3f97 --- /dev/null +++ b/be/apps/dashboard/src/hooks/useRoutePermission.ts @@ -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 Promise> = { + 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]) +} diff --git a/be/apps/dashboard/src/lib/api-client.ts b/be/apps/dashboard/src/lib/api-client.ts index b8305c3d..1ab943b0 100644 --- a/be/apps/dashboard/src/lib/api-client.ts +++ b/be/apps/dashboard/src/lib/api-client.ts @@ -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(), + }) + }, }) diff --git a/be/apps/dashboard/src/modules/auth/api/permissions.ts b/be/apps/dashboard/src/modules/auth/api/permissions.ts new file mode 100644 index 00000000..be3130c4 --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/api/permissions.ts @@ -0,0 +1,13 @@ +import { coreApi } from '~/lib/api-client' + +type PermissionResponse = { + allowed: boolean +} + +export async function checkDashboardAccess(): Promise { + return coreApi('/auth/permissions/dashboard', { method: 'GET' }) +} + +export async function checkSuperAdminAccess(): Promise { + return coreApi('/auth/permissions/superadmin', { method: 'GET' }) +} diff --git a/be/apps/dashboard/src/modules/auth/api/session.ts b/be/apps/dashboard/src/modules/auth/api/session.ts index df4ba318..28fa6fd0 100644 --- a/be/apps/dashboard/src/modules/auth/api/session.ts +++ b/be/apps/dashboard/src/modules/auth/api/session.ts @@ -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 diff --git a/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts b/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts index 1e6e0b01..a4243c5d 100644 --- a/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts +++ b/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts @@ -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) => { diff --git a/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts b/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts index 7c193012..3ebdd020 100644 --- a/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts +++ b/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts @@ -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(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) { diff --git a/be/apps/dashboard/src/modules/auth/utils/domain.ts b/be/apps/dashboard/src/modules/auth/utils/domain.ts index b358b5ab..2cc4fc3a 100644 --- a/be/apps/dashboard/src/modules/auth/utils/domain.ts +++ b/be/apps/dashboard/src/modules/auth/utils/domain.ts @@ -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}` +} diff --git a/be/apps/dashboard/src/pages/no-access.tsx b/be/apps/dashboard/src/pages/no-access.tsx new file mode 100644 index 00000000..a76d2a72 --- /dev/null +++ b/be/apps/dashboard/src/pages/no-access.tsx @@ -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 ( +
+
+ +
+
+ +

{title}

+

{description}

+ {hint && ( +
+

+ 请求路径: {hint} +

+
+ )} +
+ + +
+
+
+
+
+
+
+ ) +} diff --git a/packages/utils/src/tenant.ts b/packages/utils/src/tenant.ts index 747b132e..9b4306f9 100644 --- a/packages/utils/src/tenant.ts +++ b/packages/utils/src/tenant.ts @@ -53,3 +53,5 @@ export function isTenantSlugReserved(slug: string): boolean { } export const DEFAULT_BASE_DOMAIN = 'afilmory.art' + +export const PLACEHOLDER_TENANT_SLUG = 'holding'