feat(errors): add new error codes for internal server errors and reserved tenant slugs

- Introduced COMMON_INTERNAL_SERVER_ERROR and TENANT_SLUG_RESERVED error codes to enhance error handling.
- Updated ERROR_CODE_DESCRIPTORS to include HTTP status and messages for the new error codes.
- Refactored various services and controllers to replace references to SuperAdminSettingService with SystemSettingService for improved consistency and clarity.
- Removed SuperAdminSettingService and its associated types, consolidating functionality into SystemSettingService.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-08 21:36:01 +08:00
parent 8d0e5dfe6b
commit 5fe57599d7
20 changed files with 533 additions and 508 deletions

View File

@@ -5,6 +5,7 @@ export enum ErrorCode {
COMMON_NOT_FOUND = 3,
COMMON_CONFLICT = 4,
COMMON_RATE_LIMITED = 5,
COMMON_INTERNAL_SERVER_ERROR = 6,
// Auth
AUTH_UNAUTHORIZED = 10,
@@ -16,6 +17,7 @@ export enum ErrorCode {
TENANT_NOT_FOUND = 20,
TENANT_SUSPENDED = 21,
TENANT_INACTIVE = 22,
TENANT_SLUG_RESERVED = 23,
// Image Processing
IMAGE_PROCESSING_FAILED = 30,
@@ -48,6 +50,10 @@ export const ERROR_CODE_DESCRIPTORS: Record<ErrorCode, ErrorDescriptor> = {
httpStatus: 429,
message: 'Too many requests',
},
[ErrorCode.COMMON_INTERNAL_SERVER_ERROR]: {
httpStatus: 500,
message: 'Internal server error',
},
[ErrorCode.AUTH_UNAUTHORIZED]: {
httpStatus: 401,
message: 'Unauthorized',
@@ -76,6 +82,10 @@ export const ERROR_CODE_DESCRIPTORS: Record<ErrorCode, ErrorDescriptor> = {
httpStatus: 403,
message: 'Tenant is not active',
},
[ErrorCode.TENANT_SLUG_RESERVED]: {
httpStatus: 400,
message: 'Tenant slug is reserved',
},
[ErrorCode.IMAGE_PROCESSING_FAILED]: {
httpStatus: 500,

View File

@@ -8,7 +8,7 @@ import { SETTING_SCHEMAS } from '../setting/setting.constant'
import type { SettingEntryInput } from '../setting/setting.service'
import { SettingService } from '../setting/setting.service'
import type { SettingKeyType } from '../setting/setting.type'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { SystemSettingService } from '../system-setting/system-setting.service'
import { getTenantContext } from '../tenant/tenant.context'
import { TenantRepository } from '../tenant/tenant.repository'
import { TenantService } from '../tenant/tenant.service'
@@ -53,13 +53,13 @@ export class AuthRegistrationService {
private readonly authProvider: AuthProvider,
private readonly tenantService: TenantService,
private readonly tenantRepository: TenantRepository,
private readonly superAdminSettings: SuperAdminSettingService,
private readonly systemSettings: SystemSettingService,
private readonly settingService: SettingService,
private readonly dbAccessor: DbAccessor,
) {}
async registerTenant(input: RegisterTenantInput, headers: Headers): Promise<RegisterTenantResult> {
await this.superAdminSettings.ensureRegistrationAllowed()
await this.systemSettings.ensureRegistrationAllowed()
const tenantContext = getTenantContext()
const account = this.normalizeAccountInput(input.account)
@@ -268,7 +268,7 @@ export class AuthRegistrationService {
initialSettings.map((entry) => ({
...entry,
options: {
tenantId,
...(tenantId ? { tenantId } : {}),
isSensitive: false,
},
})),

View File

@@ -1,6 +1,6 @@
import { injectable } from 'tsyringe'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { SystemSettingService } from '../system-setting/system-setting.service'
export interface SocialProviderOptions {
clientId: string
@@ -22,11 +22,11 @@ export interface AuthModuleOptions {
@injectable()
export class AuthConfig {
constructor(private readonly superAdminSettings: SuperAdminSettingService) {}
constructor(private readonly systemSettings: SystemSettingService) {}
async getOptions(): Promise<AuthModuleOptions> {
const prefix = '/auth'
const { socialProviders, baseDomain } = await this.superAdminSettings.getAuthModuleConfig()
const { socialProviders, baseDomain } = await this.systemSettings.getAuthModuleConfig()
return {
prefix,

View File

@@ -6,7 +6,7 @@ import type { Context } from 'hono'
import { DbAccessor } from '../../database/database.provider'
import { RoleBit, Roles } from '../../guards/roles.decorator'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { SystemSettingService } from '../system-setting/system-setting.service'
import { getTenantContext } from '../tenant/tenant.context'
import type { SocialProvidersConfig } from './auth.config'
import { AuthProvider } from './auth.provider'
@@ -76,7 +76,7 @@ export class AuthController {
constructor(
private readonly auth: AuthProvider,
private readonly dbAccessor: DbAccessor,
private readonly superAdminSettings: SuperAdminSettingService,
private readonly systemSettings: SystemSettingService,
private readonly registration: AuthRegistrationService,
) {}
@@ -94,7 +94,7 @@ export class AuthController {
@Get('/social/providers')
async getSocialProviders() {
const { socialProviders } = await this.superAdminSettings.getAuthModuleConfig()
const { socialProviders } = await this.systemSettings.getAuthModuleConfig()
return { providers: buildProviderResponse(socialProviders) }
}
@@ -104,7 +104,7 @@ export class AuthController {
if (email.length === 0) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '邮箱不能为空' })
}
const settings = await this.superAdminSettings.getSettings()
const settings = await this.systemSettings.getSettings()
if (!settings.localProviderEnabled) {
const db = this.dbAccessor.get()
const [record] = await db

View File

@@ -10,7 +10,7 @@ import type { Context } from 'hono'
import { injectable } from 'tsyringe'
import { DrizzleProvider } from '../../database/database.provider'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { SystemSettingService } from '../system-setting/system-setting.service'
import type { AuthModuleOptions, SocialProviderOptions, SocialProvidersConfig } from './auth.config'
import { AuthConfig } from './auth.config'
@@ -26,7 +26,7 @@ export class AuthProvider implements OnModuleInit {
constructor(
private readonly config: AuthConfig,
private readonly drizzleProvider: DrizzleProvider,
private readonly superAdminSettings: SuperAdminSettingService,
private readonly systemSettings: SystemSettingService,
) {}
async onModuleInit(): Promise<void> {
@@ -236,7 +236,7 @@ export class AuthProvider implements OnModuleInit {
}
try {
await this.superAdminSettings.ensureRegistrationAllowed()
await this.systemSettings.ensureRegistrationAllowed()
} catch (error) {
if (error instanceof BizException) {
throw new APIError('FORBIDDEN', {

View File

@@ -2,24 +2,24 @@ import { Body, Controller, Get, Patch } from '@afilmory/framework'
import { Roles } from 'core/guards/roles.decorator'
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { SystemSettingService } from '../system-setting/system-setting.service'
import { UpdateSuperAdminSettingsDto } from './super-admin.dto'
@Controller('super-admin/settings')
@Roles('superadmin')
export class SuperAdminSettingController {
constructor(private readonly superAdminSettings: SuperAdminSettingService) {}
constructor(private readonly systemSettings: SystemSettingService) {}
@Get('/')
@BypassResponseTransform()
async getOverview() {
return await this.superAdminSettings.getOverview()
return await this.systemSettings.getOverview()
}
@Patch('/')
@BypassResponseTransform()
async update(@Body() dto: UpdateSuperAdminSettingsDto) {
await this.superAdminSettings.updateSettings(dto)
return await this.superAdminSettings.getOverview()
await this.systemSettings.updateSettings(dto)
return await this.systemSettings.getOverview()
}
}

View File

@@ -1,342 +0,0 @@
import { authUsers } from '@afilmory/db'
import { BizException, ErrorCode } from 'core/errors'
import { sql } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import type { ZodType } from 'zod'
import { DbAccessor } from '../../database/database.provider'
import type { SocialProvidersConfig } from '../auth/auth.config'
import { SUPER_ADMIN_SETTING_DEFINITIONS, SUPER_ADMIN_SETTING_KEYS } from './super-admin-setting.constants'
import type {
SuperAdminSettingField,
SuperAdminSettings,
SuperAdminSettingsOverview,
SuperAdminSettingsStats,
UpdateSuperAdminSettingsInput,
} from './super-admin-setting.types'
import { SUPER_ADMIN_SETTING_UI_SCHEMA } from './super-admin-setting.ui-schema'
import { SystemSettingService } from './system-setting.service'
@injectable()
export class SuperAdminSettingService {
constructor(
private readonly systemSettingService: SystemSettingService,
private readonly dbAccessor: DbAccessor,
) {}
async getSettings(): Promise<SuperAdminSettings> {
const rawValues = await this.systemSettingService.getMany(SUPER_ADMIN_SETTING_KEYS)
const allowRegistration = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.allowRegistration.key],
SUPER_ADMIN_SETTING_DEFINITIONS.allowRegistration.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.allowRegistration.defaultValue,
)
const maxRegistrableUsers = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.maxRegistrableUsers.key],
SUPER_ADMIN_SETTING_DEFINITIONS.maxRegistrableUsers.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.maxRegistrableUsers.defaultValue,
)
const localProviderEnabled = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.localProviderEnabled.key],
SUPER_ADMIN_SETTING_DEFINITIONS.localProviderEnabled.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.localProviderEnabled.defaultValue,
)
const baseDomainRaw = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.key],
SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.defaultValue,
)
const baseDomain = baseDomainRaw.trim().toLowerCase()
const oauthGoogleClientId = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientId.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientId.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientId.defaultValue,
)
const oauthGoogleClientSecret = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientSecret.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientSecret.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientSecret.defaultValue,
)
const oauthGoogleRedirectUri = this.normalizeRedirectPath(
this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleRedirectUri.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleRedirectUri.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleRedirectUri.defaultValue,
),
)
const oauthGithubClientId = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientId.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientId.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientId.defaultValue,
)
const oauthGithubClientSecret = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientSecret.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientSecret.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientSecret.defaultValue,
)
const oauthGithubRedirectUri = this.normalizeRedirectPath(
this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubRedirectUri.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubRedirectUri.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubRedirectUri.defaultValue,
),
)
return {
allowRegistration,
maxRegistrableUsers,
localProviderEnabled,
baseDomain,
oauthGoogleClientId,
oauthGoogleClientSecret,
oauthGoogleRedirectUri,
oauthGithubClientId,
oauthGithubClientSecret,
oauthGithubRedirectUri,
}
}
async getStats(): Promise<SuperAdminSettingsStats> {
const settings = await this.getSettings()
const totalUsers = await this.getTotalUserCount()
return this.buildStats(settings, totalUsers)
}
async getOverview(): Promise<SuperAdminSettingsOverview> {
const values = await this.getSettings()
const totalUsers = await this.getTotalUserCount()
const stats = this.buildStats(values, totalUsers)
return {
schema: SUPER_ADMIN_SETTING_UI_SCHEMA,
values,
stats,
}
}
async updateSettings(patch: UpdateSuperAdminSettingsInput): Promise<SuperAdminSettings> {
if (!patch || Object.values(patch).every((value) => value === undefined)) {
return await this.getSettings()
}
const current = await this.getSettings()
const updates: Array<{ field: SuperAdminSettingField; value: SuperAdminSettings[SuperAdminSettingField] }> = []
const enqueueUpdate = <K extends SuperAdminSettingField>(field: K, value: SuperAdminSettings[K]) => {
updates.push({ field, value })
current[field] = value
}
if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) {
enqueueUpdate('allowRegistration', patch.allowRegistration)
}
if (patch.localProviderEnabled !== undefined && patch.localProviderEnabled !== current.localProviderEnabled) {
enqueueUpdate('localProviderEnabled', patch.localProviderEnabled)
}
if (patch.maxRegistrableUsers !== undefined) {
const normalized = patch.maxRegistrableUsers === null ? null : Math.max(0, Math.trunc(patch.maxRegistrableUsers))
if (normalized !== current.maxRegistrableUsers) {
if (normalized !== null) {
const totalUsers = await this.getTotalUserCount()
if (normalized < totalUsers) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '最大可注册用户数不能小于当前用户总数',
})
}
}
enqueueUpdate('maxRegistrableUsers', normalized)
}
}
if (patch.baseDomain !== undefined) {
const sanitized = patch.baseDomain === null ? null : String(patch.baseDomain).trim().toLowerCase()
if (!sanitized) {
enqueueUpdate('baseDomain', SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.defaultValue)
} else if (sanitized !== current.baseDomain) {
enqueueUpdate('baseDomain', sanitized)
}
}
if (patch.oauthGoogleClientId !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientId)
if (sanitized !== current.oauthGoogleClientId) {
enqueueUpdate('oauthGoogleClientId', sanitized)
}
}
if (patch.oauthGoogleClientSecret !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientSecret)
if (sanitized !== current.oauthGoogleClientSecret) {
enqueueUpdate('oauthGoogleClientSecret', sanitized)
}
}
if (patch.oauthGoogleRedirectUri !== undefined) {
const sanitized = this.normalizeRedirectPath(patch.oauthGoogleRedirectUri)
if (sanitized !== current.oauthGoogleRedirectUri) {
enqueueUpdate('oauthGoogleRedirectUri', sanitized)
}
}
if (patch.oauthGithubClientId !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGithubClientId)
if (sanitized !== current.oauthGithubClientId) {
enqueueUpdate('oauthGithubClientId', sanitized)
}
}
if (patch.oauthGithubClientSecret !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGithubClientSecret)
if (sanitized !== current.oauthGithubClientSecret) {
enqueueUpdate('oauthGithubClientSecret', sanitized)
}
}
if (patch.oauthGithubRedirectUri !== undefined) {
const sanitized = this.normalizeRedirectPath(patch.oauthGithubRedirectUri)
if (sanitized !== current.oauthGithubRedirectUri) {
enqueueUpdate('oauthGithubRedirectUri', sanitized)
}
}
if (updates.length === 0) {
return current
}
await this.systemSettingService.setMany(
updates.map((entry) => {
const definition = SUPER_ADMIN_SETTING_DEFINITIONS[entry.field]
return {
key: definition.key,
value: (entry.value ?? null) as SuperAdminSettings[typeof entry.field] | null,
options: { isSensitive: definition.isSensitive ?? false },
}
}),
)
return current
}
async ensureRegistrationAllowed(): Promise<void> {
const settings = await this.getSettings()
if (!settings.allowRegistration) {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '当前已关闭用户注册,请联系管理员获取访问权限。',
})
}
if (settings.maxRegistrableUsers === null) {
return
}
const totalUsers = await this.getTotalUserCount()
if (totalUsers >= settings.maxRegistrableUsers) {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '用户注册数量已达到上限,请联系管理员。',
})
}
}
async getAuthModuleConfig(): Promise<{ baseDomain: string; socialProviders: SocialProvidersConfig }> {
const settings = await this.getSettings()
return {
baseDomain: settings.baseDomain,
socialProviders: this.buildSocialProviders(settings),
}
}
private parseSetting<T>(raw: unknown, schema: ZodType<T>, defaultValue: T): T {
if (raw === null || raw === undefined) {
return defaultValue
}
const parsed = schema.safeParse(raw)
return parsed.success ? parsed.data : defaultValue
}
private buildSocialProviders(settings: SuperAdminSettings): SocialProvidersConfig {
const providers: SocialProvidersConfig = {}
if (settings.oauthGoogleClientId && settings.oauthGoogleClientSecret) {
providers.google = {
clientId: settings.oauthGoogleClientId,
clientSecret: settings.oauthGoogleClientSecret,
redirectPath: settings.oauthGoogleRedirectUri,
}
}
if (settings.oauthGithubClientId && settings.oauthGithubClientSecret) {
providers.github = {
clientId: settings.oauthGithubClientId,
clientSecret: settings.oauthGithubClientSecret,
redirectPath: settings.oauthGithubRedirectUri,
}
}
return providers
}
private normalizeNullableString(value: string | null | undefined): string | null {
if (value === undefined || value === null) {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
private normalizeRedirectPath(value: string | null | undefined): string | null {
if (value === undefined || value === null) {
return null
}
const trimmed = value.trim()
if (trimmed.length === 0) {
return null
}
const ensureLeadingSlash = (input: string): string | null => {
if (!input.startsWith('/')) {
return null
}
return input
}
try {
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
const url = new URL(trimmed)
const pathWithQuery = `${url.pathname}${url.search ?? ''}`
return ensureLeadingSlash(pathWithQuery) ?? null
}
} catch {
// fall through to path handling
}
return ensureLeadingSlash(trimmed)
}
private buildStats(settings: SuperAdminSettings, totalUsers: number): SuperAdminSettingsStats {
const remaining =
settings.maxRegistrableUsers === null ? null : Math.max(settings.maxRegistrableUsers - totalUsers, 0)
return {
totalUsers,
registrationsRemaining: remaining,
}
}
private async getTotalUserCount(): Promise<number> {
const db = this.dbAccessor.get()
const [row] = await db.select({ total: sql<number>`count(*)` }).from(authUsers)
return typeof row?.total === 'number' ? row.total : Number(row?.total ?? 0)
}
}

