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:
Innei
2025-11-11 15:28:46 +08:00
parent 1957abede6
commit 88f763d2e2
28 changed files with 708 additions and 95 deletions

View 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
}
}

View File

@@ -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
}
}
}

View 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.',
})
}
}

View File

@@ -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
}

View 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)
}
}

View File

@@ -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()
}

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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,
}
}
}

View File

@@ -0,0 +1,6 @@
export const PLACEHOLDER_TENANT_NAME = 'Pending Workspace'
export {PLACEHOLDER_TENANT_SLUG} from '@afilmory/utils'

View File

@@ -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
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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
}

View 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)

View 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,
])

View File

@@ -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,

View 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])
}

View File

@@ -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(),
})
},
})

View 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' })
}

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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}`
}

View 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>
)
}

View File

@@ -53,3 +53,5 @@ export function isTenantSlugReserved(slug: string): boolean {
}
export const DEFAULT_BASE_DOMAIN = 'afilmory.art'
export const PLACEHOLDER_TENANT_SLUG = 'holding'