mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
42
be/apps/core/src/cli/index.ts
Normal file
42
be/apps/core/src/cli/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ResetCliOptions } from './reset-superadmin'
|
||||
import { handleResetSuperAdminPassword, parseResetCliArgs } from './reset-superadmin'
|
||||
|
||||
type CliCommand<TOptions> = {
|
||||
name: string
|
||||
parse: (argv: readonly string[]) => TOptions | null
|
||||
execute: (options: TOptions) => Promise<void>
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
const cliCommands: Array<CliCommand<unknown>> = [
|
||||
{
|
||||
name: 'reset-superadmin-password',
|
||||
parse: parseResetCliArgs,
|
||||
execute: (options) => handleResetSuperAdminPassword(options as ResetCliOptions),
|
||||
onError: (error) => {
|
||||
console.error('Superadmin password reset failed', error)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export async function runCliPipeline(argv: readonly string[]): Promise<boolean> {
|
||||
for (const command of cliCommands) {
|
||||
const parsedOptions = command.parse(argv)
|
||||
if (!parsedOptions) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(parsedOptions)
|
||||
} catch (error) {
|
||||
command.onError?.(error)
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1)
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
199
be/apps/core/src/cli/reset-superadmin.ts
Normal file
199
be/apps/core/src/cli/reset-superadmin.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import { authAccounts, authSessions, authUsers, generateId } from '@afilmory/db'
|
||||
import { env } from '@afilmory/env'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
import { createConfiguredApp } from '../app.factory'
|
||||
import { DbAccessor, PgPoolProvider } from '../database/database.provider'
|
||||
import { logger } from '../helpers/logger.helper'
|
||||
import { AuthProvider } from '../modules/auth/auth.provider'
|
||||
import { RedisProvider } from '../redis/redis.provider'
|
||||
|
||||
const RESET_FLAG = '--reset-superadmin-password'
|
||||
const PASSWORD_FLAG = '--password'
|
||||
const EMAIL_FLAG = '--email'
|
||||
|
||||
export interface ResetCliOptions {
|
||||
password?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export function parseResetCliArgs(args: readonly string[]): ResetCliOptions | null {
|
||||
const hasResetFlag = args.some((arg) => arg === RESET_FLAG || arg.startsWith(`${RESET_FLAG}=`))
|
||||
if (!hasResetFlag) {
|
||||
return null
|
||||
}
|
||||
|
||||
let password: string | undefined
|
||||
let email: string | undefined
|
||||
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index]
|
||||
if (!arg || arg === '--') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === RESET_FLAG) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith(`${RESET_FLAG}=`)) {
|
||||
const inline = arg.slice(RESET_FLAG.length + 1).trim()
|
||||
if (inline.length > 0) {
|
||||
password = inline
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === PASSWORD_FLAG) {
|
||||
const value = args[index + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error('Missing value for --password')
|
||||
}
|
||||
password = value
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith(`${PASSWORD_FLAG}=`)) {
|
||||
const value = arg.slice(PASSWORD_FLAG.length + 1).trim()
|
||||
if (value.length === 0) {
|
||||
throw new Error('Missing value for --password')
|
||||
}
|
||||
password = value
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === EMAIL_FLAG) {
|
||||
const value = args[index + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error('Missing value for --email')
|
||||
}
|
||||
email = value
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith(`${EMAIL_FLAG}=`)) {
|
||||
const value = arg.slice(EMAIL_FLAG.length + 1).trim()
|
||||
if (value.length === 0) {
|
||||
throw new Error('Missing value for --email')
|
||||
}
|
||||
email = value
|
||||
}
|
||||
}
|
||||
|
||||
return { password, email }
|
||||
}
|
||||
|
||||
function generateRandomPassword(): string {
|
||||
return randomBytes(16).toString('base64url')
|
||||
}
|
||||
|
||||
export async function handleResetSuperAdminPassword(options: ResetCliOptions): Promise<void> {
|
||||
const app = await createConfiguredApp({
|
||||
globalPrefix: '/api',
|
||||
})
|
||||
|
||||
const container = app.getContainer()
|
||||
const poolProvider = container.resolve(PgPoolProvider)
|
||||
const redisProvider = container.resolve(RedisProvider)
|
||||
const authProvider = container.resolve(AuthProvider)
|
||||
const dbAccessor = container.resolve(DbAccessor)
|
||||
|
||||
try {
|
||||
const auth = authProvider.getAuth()
|
||||
const context = await auth.$context
|
||||
const rawPassword = options.password ?? generateRandomPassword()
|
||||
const { minPasswordLength, maxPasswordLength } = context.password.config
|
||||
|
||||
if (rawPassword.length < minPasswordLength || rawPassword.length > maxPasswordLength) {
|
||||
throw new Error(`Password must be between ${minPasswordLength} and ${maxPasswordLength} characters.`)
|
||||
}
|
||||
|
||||
const hashedPassword = await context.password.hash(rawPassword)
|
||||
const db = dbAccessor.get()
|
||||
|
||||
const targetEmail = options.email ?? env.DEFAULT_SUPERADMIN_EMAIL
|
||||
const now = new Date().toISOString()
|
||||
let resolvedEmail = targetEmail
|
||||
let revokedSessionsCount = 0
|
||||
let credentialAccountCreated = false
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
let superAdmin = await tx.query.authUsers.findFirst({
|
||||
where: (users, { eq }) => eq(users.email, targetEmail),
|
||||
})
|
||||
|
||||
if (!superAdmin) {
|
||||
superAdmin = await tx.query.authUsers.findFirst({
|
||||
where: (users, { eq }) => eq(users.role, 'superadmin'),
|
||||
})
|
||||
}
|
||||
|
||||
if (!superAdmin) {
|
||||
const message = options.email
|
||||
? `No superadmin account found for email "${options.email}"`
|
||||
: 'No superadmin account found'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
resolvedEmail = superAdmin.email
|
||||
|
||||
const credentialAccount = await tx.query.authAccounts.findFirst({
|
||||
where: (accounts, { eq, and }) =>
|
||||
and(eq(accounts.userId, superAdmin.id), eq(accounts.providerId, 'credential')),
|
||||
})
|
||||
|
||||
if (credentialAccount) {
|
||||
await tx
|
||||
.update(authAccounts)
|
||||
.set({ password: hashedPassword, updatedAt: now })
|
||||
.where(eq(authAccounts.id, credentialAccount.id))
|
||||
} else {
|
||||
credentialAccountCreated = true
|
||||
await tx.insert(authAccounts).values({
|
||||
id: generateId(),
|
||||
accountId: superAdmin.id,
|
||||
providerId: 'credential',
|
||||
userId: superAdmin.id,
|
||||
password: hashedPassword,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
await tx.update(authUsers).set({ updatedAt: now }).where(eq(authUsers.id, superAdmin.id))
|
||||
|
||||
const deletedSessions = await tx
|
||||
.delete(authSessions)
|
||||
.where(eq(authSessions.userId, superAdmin.id))
|
||||
.returning({ id: authSessions.id })
|
||||
|
||||
revokedSessionsCount = deletedSessions.length
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`Superadmin password reset for ${resolvedEmail}. ${credentialAccountCreated ? 'Credential account created.' : 'Credential account updated.'} Revoked ${revokedSessionsCount} sessions.`,
|
||||
)
|
||||
|
||||
process.stdout.write(`Superadmin credentials reset\n email: ${resolvedEmail}\n password: ${rawPassword}\n`)
|
||||
} finally {
|
||||
await app.close('cli')
|
||||
|
||||
try {
|
||||
const pool = poolProvider.getPool()
|
||||
await pool.end()
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to close PostgreSQL pool cleanly: ${String(error)}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const redis = redisProvider.getClient()
|
||||
redis.disconnect()
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to disconnect Redis client cleanly: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { serve } from '@hono/node-server'
|
||||
import { green } from 'picocolors'
|
||||
|
||||
import { createConfiguredApp } from './app.factory'
|
||||
import { runCliPipeline } from './cli'
|
||||
import { logger } from './helpers/logger.helper'
|
||||
|
||||
process.title = 'Hono HTTP Server'
|
||||
@@ -31,7 +32,16 @@ async function bootstrap() {
|
||||
)
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
async function main() {
|
||||
const handledByCli = await runCliPipeline(process.argv.slice(2))
|
||||
if (handledByCli) {
|
||||
return
|
||||
}
|
||||
|
||||
await bootstrap()
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Application bootstrap failed', error)
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1)
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { Body, ContextParam, Controller, Get, Post, UnauthorizedException } from '@afilmory/framework'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
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 { AuthProvider } from './auth.provider'
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthProvider) {}
|
||||
constructor(
|
||||
private readonly auth: AuthProvider,
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
private readonly superAdminSettings: SuperAdminSettingService,
|
||||
) {}
|
||||
|
||||
@Get('/session')
|
||||
async getSession(@ContextParam() context: Context) {
|
||||
@@ -27,6 +36,27 @@ export class AuthController {
|
||||
|
||||
@Post('/sign-in/email')
|
||||
async signInEmail(@ContextParam() context: Context, @Body() body: { email: string; password: string }) {
|
||||
const email = body.email.trim()
|
||||
if (email.length === 0) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '邮箱不能为空' })
|
||||
}
|
||||
const settings = await this.superAdminSettings.getSettings()
|
||||
if (!settings.localProviderEnabled) {
|
||||
const db = this.dbAccessor.get()
|
||||
const [record] = await db
|
||||
.select({ role: authUsers.role })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.email, email))
|
||||
.limit(1)
|
||||
|
||||
const isSuperAdmin = record?.role === 'superadmin'
|
||||
if (!isSuperAdmin) {
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
|
||||
message: '邮箱密码登录已禁用,请联系管理员开启本地登录。',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const auth = this.auth.getAuth()
|
||||
const headers = new Headers(context.req.raw.headers)
|
||||
const tenant = (context as any).var?.tenant
|
||||
@@ -36,7 +66,7 @@ export class AuthController {
|
||||
}
|
||||
const response = await auth.api.signInEmail({
|
||||
body: {
|
||||
email: body.email,
|
||||
email,
|
||||
password: body.password,
|
||||
},
|
||||
asResponse: true,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
import { DatabaseModule } from 'core/database/database.module'
|
||||
|
||||
import { SystemSettingModule } from '../system-setting/system-setting.module'
|
||||
import { AuthConfig } from './auth.config'
|
||||
import { AuthController } from './auth.controller'
|
||||
import { AuthProvider } from './auth.provider'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
imports: [DatabaseModule, SystemSettingModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthProvider, AuthConfig],
|
||||
})
|
||||
|
||||
@@ -12,24 +12,30 @@ import { DataSyncModule } from './data-sync/data-sync.module'
|
||||
import { OnboardingModule } from './onboarding/onboarding.module'
|
||||
import { PhotoModule } from './photo/photo.module'
|
||||
import { SettingModule } from './setting/setting.module'
|
||||
import { SuperAdminModule } from './super-admin/super-admin.module'
|
||||
import { SystemSettingModule } from './system-setting/system-setting.module'
|
||||
import { TenantModule } from './tenant/tenant.module'
|
||||
|
||||
function createEventModuleOptions(redis: RedisAccessor) {
|
||||
return {
|
||||
redisClient: redis.get(),
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
RedisModule,
|
||||
AuthModule,
|
||||
SettingModule,
|
||||
SystemSettingModule,
|
||||
SuperAdminModule,
|
||||
OnboardingModule,
|
||||
PhotoModule,
|
||||
TenantModule,
|
||||
DataSyncModule,
|
||||
EventModule.forRootAsync({
|
||||
useFactory: async (redis: RedisAccessor) => {
|
||||
return {
|
||||
redisClient: redis.get(),
|
||||
}
|
||||
},
|
||||
useFactory: createEventModuleOptions,
|
||||
inject: [RedisAccessor],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Body, Controller, Get, Patch } from '@afilmory/framework'
|
||||
import { Roles } from 'core/guards/roles.decorator'
|
||||
|
||||
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
|
||||
import { UpdateSuperAdminSettingsDto } from './super-admin.dto'
|
||||
|
||||
@Controller('super-admin/settings')
|
||||
@Roles('superadmin')
|
||||
export class SuperAdminSettingController {
|
||||
constructor(private readonly superAdminSettings: SuperAdminSettingService) {}
|
||||
|
||||
@Get('/')
|
||||
async getOverview() {
|
||||
return await this.superAdminSettings.getOverview()
|
||||
}
|
||||
|
||||
@Patch('/')
|
||||
async update(@Body() dto: UpdateSuperAdminSettingsDto) {
|
||||
await this.superAdminSettings.updateSettings(dto)
|
||||
return await this.superAdminSettings.getOverview()
|
||||
}
|
||||
}
|
||||
14
be/apps/core/src/modules/super-admin/super-admin.dto.ts
Normal file
14
be/apps/core/src/modules/super-admin/super-admin.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createZodDto } from '@afilmory/framework'
|
||||
import { z } from 'zod'
|
||||
|
||||
const updateSuperAdminSettingsSchema = z
|
||||
.object({
|
||||
allowRegistration: z.boolean().optional(),
|
||||
maxRegistrableUsers: z.number().int().min(0).nullable().optional(),
|
||||
localProviderEnabled: z.boolean().optional(),
|
||||
})
|
||||
.refine((value) => Object.values(value).some((entry) => entry !== undefined), {
|
||||
message: '至少需要更新一项设置',
|
||||
})
|
||||
|
||||
export class UpdateSuperAdminSettingsDto extends createZodDto(updateSuperAdminSettingsSchema) {}
|
||||
10
be/apps/core/src/modules/super-admin/super-admin.module.ts
Normal file
10
be/apps/core/src/modules/super-admin/super-admin.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
|
||||
import { SystemSettingModule } from '../system-setting/system-setting.module'
|
||||
import { SuperAdminSettingController } from './super-admin.controller'
|
||||
|
||||
@Module({
|
||||
imports: [SystemSettingModule],
|
||||
controllers: [SuperAdminSettingController],
|
||||
})
|
||||
export class SuperAdminModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const SUPER_ADMIN_SETTING_DEFINITIONS = {
|
||||
allowRegistration: {
|
||||
key: 'system.registration.allow',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
},
|
||||
maxRegistrableUsers: {
|
||||
key: 'system.registration.maxUsers',
|
||||
schema: z.number().int().min(0).nullable(),
|
||||
defaultValue: null as number | null,
|
||||
},
|
||||
localProviderEnabled: {
|
||||
key: 'system.auth.localProvider.enabled',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
},
|
||||
} as const
|
||||
|
||||
export type SuperAdminSettingField = keyof typeof SUPER_ADMIN_SETTING_DEFINITIONS
|
||||
export type SuperAdminSettingKey = (typeof SUPER_ADMIN_SETTING_DEFINITIONS)[SuperAdminSettingField]['key']
|
||||
|
||||
export const SUPER_ADMIN_SETTING_KEYS = Object.values(SUPER_ADMIN_SETTING_DEFINITIONS).map(
|
||||
(definition) => definition.key,
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
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 { SUPER_ADMIN_SETTING_DEFINITIONS, SUPER_ADMIN_SETTING_KEYS } from './super-admin-setting.constants'
|
||||
import type {
|
||||
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,
|
||||
)
|
||||
|
||||
return {
|
||||
allowRegistration,
|
||||
maxRegistrableUsers,
|
||||
localProviderEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
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<{ key: string; value: SuperAdminSettings[keyof SuperAdminSettings] | null }> = []
|
||||
|
||||
if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) {
|
||||
updates.push({
|
||||
key: SUPER_ADMIN_SETTING_DEFINITIONS.allowRegistration.key,
|
||||
value: patch.allowRegistration,
|
||||
})
|
||||
current.allowRegistration = patch.allowRegistration
|
||||
}
|
||||
|
||||
if (patch.localProviderEnabled !== undefined && patch.localProviderEnabled !== current.localProviderEnabled) {
|
||||
updates.push({
|
||||
key: SUPER_ADMIN_SETTING_DEFINITIONS.localProviderEnabled.key,
|
||||
value: patch.localProviderEnabled,
|
||||
})
|
||||
current.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: '最大可注册用户数不能小于当前用户总数',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
updates.push({
|
||||
key: SUPER_ADMIN_SETTING_DEFINITIONS.maxRegistrableUsers.key,
|
||||
value: normalized,
|
||||
})
|
||||
current.maxRegistrableUsers = normalized
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return current
|
||||
}
|
||||
|
||||
await this.systemSettingService.setMany(
|
||||
updates.map((entry) => ({
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
})),
|
||||
)
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { UiNode, UiSchema } from '../ui-schema/ui-schema.type'
|
||||
import type { SuperAdminSettingField } from './super-admin-setting.constants'
|
||||
|
||||
export const SUPER_ADMIN_SETTING_UI_SCHEMA_VERSION = '1.0.0'
|
||||
|
||||
export const SUPER_ADMIN_SETTING_UI_SCHEMA: UiSchema<SuperAdminSettingField> = {
|
||||
version: SUPER_ADMIN_SETTING_UI_SCHEMA_VERSION,
|
||||
title: '超级管理员设置',
|
||||
description: '管理整个平台的注册入口和本地登录策略。',
|
||||
sections: [
|
||||
{
|
||||
type: 'section',
|
||||
id: 'registration-control',
|
||||
title: '全局注册策略',
|
||||
description: '控制新用户注册配额以及本地账号登录能力。',
|
||||
icon: 'user-cog',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'registration-allow',
|
||||
title: '允许新用户注册',
|
||||
description: '关闭后仅超级管理员可以手动添加新账号。',
|
||||
key: 'allowRegistration',
|
||||
component: {
|
||||
type: 'switch',
|
||||
trueLabel: '允许',
|
||||
falseLabel: '禁止',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'local-provider-enabled',
|
||||
title: '启用本地登录(邮箱 / 密码)',
|
||||
description: '关闭后普通用户只能使用第三方登录渠道。',
|
||||
key: 'localProviderEnabled',
|
||||
component: {
|
||||
type: 'switch',
|
||||
trueLabel: '启用',
|
||||
falseLabel: '禁用',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'registration-max-users',
|
||||
title: '全局可注册用户上限',
|
||||
description: '达到上限后将阻止新的注册,留空表示不限制用户数量。',
|
||||
helperText: '设置为 0 时将立即阻止新的用户注册。',
|
||||
key: 'maxRegistrableUsers',
|
||||
component: {
|
||||
type: 'text',
|
||||
inputType: 'number',
|
||||
placeholder: '无限制',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function collectKeys(nodes: ReadonlyArray<UiNode<SuperAdminSettingField>>): SuperAdminSettingField[] {
|
||||
const keys: SuperAdminSettingField[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'field') {
|
||||
keys.push(node.key)
|
||||
continue
|
||||
}
|
||||
|
||||
keys.push(...collectKeys(node.children))
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
export const SUPER_ADMIN_SETTING_UI_SCHEMA_KEYS = Array.from(
|
||||
new Set(collectKeys(SUPER_ADMIN_SETTING_UI_SCHEMA.sections)),
|
||||
) as SuperAdminSettingField[]
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
|
||||
import { DatabaseModule } from '../../database/database.module'
|
||||
import { SuperAdminSettingService } from './super-admin-setting.service'
|
||||
import { SystemSettingService } from './system-setting.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [SystemSettingService, SuperAdminSettingService],
|
||||
})
|
||||
export class SystemSettingModule {}
|
||||
@@ -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.types'
|
||||
|
||||
@injectable()
|
||||
export class SystemSettingService {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type UiFieldComponentType = 'text' | 'secret' | 'textarea' | 'select' | 'slot'
|
||||
export type UiFieldComponentType = 'text' | 'secret' | 'textarea' | 'select' | 'slot' | 'switch'
|
||||
|
||||
interface UiFieldComponentBase<Type extends UiFieldComponentType> {
|
||||
readonly type: Type
|
||||
@@ -28,6 +28,11 @@ export interface UiSelectComponent extends UiFieldComponentBase<'select'> {
|
||||
readonly allowCustom?: boolean
|
||||
}
|
||||
|
||||
export interface UiSwitchComponent extends UiFieldComponentBase<'switch'> {
|
||||
readonly trueLabel?: string
|
||||
readonly falseLabel?: string
|
||||
}
|
||||
|
||||
export interface UiSlotComponent<Key extends string = string> extends UiFieldComponentBase<'slot'> {
|
||||
readonly name: string
|
||||
readonly fields?: ReadonlyArray<{ key: Key; label?: string; required?: boolean }>
|
||||
@@ -39,6 +44,7 @@ export type UiFieldComponentDefinition<Key extends string = string> =
|
||||
| UiSecretInputComponent
|
||||
| UiTextareaComponent
|
||||
| UiSelectComponent
|
||||
| UiSwitchComponent
|
||||
| UiSlotComponent<Key>
|
||||
|
||||
interface BaseUiNode {
|
||||
|
||||
@@ -17,6 +17,8 @@ const ONBOARDING_STATUS_QUERY_KEY = ['onboarding', 'status'] as const
|
||||
const DEFAULT_LOGIN_PATH = '/login'
|
||||
const DEFAULT_ONBOARDING_PATH = '/onboarding'
|
||||
const DEFAULT_AUTHENTICATED_PATH = '/'
|
||||
const SUPERADMIN_ROOT_PATH = '/superadmin'
|
||||
const SUPERADMIN_DEFAULT_PATH = '/superadmin/settings'
|
||||
|
||||
const AUTH_FAILURE_STATUSES = new Set([401, 403, 419])
|
||||
|
||||
@@ -89,6 +91,8 @@ export const usePageRedirect = () => {
|
||||
const { pathname } = location
|
||||
const session = sessionQuery.data
|
||||
const onboardingInitialized = onboardingQuery.data?.initialized ?? false
|
||||
const isSuperAdmin = session?.user.role === 'superadmin'
|
||||
const isOnSuperAdminPage = pathname.startsWith(SUPERADMIN_ROOT_PATH)
|
||||
|
||||
// If onboarding is not complete, redirect to onboarding
|
||||
if (!onboardingInitialized) {
|
||||
@@ -98,6 +102,18 @@ export const usePageRedirect = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (session && isSuperAdmin) {
|
||||
if (!isOnSuperAdminPage || pathname === DEFAULT_LOGIN_PATH) {
|
||||
navigate(SUPERADMIN_DEFAULT_PATH, { replace: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (session && !isSuperAdmin && isOnSuperAdminPage) {
|
||||
navigate(DEFAULT_AUTHENTICATED_PATH, { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
// If not authenticated and trying to access protected route
|
||||
if (!session && !PUBLIC_PATHS.has(pathname)) {
|
||||
navigate(DEFAULT_LOGIN_PATH, { replace: true })
|
||||
|
||||
@@ -44,7 +44,9 @@ export const useLogin = () => {
|
||||
queryClient.setQueryData(AUTH_SESSION_QUERY_KEY, session)
|
||||
setAuthUser(session.user)
|
||||
setErrorMessage(null)
|
||||
navigate('/', { replace: true })
|
||||
const destination =
|
||||
session.user.role === 'superadmin' ? '/superadmin/settings' : '/'
|
||||
navigate(destination, { replace: true })
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error instanceof FetchError) {
|
||||
|
||||
483
be/apps/dashboard/src/modules/schema-form/SchemaFormRenderer.tsx
Normal file
483
be/apps/dashboard/src/modules/schema-form/SchemaFormRenderer.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
import {
|
||||
FormHelperText,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@afilmory/ui'
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Fragment, useState } from 'react'
|
||||
|
||||
import type {
|
||||
SchemaFormState,
|
||||
SchemaFormValue,
|
||||
UiFieldComponentDefinition,
|
||||
UiFieldNode,
|
||||
UiGroupNode,
|
||||
UiNode,
|
||||
UiSchema,
|
||||
UiSlotComponent,
|
||||
} from './types'
|
||||
|
||||
const glassCardStyles = {
|
||||
backgroundImage:
|
||||
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-background) 98%, transparent), color-mix(in srgb, var(--color-background) 95%, transparent))',
|
||||
boxShadow:
|
||||
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent), 0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
} as const
|
||||
|
||||
const glassGlowStyles = {
|
||||
background:
|
||||
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-accent) 5%, transparent), transparent, color-mix(in srgb, var(--color-accent) 5%, transparent))',
|
||||
} as const
|
||||
|
||||
export const GlassPanel = ({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<div
|
||||
className={clsxm(
|
||||
'group relative overflow-hidden rounded-2xl border border-accent/20 backdrop-blur-2xl',
|
||||
className,
|
||||
)}
|
||||
style={glassCardStyles}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-2xl opacity-60"
|
||||
style={glassGlowStyles}
|
||||
/>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FieldDescription = ({ description }: { description?: string | null }) => {
|
||||
if (!description) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <p className="mt-1 text-xs text-text-tertiary">{description}</p>
|
||||
}
|
||||
|
||||
const SchemaIcon = ({
|
||||
name,
|
||||
className,
|
||||
}: {
|
||||
name?: string | null
|
||||
className?: string
|
||||
}) => {
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicIcon name={name as any} className={clsxm('h-4 w-4', className)} />
|
||||
)
|
||||
}
|
||||
|
||||
const SecretFieldInput = <Key extends string>({
|
||||
component,
|
||||
fieldKey,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
component: Extract<UiFieldComponentDefinition<Key>, { type: 'secret' }>
|
||||
fieldKey: Key
|
||||
value: string
|
||||
onChange: (key: Key, value: SchemaFormValue) => void
|
||||
}) => {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Input
|
||||
type={revealed ? 'text' : 'password'}
|
||||
value={value}
|
||||
onInput={(event) => onChange(fieldKey, event.currentTarget.value)}
|
||||
placeholder={component.placeholder ?? ''}
|
||||
autoComplete={component.autoComplete}
|
||||
className="flex-1 bg-background/60"
|
||||
/>
|
||||
{component.revealable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((prev) => !prev)}
|
||||
className="h-9 rounded-lg border border-accent/30 px-3 text-xs font-medium text-accent transition-all duration-200 hover:bg-accent/10"
|
||||
>
|
||||
{revealed ? '隐藏' : '显示'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SlotRenderer<Key extends string> = (
|
||||
field: UiFieldNode<Key> & { component: UiSlotComponent<Key> },
|
||||
context: SchemaRendererContext<Key>,
|
||||
onChange: (key: Key, value: SchemaFormValue) => void,
|
||||
) => ReactNode
|
||||
|
||||
type FieldRendererProps<Key extends string> = {
|
||||
field: UiFieldNode<Key>
|
||||
value: SchemaFormValue | undefined
|
||||
onChange: (key: Key, value: SchemaFormValue) => void
|
||||
renderSlot?: SlotRenderer<Key>
|
||||
context: SchemaRendererContext<Key>
|
||||
}
|
||||
|
||||
const FieldRenderer = <Key extends string>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
renderSlot,
|
||||
context,
|
||||
}: FieldRendererProps<Key>) => {
|
||||
const { component } = field
|
||||
|
||||
if (component.type === 'slot') {
|
||||
return renderSlot
|
||||
? renderSlot(
|
||||
field as UiFieldNode<Key> & { component: UiSlotComponent<Key> },
|
||||
context,
|
||||
onChange,
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
if (component.type === 'textarea') {
|
||||
const stringValue =
|
||||
typeof value === 'string' ? value : value == null ? '' : String(value)
|
||||
return (
|
||||
<Textarea
|
||||
value={stringValue}
|
||||
onInput={(event) => onChange(field.key, event.currentTarget.value)}
|
||||
placeholder={component.placeholder ?? ''}
|
||||
rows={component.minRows ?? 3}
|
||||
className="bg-background/60"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (component.type === 'select') {
|
||||
const stringValue =
|
||||
typeof value === 'string' ? value : value == null ? '' : String(value)
|
||||
return (
|
||||
<Select
|
||||
value={stringValue}
|
||||
onValueChange={(nextValue) => onChange(field.key, nextValue)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={component.placeholder ?? '请选择'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{component.options?.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (component.type === 'secret') {
|
||||
const stringValue =
|
||||
typeof value === 'string' ? value : value == null ? '' : String(value)
|
||||
return (
|
||||
<SecretFieldInput
|
||||
component={component}
|
||||
fieldKey={field.key}
|
||||
value={stringValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (component.type === 'switch') {
|
||||
const checked = Boolean(value)
|
||||
const label = checked ? component.trueLabel : component.falseLabel
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => onChange(field.key, next)}
|
||||
/>
|
||||
{label ? (
|
||||
<span className="text-xs text-text-secondary">{label}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stringValue =
|
||||
typeof value === 'string' ? value : value == null ? '' : String(value)
|
||||
const inputType =
|
||||
component.type === 'text' ? (component.inputType ?? 'text') : 'text'
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={inputType}
|
||||
value={stringValue}
|
||||
onInput={(event) => onChange(field.key, event.currentTarget.value)}
|
||||
placeholder={component.placeholder ?? ''}
|
||||
autoComplete={component.autoComplete}
|
||||
className="bg-background/60"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGroup = <Key extends string>(
|
||||
node: UiGroupNode<Key>,
|
||||
context: SchemaRendererContext<Key>,
|
||||
formState: SchemaFormState<Key>,
|
||||
handleChange: (key: Key, value: SchemaFormValue) => void,
|
||||
shouldRenderNode?: SchemaFormRendererProps<Key>['shouldRenderNode'],
|
||||
renderSlot?: SlotRenderer<Key>,
|
||||
) => {
|
||||
const renderedChildren = node.children
|
||||
.map((child) =>
|
||||
renderNode(
|
||||
child,
|
||||
context,
|
||||
formState,
|
||||
handleChange,
|
||||
shouldRenderNode,
|
||||
renderSlot,
|
||||
),
|
||||
)
|
||||
.filter(Boolean)
|
||||
|
||||
if (renderedChildren.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="rounded-2xl border border-accent/10 bg-accent/2 p-5 backdrop-blur-xl transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SchemaIcon name={node.icon} className="text-accent" />
|
||||
<h3 className="text-sm font-semibold text-text">{node.title}</h3>
|
||||
</div>
|
||||
<FieldDescription description={node.description} />
|
||||
|
||||
<div className="mt-4 space-y-4">{renderedChildren}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderField = <Key extends string>(
|
||||
field: UiFieldNode<Key>,
|
||||
formState: SchemaFormState<Key>,
|
||||
handleChange: (key: Key, value: SchemaFormValue) => void,
|
||||
renderSlot: SlotRenderer<Key> | undefined,
|
||||
context: SchemaRendererContext<Key>,
|
||||
) => {
|
||||
if (field.hidden) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = formState[field.key]
|
||||
const { isSensitive, helperText, component, icon } = field
|
||||
|
||||
if (component.type === 'switch') {
|
||||
const helper = helperText ? (
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="rounded-xl border border-fill/30 bg-background/40 p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-text">
|
||||
{field.title}
|
||||
</Label>
|
||||
<FieldDescription description={field.description} />
|
||||
</div>
|
||||
<FieldRenderer
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
renderSlot={renderSlot}
|
||||
context={context}
|
||||
/>
|
||||
</div>
|
||||
{helper}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showSensitiveHint =
|
||||
isSensitive && typeof value === 'string' && value.length === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="space-y-2 rounded-xl border border-fill-tertiary/40 bg-background/30 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-text">{field.title}</Label>
|
||||
<FieldDescription description={field.description} />
|
||||
</div>
|
||||
<SchemaIcon name={icon} className="text-text-tertiary" />
|
||||
</div>
|
||||
|
||||
<FieldRenderer
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
renderSlot={renderSlot}
|
||||
context={context}
|
||||
/>
|
||||
|
||||
{showSensitiveHint ? (
|
||||
<FormHelperText>出于安全考虑,仅在更新时填写新的值。</FormHelperText>
|
||||
) : null}
|
||||
|
||||
{helperText ? <FormHelperText>{helperText}</FormHelperText> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderNode = <Key extends string>(
|
||||
node: UiNode<Key>,
|
||||
context: SchemaRendererContext<Key>,
|
||||
formState: SchemaFormState<Key>,
|
||||
handleChange: (key: Key, value: SchemaFormValue) => void,
|
||||
shouldRenderNode?: SchemaFormRendererProps<Key>['shouldRenderNode'],
|
||||
renderSlot?: SlotRenderer<Key>,
|
||||
): ReactNode => {
|
||||
if (shouldRenderNode && !shouldRenderNode(node, context)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (node.type === 'group') {
|
||||
return renderGroup(
|
||||
node,
|
||||
context,
|
||||
formState,
|
||||
handleChange,
|
||||
shouldRenderNode,
|
||||
renderSlot,
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'field') {
|
||||
return renderField(node, formState, handleChange, renderSlot, context)
|
||||
}
|
||||
|
||||
const renderedChildren = node.children
|
||||
.map((child) =>
|
||||
renderNode(
|
||||
child,
|
||||
context,
|
||||
formState,
|
||||
handleChange,
|
||||
shouldRenderNode,
|
||||
renderSlot,
|
||||
),
|
||||
)
|
||||
.filter(Boolean)
|
||||
|
||||
if (renderedChildren.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={node.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SchemaIcon name={node.icon} className="h-5 w-5 text-accent" />
|
||||
<h2 className="text-base font-semibold text-text">{node.title}</h2>
|
||||
</div>
|
||||
<FieldDescription description={node.description} />
|
||||
<div className="grid gap-4">{renderedChildren}</div>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SchemaRendererContext<Key extends string> {
|
||||
readonly values: SchemaFormState<Key>
|
||||
}
|
||||
|
||||
export interface SchemaFormRendererProps<Key extends string> {
|
||||
schema: UiSchema<Key>
|
||||
values: SchemaFormState<Key>
|
||||
onChange: (key: Key, value: SchemaFormValue) => void
|
||||
shouldRenderNode?: (
|
||||
node: UiNode<Key>,
|
||||
context: SchemaRendererContext<Key>,
|
||||
) => boolean
|
||||
renderSlot?: SlotRenderer<Key>
|
||||
}
|
||||
|
||||
export const SchemaFormRenderer = <Key extends string>({
|
||||
schema,
|
||||
values,
|
||||
onChange,
|
||||
shouldRenderNode,
|
||||
renderSlot,
|
||||
}: SchemaFormRendererProps<Key>) => {
|
||||
const context: SchemaRendererContext<Key> = { values }
|
||||
|
||||
return (
|
||||
<>
|
||||
{schema.sections.map((section) => {
|
||||
if (shouldRenderNode && !shouldRenderNode(section, context)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderedChildren = section.children
|
||||
.map((child) =>
|
||||
renderNode(
|
||||
child,
|
||||
context,
|
||||
values,
|
||||
onChange,
|
||||
shouldRenderNode,
|
||||
renderSlot,
|
||||
),
|
||||
)
|
||||
.filter(Boolean)
|
||||
|
||||
if (renderedChildren.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassPanel key={section.id} className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SchemaIcon
|
||||
name={section.icon}
|
||||
className="h-5 w-5 text-accent"
|
||||
/>
|
||||
<h2 className="text-lg font-semibold text-text">
|
||||
{section.title}
|
||||
</h2>
|
||||
</div>
|
||||
<FieldDescription description={section.description} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">{renderedChildren}</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
119
be/apps/dashboard/src/modules/schema-form/types.ts
Normal file
119
be/apps/dashboard/src/modules/schema-form/types.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
export type UiFieldComponentType =
|
||||
| 'text'
|
||||
| 'secret'
|
||||
| 'textarea'
|
||||
| 'select'
|
||||
| 'slot'
|
||||
| 'switch'
|
||||
|
||||
interface UiFieldComponentBase<Type extends UiFieldComponentType> {
|
||||
readonly type: Type
|
||||
}
|
||||
|
||||
export interface UiTextInputComponent extends UiFieldComponentBase<'text'> {
|
||||
readonly inputType?: 'text' | 'email' | 'url' | 'number'
|
||||
readonly placeholder?: string
|
||||
readonly autoComplete?: string
|
||||
}
|
||||
|
||||
export interface UiSecretInputComponent extends UiFieldComponentBase<'secret'> {
|
||||
readonly placeholder?: string
|
||||
readonly autoComplete?: string
|
||||
readonly revealable?: boolean
|
||||
}
|
||||
|
||||
export interface UiTextareaComponent extends UiFieldComponentBase<'textarea'> {
|
||||
readonly placeholder?: string
|
||||
readonly minRows?: number
|
||||
readonly maxRows?: number
|
||||
}
|
||||
|
||||
export interface UiSelectComponent extends UiFieldComponentBase<'select'> {
|
||||
readonly placeholder?: string
|
||||
readonly options?: ReadonlyArray<string>
|
||||
readonly allowCustom?: boolean
|
||||
}
|
||||
|
||||
export interface UiSwitchComponent extends UiFieldComponentBase<'switch'> {
|
||||
readonly trueLabel?: string
|
||||
readonly falseLabel?: string
|
||||
}
|
||||
|
||||
export interface UiSlotComponent<Key extends string = string>
|
||||
extends UiFieldComponentBase<'slot'> {
|
||||
readonly name: string
|
||||
readonly fields?: ReadonlyArray<{
|
||||
key: Key
|
||||
label?: string
|
||||
required?: boolean
|
||||
}>
|
||||
readonly props?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type UiFieldComponentDefinition<Key extends string = string> =
|
||||
| UiTextInputComponent
|
||||
| UiSecretInputComponent
|
||||
| UiTextareaComponent
|
||||
| UiSelectComponent
|
||||
| UiSwitchComponent
|
||||
| UiSlotComponent<Key>
|
||||
|
||||
interface BaseUiNode {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string | null
|
||||
}
|
||||
|
||||
export interface UiFieldNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'field'
|
||||
readonly key: Key
|
||||
readonly component: UiFieldComponentDefinition<Key>
|
||||
readonly helperText?: string | null
|
||||
readonly docUrl?: string | null
|
||||
readonly isSensitive?: boolean
|
||||
readonly required?: boolean
|
||||
readonly icon?: string
|
||||
readonly hidden?: boolean
|
||||
}
|
||||
|
||||
export interface UiGroupNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'group'
|
||||
readonly icon?: string
|
||||
readonly children: ReadonlyArray<UiNode<Key>>
|
||||
}
|
||||
|
||||
export interface UiSectionNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'section'
|
||||
readonly icon?: string
|
||||
readonly layout?: {
|
||||
readonly columns?: number
|
||||
}
|
||||
readonly children: ReadonlyArray<UiNode<Key>>
|
||||
}
|
||||
|
||||
export type UiNode<Key extends string = string> =
|
||||
| UiSectionNode<Key>
|
||||
| UiGroupNode<Key>
|
||||
| UiFieldNode<Key>
|
||||
|
||||
export interface UiSchema<Key extends string = string> {
|
||||
readonly version: string
|
||||
readonly title: string
|
||||
readonly description?: string | null
|
||||
readonly sections: ReadonlyArray<UiSectionNode<Key>>
|
||||
}
|
||||
|
||||
export interface UiSchemaResponse<
|
||||
Key extends string = string,
|
||||
Value = string | null,
|
||||
> {
|
||||
readonly schema: UiSchema<Key>
|
||||
readonly values?: Partial<Record<Key, Value>>
|
||||
}
|
||||
|
||||
export type SchemaFormValue = string | number | boolean | null
|
||||
|
||||
export type SchemaFormState<Key extends string = string> = Record<
|
||||
Key,
|
||||
SchemaFormValue | undefined
|
||||
>
|
||||
18
be/apps/dashboard/src/modules/schema-form/utils.ts
Normal file
18
be/apps/dashboard/src/modules/schema-form/utils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { UiFieldNode, UiNode } from './types'
|
||||
|
||||
export const collectFieldNodes = <Key extends string>(
|
||||
nodes: ReadonlyArray<UiNode<Key>>,
|
||||
): UiFieldNode<Key>[] => {
|
||||
const fields: UiFieldNode<Key>[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'field') {
|
||||
fields.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
fields.push(...collectFieldNodes(node.children))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
@@ -1,45 +1,22 @@
|
||||
/* eslint-disable react-hooks/refs */
|
||||
import {
|
||||
FormHelperText,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@afilmory/ui'
|
||||
import { clsxm, Spring } from '@afilmory/utils'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import { m } from 'motion/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { SchemaFormRendererProps } from '../../schema-form/SchemaFormRenderer'
|
||||
import {
|
||||
GlassPanel,
|
||||
SchemaFormRenderer,
|
||||
} from '../../schema-form/SchemaFormRenderer'
|
||||
import type { SchemaFormValue } from '../../schema-form/types'
|
||||
import { collectFieldNodes } from '../../schema-form/utils'
|
||||
import { useSettingUiSchemaQuery, useUpdateSettingsMutation } from '../hooks'
|
||||
import type {
|
||||
SettingEntryInput,
|
||||
SettingUiSchemaResponse,
|
||||
SettingValueState,
|
||||
UiFieldComponentDefinition,
|
||||
UiFieldNode,
|
||||
UiGroupNode,
|
||||
UiNode,
|
||||
UiSectionNode,
|
||||
} from '../types'
|
||||
|
||||
const glassCardStyles = {
|
||||
backgroundImage:
|
||||
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-background) 98%, transparent), color-mix(in srgb, var(--color-background) 95%, transparent))',
|
||||
boxShadow:
|
||||
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent), 0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
} as const
|
||||
|
||||
const glassGlowStyles = {
|
||||
background:
|
||||
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-accent) 5%, transparent), transparent, color-mix(in srgb, var(--color-accent) 5%, transparent))',
|
||||
} as const
|
||||
|
||||
const providerGroupVisibility: Record<string, string> = {
|
||||
'builder-storage-s3': 's3',
|
||||
'builder-storage-github': 'github',
|
||||
@@ -47,23 +24,6 @@ const providerGroupVisibility: Record<string, string> = {
|
||||
'builder-storage-eagle': 'eagle',
|
||||
}
|
||||
|
||||
const collectFieldNodes = (
|
||||
nodes: ReadonlyArray<UiNode<string>>,
|
||||
): UiFieldNode<string>[] => {
|
||||
const fields: UiFieldNode<string>[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'field') {
|
||||
fields.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
fields.push(...collectFieldNodes(node.children))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
const buildInitialState = (
|
||||
schema: SettingUiSchemaResponse['schema'],
|
||||
values: SettingUiSchemaResponse['values'],
|
||||
@@ -79,256 +39,6 @@ const buildInitialState = (
|
||||
return state
|
||||
}
|
||||
|
||||
const GlassPanel = ({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<div
|
||||
className={clsxm(
|
||||
'group relative overflow-hidden rounded-2xl border border-accent/20 backdrop-blur-2xl',
|
||||
className,
|
||||
)}
|
||||
style={glassCardStyles}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-2xl opacity-60"
|
||||
style={glassGlowStyles}
|
||||
/>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FieldDescription = ({ description }: { description?: string | null }) => {
|
||||
if (!description) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <p className="mt-1 text-xs text-text-tertiary">{description}</p>
|
||||
}
|
||||
|
||||
const SchemaIcon = ({
|
||||
name,
|
||||
className,
|
||||
}: {
|
||||
name?: string | null
|
||||
className?: string
|
||||
}) => {
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicIcon name={name as any} className={clsxm('h-4 w-4', className)} />
|
||||
)
|
||||
}
|
||||
|
||||
const SecretFieldInput = ({
|
||||
component,
|
||||
fieldKey,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
component: Extract<UiFieldComponentDefinition<string>, { type: 'secret' }>
|
||||
fieldKey: string
|
||||
value: string
|
||||
onChange: (key: string, value: string) => void
|
||||
}) => {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Input
|
||||
type={revealed ? 'text' : 'password'}
|
||||
value={value}
|
||||
onInput={(event) => onChange(fieldKey, event.currentTarget.value)}
|
||||
placeholder={component.placeholder ?? ''}
|
||||
autoComplete={component.autoComplete}
|
||||
className="flex-1 bg-background/60"
|
||||
/>
|
||||
{component.revealable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((prev) => !prev)}
|
||||
className="h-9 rounded-lg border border-accent/30 px-3 text-xs font-medium text-accent transition-all duration-200 hover:bg-accent/10"
|
||||
>
|
||||
{revealed ? '隐藏' : '显示'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FieldRenderer = ({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: UiFieldNode<string>
|
||||
value: string
|
||||
onChange: (key: string, value: string) => void
|
||||
}) => {
|
||||
const { component } = field
|
||||
|
||||
if (component.type === 'slot') {
|
||||
// Slot components are handled by a dedicated renderer once implemented.
|
||||
return null
|
||||
}
|
||||
|
||||
if (component.type === 'textarea') {
|
||||
return (
|
||||
<Textarea
|
||||
value={value}
|
||||
onInput={(event) => onChange(field.key, event.currentTarget.value)}
|
||||
placeholder={component.placeholder ?? ''}
|
||||
rows={component.minRows ?? 3}
|
||||
className="bg-background/60"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (component.type === 'select') {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(value) => onChange(field.key, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={component.placeholder ?? '请选择'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{component.options?.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (component.type === 'secret') {
|
||||
return (
|
||||
<SecretFieldInput
|
||||
component={component}
|
||||
fieldKey={field.key}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputType =
|
||||
component.type === 'text' ? (component.inputType ?? 'text') : 'text'
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={inputType}
|
||||
value={value}
|
||||
onInput={(event) => onChange(field.key, event.currentTarget.value)}
|
||||
placeholder={component.placeholder ?? ''}
|
||||
autoComplete={component.autoComplete}
|
||||
className="bg-background/60"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGroup = (
|
||||
node: UiGroupNode<string>,
|
||||
provider: string,
|
||||
formState: SettingValueState<string>,
|
||||
handleChange: (key: string, value: string) => void,
|
||||
) => {
|
||||
const expectedProvider = providerGroupVisibility[node.id]
|
||||
if (expectedProvider && expectedProvider !== provider) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="rounded-2xl border border-accent/10 bg-accent/[0.02] p-5 backdrop-blur-xl transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SchemaIcon name={node.icon} className="text-accent" />
|
||||
<h3 className="text-sm font-semibold text-text">{node.title}</h3>
|
||||
</div>
|
||||
<FieldDescription description={node.description} />
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{node.children.map((child) =>
|
||||
renderNode(child, provider, formState, handleChange),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderField = (
|
||||
field: UiFieldNode<string>,
|
||||
formState: SettingValueState<string>,
|
||||
handleChange: (key: string, value: string) => void,
|
||||
) => {
|
||||
const value = formState[field.key] ?? ''
|
||||
const { isSensitive } = field
|
||||
const showSensitiveHint = isSensitive && value.length === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="space-y-2 rounded-xl border border-fill-tertiary/40 bg-background/30 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-text">{field.title}</Label>
|
||||
<FieldDescription description={field.description} />
|
||||
</div>
|
||||
<SchemaIcon name={field.icon} className="text-text-tertiary" />
|
||||
</div>
|
||||
|
||||
<FieldRenderer field={field} value={value} onChange={handleChange} />
|
||||
|
||||
{showSensitiveHint ? (
|
||||
<FormHelperText>出于安全考虑,仅在更新时填写新的值。</FormHelperText>
|
||||
) : null}
|
||||
|
||||
<FormHelperText>{field.helperText ?? undefined}</FormHelperText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderNode = (
|
||||
node: UiNode<string>,
|
||||
provider: string,
|
||||
formState: SettingValueState<string>,
|
||||
handleChange: (key: string, value: string) => void,
|
||||
): ReactNode => {
|
||||
if (node.type === 'group') {
|
||||
return renderGroup(node, provider, formState, handleChange)
|
||||
}
|
||||
|
||||
if (node.type === 'field') {
|
||||
return renderField(node, formState, handleChange)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={node.id} className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SchemaIcon name={node.icon} className="h-5 w-5 text-accent" />
|
||||
<h2 className="text-base font-semibold text-text">{node.title}</h2>
|
||||
</div>
|
||||
<FieldDescription description={node.description} />
|
||||
<div className="grid gap-4">
|
||||
{node.children.map((child) =>
|
||||
renderNode(child, provider, formState, handleChange),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsForm = () => {
|
||||
const { data, isLoading, isError, error } = useSettingUiSchemaQuery()
|
||||
const updateSettingsMutation = useUpdateSettingsMutation()
|
||||
@@ -351,6 +61,24 @@ export const SettingsForm = () => {
|
||||
|
||||
const providerValue = formState['builder.storage.provider'] ?? ''
|
||||
|
||||
const shouldRenderNode = useCallback<
|
||||
NonNullable<SchemaFormRendererProps<string>['shouldRenderNode']>
|
||||
>(
|
||||
(node) => {
|
||||
if (node.type !== 'group') {
|
||||
return true
|
||||
}
|
||||
|
||||
const expectedProvider = providerGroupVisibility[node.id]
|
||||
if (!expectedProvider) {
|
||||
return true
|
||||
}
|
||||
|
||||
return expectedProvider === providerValue
|
||||
},
|
||||
[providerValue],
|
||||
)
|
||||
|
||||
const changedEntries = useMemo<SettingEntryInput[]>(() => {
|
||||
const entries: SettingEntryInput[] = []
|
||||
|
||||
@@ -365,12 +93,13 @@ export const SettingsForm = () => {
|
||||
return entries
|
||||
}, [formState])
|
||||
|
||||
const handleChange = (key: string, value: string) => {
|
||||
const handleChange = useCallback((key: string, value: SchemaFormValue) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
[key]:
|
||||
typeof value === 'string' ? value : value == null ? '' : String(value),
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault()
|
||||
@@ -394,12 +123,14 @@ export const SettingsForm = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="h-5 w-1/2 animate-pulse rounded-full bg-fill/40" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-20 animate-pulse rounded-xl bg-fill/30"
|
||||
/>
|
||||
))}
|
||||
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map(
|
||||
(key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="h-20 animate-pulse rounded-xl bg-fill/30"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
@@ -412,7 +143,7 @@ export const SettingsForm = () => {
|
||||
<div className="flex items-center gap-3 text-sm text-red">
|
||||
<i className="i-mingcute-close-circle-fill text-lg" />
|
||||
<span>
|
||||
无法加载设置:{error instanceof Error ? error.message : '未知错误'}
|
||||
{`无法加载设置:${error instanceof Error ? error.message : '未知错误'}`}
|
||||
</span>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
@@ -421,7 +152,6 @@ export const SettingsForm = () => {
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
// Should never reach here since loading and error states are handled.
|
||||
}
|
||||
|
||||
const { schema } = data
|
||||
@@ -434,30 +164,12 @@ export const SettingsForm = () => {
|
||||
transition={Spring.presets.smooth}
|
||||
className="space-y-6"
|
||||
>
|
||||
{schema.sections.map((section: UiSectionNode<string>) => (
|
||||
<GlassPanel key={section.id} className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SchemaIcon
|
||||
name={section.icon}
|
||||
className="h-5 w-5 text-accent"
|
||||
/>
|
||||
<h2 className="text-lg font-semibold text-text">
|
||||
{section.title}
|
||||
</h2>
|
||||
</div>
|
||||
<FieldDescription description={section.description} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{section.children.map((child) =>
|
||||
renderNode(child, providerValue, formState, handleChange),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
))}
|
||||
<SchemaFormRenderer
|
||||
schema={schema}
|
||||
values={formState}
|
||||
onChange={handleChange}
|
||||
shouldRenderNode={shouldRenderNode}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<div className="text-xs text-text-tertiary">
|
||||
|
||||
@@ -1,87 +1,18 @@
|
||||
export type UiFieldComponentType = 'text' | 'secret' | 'textarea' | 'select' | 'slot'
|
||||
import type { UiSchema } from '../schema-form/types'
|
||||
|
||||
interface UiFieldComponentBase<Type extends UiFieldComponentType> {
|
||||
readonly type: Type
|
||||
}
|
||||
|
||||
export interface UiTextInputComponent extends UiFieldComponentBase<'text'> {
|
||||
readonly inputType?: 'text' | 'email' | 'url' | 'number'
|
||||
readonly placeholder?: string
|
||||
readonly autoComplete?: string
|
||||
}
|
||||
|
||||
export interface UiSecretInputComponent extends UiFieldComponentBase<'secret'> {
|
||||
readonly placeholder?: string
|
||||
readonly autoComplete?: string
|
||||
readonly revealable?: boolean
|
||||
}
|
||||
|
||||
export interface UiTextareaComponent extends UiFieldComponentBase<'textarea'> {
|
||||
readonly placeholder?: string
|
||||
readonly minRows?: number
|
||||
readonly maxRows?: number
|
||||
}
|
||||
|
||||
export interface UiSelectComponent extends UiFieldComponentBase<'select'> {
|
||||
readonly placeholder?: string
|
||||
readonly options?: ReadonlyArray<string>
|
||||
readonly allowCustom?: boolean
|
||||
}
|
||||
|
||||
export interface UiSlotComponent<Key extends string = string> extends UiFieldComponentBase<'slot'> {
|
||||
readonly name: string
|
||||
readonly fields?: ReadonlyArray<{ key: Key; label?: string; required?: boolean }>
|
||||
readonly props?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type UiFieldComponentDefinition<Key extends string = string> =
|
||||
| UiTextInputComponent
|
||||
| UiSecretInputComponent
|
||||
| UiTextareaComponent
|
||||
| UiSelectComponent
|
||||
| UiSlotComponent<Key>
|
||||
|
||||
interface BaseUiNode {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string | null
|
||||
}
|
||||
|
||||
export interface UiFieldNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'field'
|
||||
readonly key: Key
|
||||
readonly component: UiFieldComponentDefinition<Key>
|
||||
readonly helperText?: string | null
|
||||
readonly docUrl?: string | null
|
||||
readonly isSensitive?: boolean
|
||||
readonly required?: boolean
|
||||
readonly icon?: string
|
||||
readonly hidden?: boolean
|
||||
}
|
||||
|
||||
export interface UiGroupNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'group'
|
||||
readonly icon?: string
|
||||
readonly children: ReadonlyArray<UiNode<Key>>
|
||||
}
|
||||
|
||||
export interface UiSectionNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'section'
|
||||
readonly icon?: string
|
||||
readonly layout?: {
|
||||
readonly columns?: number
|
||||
}
|
||||
readonly children: ReadonlyArray<UiNode<Key>>
|
||||
}
|
||||
|
||||
export type UiNode<Key extends string = string> = UiSectionNode<Key> | UiGroupNode<Key> | UiFieldNode<Key>
|
||||
|
||||
export interface UiSchema<Key extends string = string> {
|
||||
readonly version: string
|
||||
readonly title: string
|
||||
readonly description?: string | null
|
||||
readonly sections: ReadonlyArray<UiSectionNode<Key>>
|
||||
}
|
||||
export type {
|
||||
UiFieldComponentDefinition,
|
||||
UiFieldComponentType,
|
||||
UiFieldNode,
|
||||
UiGroupNode,
|
||||
UiNode,
|
||||
UiSecretInputComponent,
|
||||
UiSectionNode,
|
||||
UiSelectComponent,
|
||||
UiSlotComponent,
|
||||
UiTextareaComponent,
|
||||
UiTextInputComponent,
|
||||
} from '../schema-form/types'
|
||||
|
||||
export interface SettingUiSchemaResponse<Key extends string = string> {
|
||||
readonly schema: UiSchema<Key>
|
||||
|
||||
33
be/apps/dashboard/src/modules/super-admin/api.ts
Normal file
33
be/apps/dashboard/src/modules/super-admin/api.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
|
||||
import type {
|
||||
SuperAdminSettingsResponse,
|
||||
UpdateSuperAdminSettingsPayload,
|
||||
} from './types'
|
||||
|
||||
const SUPER_ADMIN_SETTINGS_ENDPOINT = '/super-admin/settings'
|
||||
|
||||
export const fetchSuperAdminSettings = async () =>
|
||||
await coreApi<SuperAdminSettingsResponse>(
|
||||
`${SUPER_ADMIN_SETTINGS_ENDPOINT}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
|
||||
export const updateSuperAdminSettings = async (
|
||||
payload: UpdateSuperAdminSettingsPayload,
|
||||
) => {
|
||||
const sanitizedEntries = Object.entries(payload).filter(
|
||||
([, value]) => value !== undefined,
|
||||
)
|
||||
const body = Object.fromEntries(sanitizedEntries)
|
||||
|
||||
return await coreApi<SuperAdminSettingsResponse>(
|
||||
`${SUPER_ADMIN_SETTINGS_ENDPOINT}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { clsxm, Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import {
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import {
|
||||
GlassPanel,
|
||||
SchemaFormRenderer,
|
||||
} from '../../schema-form/SchemaFormRenderer'
|
||||
import type { SchemaFormValue } from '../../schema-form/types'
|
||||
import {
|
||||
useSuperAdminSettingsQuery,
|
||||
useUpdateSuperAdminSettingsMutation,
|
||||
} from '../hooks'
|
||||
import type {
|
||||
SuperAdminSettingField,
|
||||
SuperAdminSettings,
|
||||
SuperAdminSettingsResponse,
|
||||
UpdateSuperAdminSettingsPayload,
|
||||
} from '../types'
|
||||
|
||||
type FormState = Record<SuperAdminSettingField, SchemaFormValue>
|
||||
|
||||
const BOOLEAN_FIELDS = new Set<SuperAdminSettingField>([
|
||||
'allowRegistration',
|
||||
'localProviderEnabled',
|
||||
])
|
||||
|
||||
const toFormState = (settings: SuperAdminSettings): FormState => ({
|
||||
allowRegistration: settings.allowRegistration,
|
||||
localProviderEnabled: settings.localProviderEnabled,
|
||||
maxRegistrableUsers:
|
||||
settings.maxRegistrableUsers === null
|
||||
? ''
|
||||
: String(settings.maxRegistrableUsers),
|
||||
})
|
||||
|
||||
const areFormStatesEqual = (
|
||||
left: FormState | null,
|
||||
right: FormState | null,
|
||||
): boolean => {
|
||||
if (left === right) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
left.allowRegistration === right.allowRegistration &&
|
||||
left.localProviderEnabled === right.localProviderEnabled &&
|
||||
left.maxRegistrableUsers === right.maxRegistrableUsers
|
||||
)
|
||||
}
|
||||
|
||||
const normalizeMaxUsers = (value: SchemaFormValue): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
type PossiblySnakeCaseSettings = Partial<
|
||||
SuperAdminSettings & {
|
||||
allow_registration: boolean
|
||||
local_provider_enabled: boolean
|
||||
max_registrable_users: number | null
|
||||
}
|
||||
>
|
||||
|
||||
const coerceMaxUsers = (value: unknown): number | null => {
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
const normalizeServerSettings = (
|
||||
input: PossiblySnakeCaseSettings | null,
|
||||
): SuperAdminSettings | null => {
|
||||
if (!input) {
|
||||
return null
|
||||
}
|
||||
|
||||
if ('allowRegistration' in input || 'localProviderEnabled' in input) {
|
||||
return {
|
||||
allowRegistration: input.allowRegistration ?? false,
|
||||
localProviderEnabled: input.localProviderEnabled ?? false,
|
||||
maxRegistrableUsers: coerceMaxUsers(input.maxRegistrableUsers),
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
'allow_registration' in input ||
|
||||
'local_provider_enabled' in input ||
|
||||
'max_registrable_users' in input
|
||||
) {
|
||||
return {
|
||||
allowRegistration: input.allow_registration ?? false,
|
||||
localProviderEnabled: input.local_provider_enabled ?? false,
|
||||
maxRegistrableUsers: coerceMaxUsers(input.max_registrable_users),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const extractServerValues = (
|
||||
payload: SuperAdminSettingsResponse,
|
||||
): SuperAdminSettings | null => {
|
||||
if ('values' in payload) {
|
||||
return normalizeServerSettings(payload.values ?? null)
|
||||
}
|
||||
|
||||
if ('settings' in payload) {
|
||||
return normalizeServerSettings(payload.settings ?? null)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const SuperAdminSettingsForm = () => {
|
||||
const { data, isLoading, isError, error } = useSuperAdminSettingsQuery()
|
||||
const [formState, setFormState] = useState<FormState | null>(null)
|
||||
const [initialState, setInitialState] = useState<FormState | null>(null)
|
||||
const lastServerStateRef = useRef<FormState | null>(null)
|
||||
|
||||
const syncFromServer = useCallback((payload: SuperAdminSettingsResponse) => {
|
||||
const serverValues = extractServerValues(payload)
|
||||
if (!serverValues) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = toFormState(serverValues)
|
||||
const lastState = lastServerStateRef.current
|
||||
if (lastState && areFormStatesEqual(lastState, nextState)) {
|
||||
return
|
||||
}
|
||||
|
||||
lastServerStateRef.current = nextState
|
||||
|
||||
startTransition(() => {
|
||||
setFormState(nextState)
|
||||
setInitialState(nextState)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateMutation = useUpdateSuperAdminSettingsMutation({
|
||||
onSuccess: syncFromServer,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
syncFromServer(data)
|
||||
}, [data, syncFromServer])
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!formState || !initialState) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !areFormStatesEqual(formState, initialState)
|
||||
}, [formState, initialState])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(key: SuperAdminSettingField, value: SchemaFormValue) => {
|
||||
setFormState((prev) => {
|
||||
if (!prev) {
|
||||
return prev
|
||||
}
|
||||
|
||||
if (BOOLEAN_FIELDS.has(key)) {
|
||||
return {
|
||||
...prev,
|
||||
[key]: Boolean(value),
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'maxRegistrableUsers') {
|
||||
return {
|
||||
...prev,
|
||||
[key]: normalizeMaxUsers(value),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[key]: value,
|
||||
}
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const buildPayload = (): UpdateSuperAdminSettingsPayload | null => {
|
||||
if (!formState || !initialState) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload: UpdateSuperAdminSettingsPayload = {}
|
||||
|
||||
if (formState.allowRegistration !== initialState.allowRegistration) {
|
||||
payload.allowRegistration = Boolean(formState.allowRegistration)
|
||||
}
|
||||
|
||||
if (formState.localProviderEnabled !== initialState.localProviderEnabled) {
|
||||
payload.localProviderEnabled = Boolean(formState.localProviderEnabled)
|
||||
}
|
||||
|
||||
if (formState.maxRegistrableUsers !== initialState.maxRegistrableUsers) {
|
||||
const trimmed = normalizeMaxUsers(formState.maxRegistrableUsers).trim()
|
||||
if (trimmed.length === 0) {
|
||||
payload.maxRegistrableUsers = null
|
||||
} else {
|
||||
const parsed = Number(trimmed)
|
||||
if (Number.isFinite(parsed)) {
|
||||
payload.maxRegistrableUsers = Math.max(0, Math.floor(parsed))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(payload).length > 0 ? payload : null
|
||||
}
|
||||
|
||||
const handleSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault()
|
||||
const payload = buildPayload()
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
|
||||
updateMutation.mutate(payload)
|
||||
}
|
||||
|
||||
const mutationMessage = useMemo(() => {
|
||||
if (updateMutation.isError) {
|
||||
const reason =
|
||||
updateMutation.error instanceof Error
|
||||
? updateMutation.error.message
|
||||
: '保存失败'
|
||||
return `保存失败:${reason}`
|
||||
}
|
||||
|
||||
if (updateMutation.isPending) {
|
||||
return '正在保存设置...'
|
||||
}
|
||||
|
||||
if (!hasChanges && updateMutation.isSuccess) {
|
||||
return '保存成功,设置已更新'
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
return '您有尚未保存的变更'
|
||||
}
|
||||
|
||||
return '所有设置已同步'
|
||||
}, [
|
||||
hasChanges,
|
||||
updateMutation.error,
|
||||
updateMutation.isError,
|
||||
updateMutation.isPending,
|
||||
updateMutation.isSuccess,
|
||||
])
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<GlassPanel className="p-6">
|
||||
<div className="text-sm text-red">
|
||||
<span>
|
||||
{`无法加载超级管理员设置:${error instanceof Error ? error.message : '未知错误'}`}
|
||||
</span>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading || !formState || !data) {
|
||||
return (
|
||||
<GlassPanel className="p-6 space-y-4">
|
||||
<div className="h-6 w-1/3 animate-pulse rounded-full bg-fill/40" />
|
||||
<div className="space-y-4">
|
||||
{['skeleton-1', 'skeleton-2', 'skeleton-3'].map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="h-20 animate-pulse rounded-xl bg-fill/30"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const { stats } = data
|
||||
const { registrationsRemaining, totalUsers } = stats
|
||||
const remainingLabel = (() => {
|
||||
if (
|
||||
registrationsRemaining === null ||
|
||||
registrationsRemaining === undefined
|
||||
) {
|
||||
return '无限制'
|
||||
}
|
||||
|
||||
if (
|
||||
typeof registrationsRemaining === 'number' &&
|
||||
Number.isFinite(registrationsRemaining)
|
||||
) {
|
||||
return String(registrationsRemaining)
|
||||
}
|
||||
|
||||
return '-'
|
||||
})()
|
||||
|
||||
return (
|
||||
<m.form
|
||||
onSubmit={handleSubmit}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="space-y-6"
|
||||
>
|
||||
<SchemaFormRenderer
|
||||
schema={data.schema}
|
||||
values={formState}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<GlassPanel className="p-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-text-tertiary">
|
||||
当前用户总数
|
||||
</p>
|
||||
<p className="text-3xl font-semibold text-text">
|
||||
{typeof totalUsers === 'number' ? totalUsers : 0}
|
||||
</p>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
<GlassPanel className="p-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-text-tertiary">
|
||||
剩余可注册名额
|
||||
</p>
|
||||
<p className="text-3xl font-semibold text-text">{remainingLabel}</p>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<span className="text-xs text-text-tertiary">{mutationMessage}</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending || !hasChanges}
|
||||
className={clsxm(
|
||||
'rounded-xl border border-accent/40 bg-accent px-4 py-2 text-sm font-semibold text-white transition-all duration-200',
|
||||
'hover:bg-accent/90 disabled:cursor-not-allowed disabled:border-accent/20 disabled:bg-accent/30 disabled:text-white/60',
|
||||
)}
|
||||
>
|
||||
{updateMutation.isPending ? '保存中...' : '保存修改'}
|
||||
</button>
|
||||
</div>
|
||||
</m.form>
|
||||
)
|
||||
}
|
||||
38
be/apps/dashboard/src/modules/super-admin/hooks.ts
Normal file
38
be/apps/dashboard/src/modules/super-admin/hooks.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { fetchSuperAdminSettings, updateSuperAdminSettings } from './api'
|
||||
import type {
|
||||
SuperAdminSettingsResponse,
|
||||
UpdateSuperAdminSettingsPayload,
|
||||
} from './types'
|
||||
|
||||
export const SUPER_ADMIN_SETTINGS_QUERY_KEY = [
|
||||
'super-admin',
|
||||
'settings',
|
||||
] as const
|
||||
|
||||
export const useSuperAdminSettingsQuery = () =>
|
||||
useQuery<SuperAdminSettingsResponse>({
|
||||
queryKey: SUPER_ADMIN_SETTINGS_QUERY_KEY,
|
||||
queryFn: fetchSuperAdminSettings,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
type SuperAdminSettingsMutationOptions = {
|
||||
onSuccess?: (data: SuperAdminSettingsResponse) => void
|
||||
}
|
||||
|
||||
export const useUpdateSuperAdminSettingsMutation = (
|
||||
options?: SuperAdminSettingsMutationOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: UpdateSuperAdminSettingsPayload) =>
|
||||
await updateSuperAdminSettings(payload),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(SUPER_ADMIN_SETTINGS_QUERY_KEY, data)
|
||||
options?.onSuccess?.(data)
|
||||
},
|
||||
})
|
||||
}
|
||||
4
be/apps/dashboard/src/modules/super-admin/index.ts
Normal file
4
be/apps/dashboard/src/modules/super-admin/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './api'
|
||||
export * from './components/SuperAdminSettingsForm'
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
35
be/apps/dashboard/src/modules/super-admin/types.ts
Normal file
35
be/apps/dashboard/src/modules/super-admin/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { UiSchema } from '../schema-form/types'
|
||||
|
||||
export interface SuperAdminSettings {
|
||||
allowRegistration: boolean
|
||||
localProviderEnabled: boolean
|
||||
maxRegistrableUsers: number | null
|
||||
}
|
||||
|
||||
export type SuperAdminSettingField = keyof SuperAdminSettings
|
||||
|
||||
export interface SuperAdminStats {
|
||||
totalUsers: number
|
||||
registrationsRemaining: number | null
|
||||
}
|
||||
|
||||
type SuperAdminSettingsResponseShape = {
|
||||
schema: UiSchema<SuperAdminSettingField>
|
||||
stats: SuperAdminStats
|
||||
}
|
||||
|
||||
export type SuperAdminSettingsResponse =
|
||||
| (SuperAdminSettingsResponseShape & {
|
||||
values: SuperAdminSettings
|
||||
settings?: never
|
||||
})
|
||||
| (SuperAdminSettingsResponseShape & {
|
||||
settings: SuperAdminSettings
|
||||
values?: never
|
||||
})
|
||||
|
||||
export type UpdateSuperAdminSettingsPayload = Partial<{
|
||||
allowRegistration: boolean
|
||||
localProviderEnabled: boolean
|
||||
maxRegistrableUsers: number | null
|
||||
}>
|
||||
5
be/apps/dashboard/src/pages/superadmin/index.tsx
Normal file
5
be/apps/dashboard/src/pages/superadmin/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Navigate } from 'react-router'
|
||||
|
||||
export const Component = () => {
|
||||
return <Navigate to="/superadmin/settings" replace />
|
||||
}
|
||||
127
be/apps/dashboard/src/pages/superadmin/layout.tsx
Normal file
127
be/apps/dashboard/src/pages/superadmin/layout.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { ScrollArea } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
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
|
||||
|
||||
export const Component = () => {
|
||||
const { logout } = usePageRedirect()
|
||||
const user = useAuthUserValue()
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||
|
||||
if (user && !isSuperAdmin) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (isLoggingOut) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoggingOut(true)
|
||||
try {
|
||||
await logout()
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
setIsLoggingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<nav className="shrink-0 border-b border-border/50 bg-background-tertiary px-6 py-3">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-base font-semibold text-text">
|
||||
Afilmory · Superadmin
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
{navigationTabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.path}
|
||||
to={tab.path}
|
||||
end={tab.path === '/superadmin/settings'}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<m.div
|
||||
className="relative overflow-hidden rounded-md px-3 py-1.5"
|
||||
initial={false}
|
||||
animate={{
|
||||
backgroundColor: isActive
|
||||
? 'color-mix(in srgb, var(--color-accent) 12%, transparent)'
|
||||
: 'transparent',
|
||||
}}
|
||||
whileHover={{
|
||||
backgroundColor: isActive
|
||||
? 'color-mix(in srgb, var(--color-accent) 12%, transparent)'
|
||||
: 'color-mix(in srgb, var(--color-fill) 60%, transparent)',
|
||||
}}
|
||||
transition={Spring.presets.snappy}
|
||||
>
|
||||
<span
|
||||
className="relative z-10 text-[13px] font-medium transition-colors"
|
||||
style={{
|
||||
color: isActive
|
||||
? 'var(--color-accent)'
|
||||
: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
</m.div>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{user && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-right">
|
||||
<div className="text-[13px] font-medium text-text">
|
||||
{user.name || user.email}
|
||||
</div>
|
||||
<div className="text-[11px] capitalize text-text-tertiary">
|
||||
{user.role}
|
||||
</div>
|
||||
</div>
|
||||
{user.image && (
|
||||
<img
|
||||
src={user.image}
|
||||
alt={user.name || user.email}
|
||||
className="size-7 rounded-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoggingOut}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-[13px] font-medium text-white transition-all duration-150 hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isLoggingOut ? 'Logging out...' : 'Logout'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="flex-1 overflow-hidden bg-background">
|
||||
<ScrollArea rootClassName="h-full" viewportClassName="h-full">
|
||||
<div className="mx-auto max-w-5xl px-6 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
be/apps/dashboard/src/pages/superadmin/settings.tsx
Normal file
24
be/apps/dashboard/src/pages/superadmin/settings.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
|
||||
import { SuperAdminSettingsForm } from '~/modules/super-admin'
|
||||
|
||||
export const Component = () => {
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold text-text">超级管理员设置</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
管理整个平台的注册策略与本地登录渠道,仅对超级管理员开放。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SuperAdminSettingsForm />
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
12
be/packages/db/migrations/0002_funny_captain_cross.sql
Normal file
12
be/packages/db/migrations/0002_funny_captain_cross.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "system_setting" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"value" jsonb DEFAULT 'null'::jsonb,
|
||||
"is_sensitive" boolean DEFAULT false NOT NULL,
|
||||
"description" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "uq_system_setting_key" UNIQUE("key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "photo_asset" ALTER COLUMN "conflict_payload" SET DEFAULT 'null'::jsonb;
|
||||
780
be/packages/db/migrations/meta/0002_snapshot.json
Normal file
780
be/packages/db/migrations/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,780 @@
|
||||
{
|
||||
"id": "40bebd58-4e8e-4961-aca6-df9bf5f091e3",
|
||||
"prevId": "d52362df-7abd-4540-ad11-311a2b8afaf6",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.auth_account": {
|
||||
"name": "auth_account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"auth_account_user_id_auth_user_id_fk": {
|
||||
"name": "auth_account_user_id_auth_user_id_fk",
|
||||
"tableFrom": "auth_account",
|
||||
"tableTo": "auth_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.auth_session": {
|
||||
"name": "auth_session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tenant_id": {
|
||||
"name": "tenant_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"auth_session_tenant_id_tenant_id_fk": {
|
||||
"name": "auth_session_tenant_id_tenant_id_fk",
|
||||
"tableFrom": "auth_session",
|
||||
"tableTo": "tenant",
|
||||
"columnsFrom": ["tenant_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"auth_session_user_id_auth_user_id_fk": {
|
||||
"name": "auth_session_user_id_auth_user_id_fk",
|
||||
"tableFrom": "auth_session",
|
||||
"tableTo": "auth_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"auth_session_token_unique": {
|
||||
"name": "auth_session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.auth_user": {
|
||||
"name": "auth_user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "user_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'user'"
|
||||
},
|
||||
"tenant_id": {
|
||||
"name": "tenant_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"two_factor_enabled": {
|
||||
"name": "two_factor_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"display_username": {
|
||||
"name": "display_username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"banned": {
|
||||
"name": "banned",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"ban_reason": {
|
||||
"name": "ban_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_expires_at": {
|
||||
"name": "ban_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"auth_user_tenant_id_tenant_id_fk": {
|
||||
"name": "auth_user_tenant_id_tenant_id_fk",
|
||||
"tableFrom": "auth_user",
|
||||
"tableTo": "tenant",
|
||||
"columnsFrom": ["tenant_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"auth_user_email_unique": {
|
||||
"name": "auth_user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.photo_asset": {
|
||||
"name": "photo_asset",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"tenant_id": {
|
||||
"name": "tenant_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"photo_id": {
|
||||
"name": "photo_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"storage_key": {
|
||||
"name": "storage_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"storage_provider": {
|
||||
"name": "storage_provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"etag": {
|
||||
"name": "etag",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_modified": {
|
||||
"name": "last_modified",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"metadata_hash": {
|
||||
"name": "metadata_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"manifest_version": {
|
||||
"name": "manifest_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'v7'"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "manifest",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sync_status": {
|
||||
"name": "sync_status",
|
||||
"type": "photo_sync_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"conflict_reason": {
|
||||
"name": "conflict_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"conflict_payload": {
|
||||
"name": "conflict_payload",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'null'::jsonb"
|
||||
},
|
||||
"synced_at": {
|
||||
"name": "synced_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"photo_asset_tenant_id_tenant_id_fk": {
|
||||
"name": "photo_asset_tenant_id_tenant_id_fk",
|
||||
"tableFrom": "photo_asset",
|
||||
"tableTo": "tenant",
|
||||
"columnsFrom": ["tenant_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"uq_photo_asset_tenant_storage_key": {
|
||||
"name": "uq_photo_asset_tenant_storage_key",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["tenant_id", "storage_key"]
|
||||
},
|
||||
"uq_photo_asset_tenant_photo_id": {
|
||||
"name": "uq_photo_asset_tenant_photo_id",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["tenant_id", "photo_id"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.settings": {
|
||||
"name": "settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"tenant_id": {
|
||||
"name": "tenant_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_sensitive": {
|
||||
"name": "is_sensitive",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"settings_tenant_id_tenant_id_fk": {
|
||||
"name": "settings_tenant_id_tenant_id_fk",
|
||||
"tableFrom": "settings",
|
||||
"tableTo": "tenant",
|
||||
"columnsFrom": ["tenant_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"uq_settings_tenant_key": {
|
||||
"name": "uq_settings_tenant_key",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["tenant_id", "key"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.system_setting": {
|
||||
"name": "system_setting",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'null'::jsonb"
|
||||
},
|
||||
"is_sensitive": {
|
||||
"name": "is_sensitive",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"uq_system_setting_key": {
|
||||
"name": "uq_system_setting_key",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["key"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tenant_domain": {
|
||||
"name": "tenant_domain",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"tenant_id": {
|
||||
"name": "tenant_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"domain": {
|
||||
"name": "domain",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_primary": {
|
||||
"name": "is_primary",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"tenant_domain_tenant_id_tenant_id_fk": {
|
||||
"name": "tenant_domain_tenant_id_tenant_id_fk",
|
||||
"tableFrom": "tenant_domain",
|
||||
"tableTo": "tenant",
|
||||
"columnsFrom": ["tenant_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"uq_tenant_domain_domain": {
|
||||
"name": "uq_tenant_domain_domain",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["domain"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tenant": {
|
||||
"name": "tenant",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "tenant_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'inactive'"
|
||||
},
|
||||
"primary_domain": {
|
||||
"name": "primary_domain",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_primary": {
|
||||
"name": "is_primary",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"uq_tenant_slug": {
|
||||
"name": "uq_tenant_slug",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["slug"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.photo_sync_status": {
|
||||
"name": "photo_sync_status",
|
||||
"schema": "public",
|
||||
"values": ["pending", "synced", "conflict"]
|
||||
},
|
||||
"public.tenant_status": {
|
||||
"name": "tenant_status",
|
||||
"schema": "public",
|
||||
"values": ["active", "inactive", "suspended"]
|
||||
},
|
||||
"public.user_role": {
|
||||
"name": "user_role",
|
||||
"schema": "public",
|
||||
"values": ["user", "admin", "superadmin"]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1761492038684,
|
||||
"tag": "0001_data_sync_module",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1761586496779,
|
||||
"tag": "0002_funny_captain_cross",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -139,6 +139,20 @@ export const settings = pgTable(
|
||||
(t) => [unique('uq_settings_tenant_key').on(t.tenantId, t.key)],
|
||||
)
|
||||
|
||||
export const systemSettings = pgTable(
|
||||
'system_setting',
|
||||
{
|
||||
id: snowflakeId,
|
||||
key: text('key').notNull(),
|
||||
value: jsonb('value').$type<unknown | null>().default(null),
|
||||
isSensitive: boolean('is_sensitive').notNull().default(false),
|
||||
description: text('description'),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [unique('uq_system_setting_key').on(t.key)],
|
||||
)
|
||||
|
||||
export const photoAssets = pgTable(
|
||||
'photo_asset',
|
||||
{
|
||||
@@ -175,6 +189,7 @@ export const dbSchema = {
|
||||
authSessions,
|
||||
authAccounts,
|
||||
settings,
|
||||
systemSettings,
|
||||
photoAssets,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user