View File

@@ -1,34 +0,0 @@
import type { UiSchema } from '../ui-schema/ui-schema.type'
import type { SuperAdminSettingField } from './super-admin-setting.constants'
export interface SuperAdminSettings {
allowRegistration: boolean
maxRegistrableUsers: number | null
localProviderEnabled: boolean
baseDomain: string
oauthGoogleClientId: string | null
oauthGoogleClientSecret: string | null
oauthGoogleRedirectUri: string | null
oauthGithubClientId: string | null
oauthGithubClientSecret: string | null
oauthGithubRedirectUri: string | null
}
export type SuperAdminSettingValueMap = {
[K in SuperAdminSettingField]: SuperAdminSettings[K]
}
export interface SuperAdminSettingsStats {
totalUsers: number
registrationsRemaining: number | null
}
export interface SuperAdminSettingsOverview {
schema: UiSchema<SuperAdminSettingField>
values: SuperAdminSettingValueMap
stats: SuperAdminSettingsStats
}
export type UpdateSuperAdminSettingsInput = Partial<SuperAdminSettings>
export { type SuperAdminSettingField } from './super-admin-setting.constants'

View File

@@ -5,7 +5,7 @@ const nonEmptyString = z.string().trim().min(1)
const nullableNonEmptyString = nonEmptyString.nullable()
const nullableUrl = z.string().trim().url({ message: '必须是有效的 URL' }).nullable()
export const SUPER_ADMIN_SETTING_DEFINITIONS = {
export const SYSTEM_SETTING_DEFINITIONS = {
allowRegistration: {
key: 'system.registration.allow',
schema: z.boolean(),
@@ -74,9 +74,7 @@ export const SUPER_ADMIN_SETTING_DEFINITIONS = {
},
} as const
export type SuperAdminSettingField = keyof typeof SUPER_ADMIN_SETTING_DEFINITIONS
export type SuperAdminSettingKey = (typeof SUPER_ADMIN_SETTING_DEFINITIONS)[SuperAdminSettingField]['key']
export type SystemSettingField = keyof typeof SYSTEM_SETTING_DEFINITIONS
export type SystemSettingKey = (typeof SYSTEM_SETTING_DEFINITIONS)[SystemSettingField]['key']
export const SUPER_ADMIN_SETTING_KEYS = Object.values(SUPER_ADMIN_SETTING_DEFINITIONS).map(
(definition) => definition.key,
)
export const SYSTEM_SETTING_KEYS = Object.values(SYSTEM_SETTING_DEFINITIONS).map((definition) => definition.key)

View File

@@ -1,11 +1,12 @@
import { Module } from '@afilmory/framework'
import { DatabaseModule } from '../../database/database.module'
import { SuperAdminSettingService } from './super-admin-setting.service'
import { SystemSettingService } from './system-setting.service'
import { SystemSettingStore } from './system-setting.store.service'
@Module({
imports: [DatabaseModule],
providers: [SystemSettingService, SuperAdminSettingService],
providers: [SystemSettingStore, SystemSettingService],
exports: [SystemSettingStore, SystemSettingService],
})
export class SystemSettingModule {}

View File

@@ -1,90 +1,344 @@
import { systemSettings } from '@afilmory/db'
import { eq, inArray } from 'drizzle-orm'
import { authUsers } from '@afilmory/db'
import { BizException, ErrorCode } from 'core/errors'
import { sql } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import type { ZodType } from 'zod'
import { DbAccessor } from '../../database/database.provider'
import type { SocialProvidersConfig } from '../auth/auth.config'
import { SYSTEM_SETTING_DEFINITIONS, SYSTEM_SETTING_KEYS } from './system-setting.constants'
import { SystemSettingStore } from './system-setting.store.service'
import type {
SystemSettingEntryInput,
SystemSettingKey,
SystemSettingRecord,
SystemSettingSetOptions,
SystemSettingField,
SystemSettingOverview,
SystemSettings,
SystemSettingStats,
UpdateSystemSettingsInput,
} from './system-setting.types'
import { SYSTEM_SETTING_UI_SCHEMA } from './system-setting.ui-schema'
@injectable()
export class SystemSettingService {
constructor(private readonly dbAccessor: DbAccessor) {}
constructor(
private readonly systemSettingStore: SystemSettingStore,
private readonly dbAccessor: DbAccessor,
) {}
async get(key: SystemSettingKey): Promise<SystemSettingRecord['value']> {
const record = await this.find(key)
return record?.value ?? null
}
async getSettings(): Promise<SystemSettings> {
const rawValues = await this.systemSettingStore.getMany(SYSTEM_SETTING_KEYS)
async getMany(keys: readonly SystemSettingKey[]): Promise<Record<SystemSettingKey, SystemSettingRecord['value']>> {
if (keys.length === 0) {
return {} as Record<SystemSettingKey, SystemSettingRecord['value']>
}
const uniqueKeys = Array.from(new Set(keys))
const db = this.dbAccessor.get()
const records = await db.select().from(systemSettings).where(inArray(systemSettings.key, uniqueKeys))
const map = new Map<SystemSettingKey, SystemSettingRecord>(records.map((record) => [record.key, record]))
return uniqueKeys.reduce(
(acc, key) => {
acc[key] = map.get(key)?.value ?? null
return acc
},
Object.create(null) as Record<SystemSettingKey, SystemSettingRecord['value']>,
const allowRegistration = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.allowRegistration.key],
SYSTEM_SETTING_DEFINITIONS.allowRegistration.schema,
SYSTEM_SETTING_DEFINITIONS.allowRegistration.defaultValue,
)
}
async set(
key: SystemSettingKey,
value: SystemSettingRecord['value'],
options: SystemSettingSetOptions = {},
): Promise<void> {
const existing = await this.find(key)
const db = this.dbAccessor.get()
const maxRegistrableUsers = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.key],
SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.schema,
SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.defaultValue,
)
const sanitizedValue = value ?? null
const isSensitive = options.isSensitive ?? existing?.isSensitive ?? false
const description = options.description ?? existing?.description ?? null
const now = new Date().toISOString()
const localProviderEnabled = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.localProviderEnabled.key],
SYSTEM_SETTING_DEFINITIONS.localProviderEnabled.schema,
SYSTEM_SETTING_DEFINITIONS.localProviderEnabled.defaultValue,
)
await db
.insert(systemSettings)
.values({
key,
value: sanitizedValue,
isSensitive,
description,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: sanitizedValue,
isSensitive,
description,
updatedAt: now,
},
})
}
const baseDomainRaw = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.baseDomain.key],
SYSTEM_SETTING_DEFINITIONS.baseDomain.schema,
SYSTEM_SETTING_DEFINITIONS.baseDomain.defaultValue,
)
async setMany(entries: readonly SystemSettingEntryInput[]): Promise<void> {
for (const entry of entries) {
await this.set(entry.key, entry.value ?? null, entry.options ?? {})
const baseDomain = baseDomainRaw.trim().toLowerCase()
const oauthGoogleClientId = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientId.key],
SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientId.schema,
SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientId.defaultValue,
)
const oauthGoogleClientSecret = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientSecret.key],
SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientSecret.schema,
SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientSecret.defaultValue,
)
const oauthGoogleRedirectUri = this.normalizeRedirectPath(
this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGoogleRedirectUri.key],
SYSTEM_SETTING_DEFINITIONS.oauthGoogleRedirectUri.schema,
SYSTEM_SETTING_DEFINITIONS.oauthGoogleRedirectUri.defaultValue,
),
)
const oauthGithubClientId = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGithubClientId.key],
SYSTEM_SETTING_DEFINITIONS.oauthGithubClientId.schema,
SYSTEM_SETTING_DEFINITIONS.oauthGithubClientId.defaultValue,
)
const oauthGithubClientSecret = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.key],
SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.schema,
SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.defaultValue,
)
const oauthGithubRedirectUri = this.normalizeRedirectPath(
this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGithubRedirectUri.key],
SYSTEM_SETTING_DEFINITIONS.oauthGithubRedirectUri.schema,
SYSTEM_SETTING_DEFINITIONS.oauthGithubRedirectUri.defaultValue,
),
)
return {
allowRegistration,
maxRegistrableUsers,
localProviderEnabled,
baseDomain,
oauthGoogleClientId,
oauthGoogleClientSecret,
oauthGoogleRedirectUri,
oauthGithubClientId,
oauthGithubClientSecret,
oauthGithubRedirectUri,
}
}
async delete(key: SystemSettingKey): Promise<void> {
const db = this.dbAccessor.get()
await db.delete(systemSettings).where(eq(systemSettings.key, key))
async getStats(): Promise<SystemSettingStats> {
const settings = await this.getSettings()
const totalUsers = await this.getTotalUserCount()
return this.buildStats(settings, totalUsers)
}
private async find(key: SystemSettingKey): Promise<SystemSettingRecord | null> {
const db = this.dbAccessor.get()
const [record] = await db.select().from(systemSettings).where(eq(systemSettings.key, key)).limit(1)
async getOverview(): Promise<SystemSettingOverview> {
const values = await this.getSettings()
const totalUsers = await this.getTotalUserCount()
const stats = this.buildStats(values, totalUsers)
return {
schema: SYSTEM_SETTING_UI_SCHEMA,
values,
stats,
}
}
return record ?? null
async updateSettings(patch: UpdateSystemSettingsInput): Promise<SystemSettings> {
if (!patch || Object.values(patch).every((value) => value === undefined)) {
return await this.getSettings()
}
const current = await this.getSettings()
const updates: Array<{ field: SystemSettingField; value: SystemSettings[SystemSettingField] }> = []
const enqueueUpdate = <K extends SystemSettingField>(field: K, value: SystemSettings[K]) => {
updates.push({ field, value })
current[field] = value
}
if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) {
enqueueUpdate('allowRegistration', patch.allowRegistration)
}
if (patch.localProviderEnabled !== undefined && patch.localProviderEnabled !== current.localProviderEnabled) {
enqueueUpdate('localProviderEnabled', patch.localProviderEnabled)
}
if (patch.maxRegistrableUsers !== undefined) {
const normalized = patch.maxRegistrableUsers === null ? null : Math.max(0, Math.trunc(patch.maxRegistrableUsers))
if (normalized !== current.maxRegistrableUsers) {
if (normalized !== null) {
const totalUsers = await this.getTotalUserCount()
if (normalized < totalUsers) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '最大可注册用户数不能小于当前用户总数',
})
}
}
enqueueUpdate('maxRegistrableUsers', normalized)
}
}
if (patch.baseDomain !== undefined) {
const sanitized = patch.baseDomain === null ? null : String(patch.baseDomain).trim().toLowerCase()
if (!sanitized) {
enqueueUpdate('baseDomain', SYSTEM_SETTING_DEFINITIONS.baseDomain.defaultValue)
} else if (sanitized !== current.baseDomain) {
enqueueUpdate('baseDomain', sanitized)
}
}
if (patch.oauthGoogleClientId !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientId)
if (sanitized !== current.oauthGoogleClientId) {
enqueueUpdate('oauthGoogleClientId', sanitized)
}
}
if (patch.oauthGoogleClientSecret !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientSecret)
if (sanitized !== current.oauthGoogleClientSecret) {
enqueueUpdate('oauthGoogleClientSecret', sanitized)
}
}
if (patch.oauthGoogleRedirectUri !== undefined) {
const sanitized = this.normalizeRedirectPath(patch.oauthGoogleRedirectUri)
if (sanitized !== current.oauthGoogleRedirectUri) {
enqueueUpdate('oauthGoogleRedirectUri', sanitized)
}
}
if (patch.oauthGithubClientId !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGithubClientId)
if (sanitized !== current.oauthGithubClientId) {
enqueueUpdate('oauthGithubClientId', sanitized)
}
}
if (patch.oauthGithubClientSecret !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGithubClientSecret)
if (sanitized !== current.oauthGithubClientSecret) {
enqueueUpdate('oauthGithubClientSecret', sanitized)
}
}
if (patch.oauthGithubRedirectUri !== undefined) {
const sanitized = this.normalizeRedirectPath(patch.oauthGithubRedirectUri)
if (sanitized !== current.oauthGithubRedirectUri) {
enqueueUpdate('oauthGithubRedirectUri', sanitized)
}
}
if (updates.length === 0) {
return current
}
await this.systemSettingStore.setMany(
updates.map((entry) => {
const definition = SYSTEM_SETTING_DEFINITIONS[entry.field]
return {
key: definition.key,
value: (entry.value ?? null) as SystemSettings[typeof entry.field] | null,
options: { isSensitive: definition.isSensitive ?? false },
}
}),
)
return current
}
async ensureRegistrationAllowed(): Promise<void> {
const settings = await this.getSettings()
if (!settings.allowRegistration) {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '当前已关闭用户注册,请联系管理员获取访问权限。',
})
}
if (settings.maxRegistrableUsers === null) {
return
}
const totalUsers = await this.getTotalUserCount()
if (totalUsers >= settings.maxRegistrableUsers) {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '用户注册数量已达到上限,请联系管理员。',
})
}
}
async getAuthModuleConfig(): Promise<{ baseDomain: string; socialProviders: SocialProvidersConfig }> {
const settings = await this.getSettings()
return {
baseDomain: settings.baseDomain,
socialProviders: this.buildSocialProviders(settings),
}
}
private parseSetting<T>(raw: unknown, schema: ZodType<T>, defaultValue: T): T {
if (raw === null || raw === undefined) {
return defaultValue
}
const parsed = schema.safeParse(raw)
return parsed.success ? parsed.data : defaultValue
}
private buildSocialProviders(settings: SystemSettings): SocialProvidersConfig {
const providers: SocialProvidersConfig = {}
if (settings.oauthGoogleClientId && settings.oauthGoogleClientSecret) {
providers.google = {
clientId: settings.oauthGoogleClientId,
clientSecret: settings.oauthGoogleClientSecret,
redirectPath: settings.oauthGoogleRedirectUri,
}
}
if (settings.oauthGithubClientId && settings.oauthGithubClientSecret) {
providers.github = {
clientId: settings.oauthGithubClientId,
clientSecret: settings.oauthGithubClientSecret,
redirectPath: settings.oauthGithubRedirectUri,
}
}
return providers
}
private normalizeNullableString(value: string | null | undefined): string | null {
if (value === undefined || value === null) {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
private normalizeRedirectPath(value: string | null | undefined): string | null {
if (value === undefined || value === null) {
return null
}
const trimmed = value.trim()
if (trimmed.length === 0) {
return null
}
const ensureLeadingSlash = (input: string): string | null => {
if (!input.startsWith('/')) {
return null
}
return input
}
try {
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
const url = new URL(trimmed)
const pathWithQuery = `${url.pathname}${url.search ?? ''}`
return ensureLeadingSlash(pathWithQuery) ?? null
}
} catch {
// fall through to path handling
}
return ensureLeadingSlash(trimmed)
}
private buildStats(settings: SystemSettings, totalUsers: number): SystemSettingStats {
const remaining =
settings.maxRegistrableUsers === null ? null : Math.max(settings.maxRegistrableUsers - totalUsers, 0)
return {
totalUsers,
registrationsRemaining: remaining,
}
}
private async getTotalUserCount(): Promise<number> {
const [row] = await this.dbAccessor
.get()
.select({ total: sql<number>`count(*)` })
.from(authUsers)
return typeof row?.total === 'number' ? row.total : Number(row?.total ?? 0)
}
}

