feat: superadmin settings

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-10-28 02:35:34 +08:00
parent a4e506f51c
commit e2d2345b78
36 changed files with 2910 additions and 427 deletions

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
import { systemSettings } from '@afilmory/db'
import { eq, inArray } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { DbAccessor } from '../../database/database.provider'
import type {
SystemSettingEntryInput,
SystemSettingKey,
SystemSettingRecord,
SystemSettingSetOptions,
} from './system-setting.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
}
}

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

View File

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

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

View File

@@ -0,0 +1,4 @@
export * from './api'
export * from './components/SuperAdminSettingsForm'
export * from './hooks'
export * from './types'

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

View File

@@ -0,0 +1,5 @@
import { Navigate } from 'react-router'
export const Component = () => {
return <Navigate to="/superadmin/settings" replace />
}

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

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

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

View 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": {}
}
}

View File

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

View File

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