View File

@@ -0,0 +1,90 @@
import { systemSettings } from '@afilmory/db'
import { eq, inArray } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { DbAccessor } from '../../database/database.provider'
import type {
SystemSettingEntryInput,
SystemSettingKey,
SystemSettingRecord,
SystemSettingSetOptions,
} from './system-setting.store.types'
@injectable()
export class SystemSettingStore {
constructor(private readonly dbAccessor: DbAccessor) {}
async get(key: SystemSettingKey): Promise<SystemSettingRecord['value']> {
const record = await this.find(key)
return record?.value ?? null
}
async getMany(keys: readonly SystemSettingKey[]): Promise<Record<SystemSettingKey, SystemSettingRecord['value']>> {
if (keys.length === 0) {
return {} as Record<SystemSettingKey, SystemSettingRecord['value']>
}
const uniqueKeys = Array.from(new Set(keys))
const db = this.dbAccessor.get()
const records = await db.select().from(systemSettings).where(inArray(systemSettings.key, uniqueKeys))
const map = new Map<SystemSettingKey, SystemSettingRecord>(records.map((record) => [record.key, record]))
return uniqueKeys.reduce(
(acc, key) => {
acc[key] = map.get(key)?.value ?? null
return acc
},
Object.create(null) as Record<SystemSettingKey, SystemSettingRecord['value']>,
)
}
async set(
key: SystemSettingKey,
value: SystemSettingRecord['value'],
options: SystemSettingSetOptions = {},
): Promise<void> {
const existing = await this.find(key)
const db = this.dbAccessor.get()
const sanitizedValue = value ?? null
const isSensitive = options.isSensitive ?? existing?.isSensitive ?? false
const description = options.description ?? existing?.description ?? null
const now = new Date().toISOString()
await db
.insert(systemSettings)
.values({
key,
value: sanitizedValue,
isSensitive,
description,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: sanitizedValue,
isSensitive,
description,
updatedAt: now,
},
})
}
async setMany(entries: readonly SystemSettingEntryInput[]): Promise<void> {
for (const entry of entries) {
await this.set(entry.key, entry.value ?? null, entry.options ?? {})
}
}
async delete(key: SystemSettingKey): Promise<void> {
const db = this.dbAccessor.get()
await db.delete(systemSettings).where(eq(systemSettings.key, key))
}
private async find(key: SystemSettingKey): Promise<SystemSettingRecord | null> {
const db = this.dbAccessor.get()
const [record] = await db.select().from(systemSettings).where(eq(systemSettings.key, key)).limit(1)
return record ?? null
}
}

View File

@@ -0,0 +1,17 @@
import type { systemSettings } from '@afilmory/db'
export type SystemSettingRecord = typeof systemSettings.$inferSelect
export type SystemSettingKey = SystemSettingRecord['key']
export type SystemSettingGetManyResult = Record<SystemSettingKey, SystemSettingRecord['value']>
export interface SystemSettingSetOptions {
readonly isSensitive?: boolean
readonly description?: string | null
}
export interface SystemSettingEntryInput {
readonly key: SystemSettingKey
readonly value: SystemSettingRecord['value']
readonly options?: SystemSettingSetOptions
}

View File

@@ -1,17 +1,34 @@
import type { systemSettings } from '@afilmory/db'
import type { UiSchema } from '../ui-schema/ui-schema.type'
import type { SystemSettingField } from './system-setting.constants'
export type SystemSettingRecord = typeof systemSettings.$inferSelect
export type SystemSettingKey = SystemSettingRecord['key']
export type SystemSettingGetManyResult = Record<SystemSettingKey, SystemSettingRecord['value']>
export interface SystemSettingSetOptions {
readonly isSensitive?: boolean
readonly description?: string | null
export interface SystemSettings {
allowRegistration: boolean
maxRegistrableUsers: number | null
localProviderEnabled: boolean
baseDomain: string
oauthGoogleClientId: string | null
oauthGoogleClientSecret: string | null
oauthGoogleRedirectUri: string | null
oauthGithubClientId: string | null
oauthGithubClientSecret: string | null
oauthGithubRedirectUri: string | null
}
export interface SystemSettingEntryInput {
readonly key: SystemSettingKey
readonly value: SystemSettingRecord['value']
readonly options?: SystemSettingSetOptions
export type SystemSettingValueMap = {
[K in SystemSettingField]: SystemSettings[K]
}
export interface SystemSettingStats {
totalUsers: number
registrationsRemaining: number | null
}
export interface SystemSettingOverview {
schema: UiSchema<SystemSettingField>
values: SystemSettingValueMap
stats: SystemSettingStats
}
export type UpdateSystemSettingsInput = Partial<SystemSettings>
export { type SystemSettingField } from './system-setting.constants'

View File

@@ -1,11 +1,11 @@
import type { UiNode, UiSchema } from '../ui-schema/ui-schema.type'
import type { SuperAdminSettingField } from './super-admin-setting.constants'
import type { SystemSettingField } from './system-setting.constants'
export const SUPER_ADMIN_SETTING_UI_SCHEMA_VERSION = '1.1.0'
export const SYSTEM_SETTING_UI_SCHEMA_VERSION = '1.1.0'
export const SUPER_ADMIN_SETTING_UI_SCHEMA: UiSchema<SuperAdminSettingField> = {
version: SUPER_ADMIN_SETTING_UI_SCHEMA_VERSION,
title: '超级管理员设置',
export const SYSTEM_SETTING_UI_SCHEMA: UiSchema<SystemSettingField> = {
version: SYSTEM_SETTING_UI_SCHEMA_VERSION,
title: '系统设置',
description: '管理整个平台的注册入口、登录策略与第三方 OAuth 配置。',
sections: [
{
@@ -162,8 +162,8 @@ export const SUPER_ADMIN_SETTING_UI_SCHEMA: UiSchema<SuperAdminSettingField> = {
],
}
function collectKeys(nodes: ReadonlyArray<UiNode<SuperAdminSettingField>>): SuperAdminSettingField[] {
const keys: SuperAdminSettingField[] = []
function collectKeys(nodes: ReadonlyArray<UiNode<SystemSettingField>>): SystemSettingField[] {
const keys: SystemSettingField[] = []
for (const node of nodes) {
if (node.type === 'field') {
@@ -177,6 +177,6 @@ function collectKeys(nodes: ReadonlyArray<UiNode<SuperAdminSettingField>>): Supe
return keys
}
export const SUPER_ADMIN_SETTING_UI_SCHEMA_KEYS = Array.from(
new Set(collectKeys(SUPER_ADMIN_SETTING_UI_SCHEMA.sections)),
) as SuperAdminSettingField[]
export const SYSTEM_SETTING_UI_SCHEMA_KEYS = Array.from(
new Set(collectKeys(SYSTEM_SETTING_UI_SCHEMA.sections)),
) as SystemSettingField[]

View File

@@ -1,12 +1,12 @@
import { HttpContext } from '@afilmory/framework'
import { DEFAULT_BASE_DOMAIN, isTenantSlugReserved } from '@afilmory/utils'
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
import { BizException, ErrorCode } from 'core/errors'
import type { Context } from 'hono'
import { injectable } from 'tsyringe'
import { logger } from '../../helpers/logger.helper'
import { OnboardingService } from '../onboarding/onboarding.service'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { SystemSettingService } from '../system-setting/system-setting.service'
import { TenantService } from './tenant.service'
import type { TenantContext } from './tenant.types'
@@ -26,7 +26,7 @@ export class TenantContextResolver {
constructor(
private readonly tenantService: TenantService,
private readonly onboardingService: OnboardingService,
private readonly superAdminSettingService: SuperAdminSettingService,
private readonly systemSettingService: SystemSettingService,
) {}
async resolve(context: Context, options: TenantResolutionOptions = {}): Promise<TenantContext | null> {
@@ -66,11 +66,6 @@ export class TenantContextResolver {
derivedSlug = this.extractSlugFromHost(host, baseDomain)
}
if (derivedSlug && isTenantSlugReserved(derivedSlug)) {
this.log.verbose(`Host ${host} matched reserved slug ${derivedSlug}, skipping tenant resolution.`)
return null
}
const tenantContext = await this.tenantService.resolve(
{
tenantId,
@@ -112,7 +107,7 @@ export class TenantContextResolver {
if (process.env.NODE_ENV === 'development') {
return 'localhost'
}
const settings = await this.superAdminSettingService.getSettings()
const settings = await this.systemSettingService.getSettings()
return settings.baseDomain || DEFAULT_BASE_DOMAIN
}

View File

@@ -1,4 +1,5 @@
import { generateId, tenants } from '@afilmory/db'
import { BizException, ErrorCode } from 'core/errors'
import { eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
@@ -42,7 +43,9 @@ export class TenantRepository {
return await this.findById(tenantId).then((aggregate) => {
if (!aggregate) {
throw new Error('Failed to create tenant')
throw new BizException(ErrorCode.COMMON_INTERNAL_SERVER_ERROR, {
message: 'Failed to create tenant',
})
}
return aggregate
})

View File

@@ -1,3 +1,4 @@
import { isTenantSlugReserved } from '@afilmory/utils'
import { BizException, ErrorCode } from 'core/errors'
import { injectable } from 'tsyringe'
@@ -9,7 +10,22 @@ export class TenantService {
constructor(private readonly repository: TenantRepository) {}
async createTenant(payload: { name: string; slug: string }): Promise<TenantAggregate> {
return await this.repository.createTenant(payload)
const normalizedSlug = this.normalizeSlug(payload.slug)
if (!normalizedSlug) {
throw new BizException(ErrorCode.COMMON_VALIDATION, {
message: 'Tenant slug is required',
})
}
if (isTenantSlugReserved(normalizedSlug)) {
throw new BizException(ErrorCode.TENANT_SLUG_RESERVED)
}
return await this.repository.createTenant({
...payload,
slug: normalizedSlug,
})
}
async resolve(input: TenantResolutionInput, noThrow: boolean): Promise<TenantContext | null>
async resolve(input: TenantResolutionInput): Promise<TenantContext>

View File

@@ -7,7 +7,7 @@ import { Navigate, NavLink, Outlet } from 'react-router'
import { useAuthUserValue, useIsSuperAdmin } from '~/atoms/auth'
import { usePageRedirect } from '~/hooks/usePageRedirect'
const navigationTabs = [{ label: '系统设置', path: '/superadmin/settings' }] as const
const navigationTabs = [{ label: 'System Settings', path: '/superadmin/settings' }] as const
export function Component() {
const { logout } = usePageRedirect()
@@ -37,7 +37,7 @@ export function Component() {
<div className="flex h-screen flex-col">
<nav className="border-border/50 bg-background-tertiary shrink-0 border-b px-6 py-3">
<div className="flex items-center gap-6">
<div className="text-text text-base font-semibold">Afilmory · Superadmin</div>
<div className="text-text text-base font-semibold">Afilmory · System Settings</div>
<div className="flex flex-1 items-center gap-1">
{navigationTabs.map((tab) => (

View File

@@ -12,8 +12,8 @@ export function Component() {
className="space-y-6"
>
<header className="space-y-2">
<h1 className="text-text text-2xl font-semibold"></h1>
<p className="text-text-secondary text-sm"></p>
<h1 className="text-text text-2xl font-semibold"></h1>
<p className="text-text-secondary text-sm"></p>
</header>
<SuperAdminSettingsForm />