feat(auth): implement social authentication support and refactor related components

- Added social authentication buttons to the registration and login flows.
- Updated API endpoints for tenant registration and session management.
- Refactored authentication client to unify global and tenant authentication handling.
- Removed deprecated tenant authentication module and related configurations.
- Enhanced onboarding and settings UI to accommodate new social provider configurations.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-07 02:16:00 +08:00
parent c1d585d9ac
commit 89f2825a1b
42 changed files with 1178 additions and 910 deletions

View File

@@ -103,7 +103,7 @@ export async function handleResetSuperAdminPassword(options: ResetCliOptions): P
const dbAccessor = container.resolve(DbAccessor)
try {
const auth = authProvider.getAuth()
const auth = await authProvider.getAuth()
const context = await auth.$context
const rawPassword = options.password ?? generateRandomPassword()
const { minPasswordLength, maxPasswordLength } = context.password.config

View File

@@ -1,4 +1,4 @@
import { authUsers, tenantAuthUsers } from '@afilmory/db'
import { authUsers } from '@afilmory/db'
import type { CanActivate, ExecutionContext } from '@afilmory/framework'
import { HttpContext } from '@afilmory/framework'
import type { Session } from 'better-auth'
@@ -13,16 +13,13 @@ import { shouldSkipTenant } from '../decorators/skip-tenant.decorator'
import { logger } from '../helpers/logger.helper'
import type { AuthSession } from '../modules/auth/auth.provider'
import { AuthProvider } from '../modules/auth/auth.provider'
import type { TenantAuthSession } from '../modules/tenant-auth/tenant-auth.provider'
import { TenantAuthProvider } from '../modules/tenant-auth/tenant-auth.provider'
import { getAllowedRoleMask, roleNameToBit } from './roles.decorator'
declare module '@afilmory/framework' {
interface HttpContextValues {
auth?: {
user?: AuthSession['user'] | TenantAuthSession['user']
user?: AuthSession['user']
session?: Session
source?: 'global' | 'tenant'
}
}
}
@@ -33,7 +30,6 @@ export class AuthGuard implements CanActivate {
constructor(
private readonly authProvider: AuthProvider,
private readonly tenantAuthProvider: TenantAuthProvider,
private readonly dbAccessor: DbAccessor,
private readonly tenantContextResolver: TenantContextResolver,
) {}
@@ -45,11 +41,6 @@ export class AuthGuard implements CanActivate {
const handler = context.getHandler()
const targetClass = context.getClass()
if (this.isPublicRoute(method, path)) {
this.log.verbose(`Bypass guard for public route ${method} ${path}`)
return true
}
if (shouldSkipTenant(handler) || shouldSkipTenant(targetClass)) {
this.log.verbose(`Skip guard and tenant resolution for ${method} ${path}`)
return true
@@ -77,26 +68,13 @@ export class AuthGuard implements CanActivate {
const { headers } = hono.req.raw
const globalAuth = this.authProvider.getAuth()
let sessionSource: 'global' | 'tenant' | null = null
let authSession: AuthSession | TenantAuthSession | null = await globalAuth.api.getSession({ headers })
const globalAuth = await this.authProvider.getAuth()
const authSession: AuthSession | null = await globalAuth.api.getSession({ headers })
if (authSession) {
sessionSource = 'global'
this.log.verbose(`Global session detected for user ${(authSession.user as { id?: string }).id ?? 'unknown'}`)
} else if (tenantContext) {
const tenantAuth = await this.tenantAuthProvider.getAuth(tenantContext.tenant.id)
authSession = await tenantAuth.api.getSession({ headers })
if (authSession) {
sessionSource = 'tenant'
this.log.verbose(
`Tenant session detected for user ${(authSession.user as { id?: string }).id ?? 'unknown'} on tenant ${tenantContext.tenant.id}`,
)
} else {
this.log.verbose(`No tenant session present for tenant ${tenantContext.tenant.id}`)
}
this.log.verbose(`Session detected for user ${(authSession.user as { id?: string }).id ?? 'unknown'}`)
} else {
this.log.verbose('No session context available (no tenant resolved and no global session)')
this.log.verbose('No session context available (no tenant resolved and no active session)')
}
if (authSession) {
@@ -104,33 +82,22 @@ export class AuthGuard implements CanActivate {
auth: {
user: authSession.user,
session: authSession.session,
source: sessionSource ?? undefined,
},
})
const userRoleValue = (authSession.user as { role?: string }).role
const roleName = userRoleValue as 'user' | 'admin' | 'superadmin' | 'guest' | undefined
const isGlobalSession = sessionSource === 'global'
const isSuperAdmin = isGlobalSession && roleName === 'superadmin'
const isSuperAdmin = roleName === 'superadmin'
let sessionTenantId = (authSession.user as { tenantId?: string | null }).tenantId ?? null
if (!isSuperAdmin) {
if (!sessionTenantId) {
const db = this.dbAccessor.get()
if (sessionSource === 'tenant') {
const [record] = await db
.select({ tenantId: tenantAuthUsers.tenantId })
.from(tenantAuthUsers)
.where(eq(tenantAuthUsers.id, authSession.user.id))
.limit(1)
sessionTenantId = record?.tenantId ?? ''
} else {
const [record] = await db
.select({ tenantId: authUsers.tenantId })
.from(authUsers)
.where(eq(authUsers.id, authSession.user.id))
.limit(1)
sessionTenantId = record?.tenantId ?? ''
}
const [record] = await db
.select({ tenantId: authUsers.tenantId })
.from(authUsers)
.where(eq(authUsers.id, authSession.user.id))
.limit(1)
sessionTenantId = record?.tenantId ?? ''
}
if (!sessionTenantId) {
@@ -190,16 +157,4 @@ export class AuthGuard implements CanActivate {
}
return true
}
private isPublicRoute(method: string, path: string): boolean {
if (method !== 'POST') {
return false
}
if (path === '/api/auth/tenants/sign-up' || path.startsWith('/api/auth/tenants/sign-up/')) {
return true
}
return false
}
}

View File

@@ -5,6 +5,7 @@ import { injectable } from 'tsyringe'
import { DbAccessor } from '../../database/database.provider'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { getTenantContext } from '../tenant/tenant.context'
import { TenantRepository } from '../tenant/tenant.repository'
import { TenantService } from '../tenant/tenant.service'
import type { TenantRecord } from '../tenant/tenant.types'
@@ -18,7 +19,7 @@ type RegisterTenantAccountInput = {
type RegisterTenantInput = {
account: RegisterTenantAccountInput
tenant: {
tenant?: {
name: string
slug?: string | null
}
@@ -54,26 +55,119 @@ export class AuthRegistrationService {
async registerTenant(input: RegisterTenantInput, headers: Headers): Promise<RegisterTenantResult> {
await this.superAdminSettings.ensureRegistrationAllowed()
const accountEmail = input.account.email.trim().toLowerCase()
const accountPassword = input.account.password
const accountName = input.account.name.trim() || accountEmail
const tenantContext = getTenantContext()
const account = this.normalizeAccountInput(input.account)
if (!accountEmail) {
if (tenantContext) {
return await this.registerExistingTenantMember(account, headers, tenantContext.tenant)
}
if (!input.tenant) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户信息不能为空' })
}
return await this.registerNewTenant(account, input.tenant, headers)
}
private async generateUniqueSlug(base: string): Promise<string> {
const sanitizedBase = base.length > 0 ? base : 'tenant'
for (let attempt = 0; attempt < 50; attempt += 1) {
const candidate = attempt === 0 ? sanitizedBase : `${sanitizedBase}-${attempt + 1}`
const existing = await this.tenantRepository.findBySlug(candidate)
if (!existing) {
return candidate
}
}
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '无法生成唯一的租户标识,请尝试使用不同的名称',
})
}
private normalizeAccountInput(account: RegisterTenantAccountInput): Required<RegisterTenantAccountInput> {
const email = account.email?.trim().toLowerCase() ?? ''
if (!email) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '邮箱不能为空' })
}
if (accountPassword.trim().length < 8) {
const password = account.password?.trim() ?? ''
if (password.length < 8) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '密码长度至少需要 8 个字符',
})
}
const tenantName = input.tenant.name.trim()
const name = account.name?.trim() || email
return {
email,
password,
name,
}
}
private async registerExistingTenantMember(
account: Required<RegisterTenantAccountInput>,
headers: Headers,
tenant: TenantRecord,
): Promise<RegisterTenantResult> {
headers.set('x-tenant-id', tenant.id)
if (tenant.slug) {
headers.set('x-tenant-slug', tenant.slug)
}
const auth = await this.authProvider.getAuth()
const response = await auth.api.signUpEmail({
body: {
email: account.email,
password: account.password,
name: account.name,
},
headers,
asResponse: true,
})
if (!response.ok) {
return { response, success: false, tenant }
}
let userId: string | undefined
try {
const payload = (await response.clone().json()) as { user?: { id?: string } } | null
userId = payload?.user?.id
} catch {
userId = undefined
}
if (!userId) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '注册成功但未返回用户信息,请稍后重试。',
})
}
const db = this.dbAccessor.get()
await db.update(authUsers).set({ tenantId: tenant.id, role: 'user' }).where(eq(authUsers.id, userId))
return {
response,
tenant,
accountId: userId,
success: true,
}
}
private async registerNewTenant(
account: Required<RegisterTenantAccountInput>,
tenantInput: RegisterTenantInput['tenant'],
headers: Headers,
): Promise<RegisterTenantResult> {
const tenantName = tenantInput?.name?.trim() ?? ''
if (!tenantName) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户名称不能为空' })
}
const slugBase = input.tenant.slug?.trim() ? slugify(input.tenant.slug) : slugify(tenantName)
const slugBase = tenantInput?.slug?.trim() ? slugify(tenantInput.slug) : slugify(tenantName)
if (!slugBase) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户标识不能为空' })
}
@@ -88,12 +182,12 @@ export class AuthRegistrationService {
})
tenantId = tenantAggregate.tenant.id
const auth = this.authProvider.getAuth()
const auth = await this.authProvider.getAuth()
const response = await auth.api.signUpEmail({
body: {
email: accountEmail,
password: accountPassword,
name: accountName,
email: account.email,
password: account.password,
name: account.name,
},
headers,
asResponse: true,
@@ -143,20 +237,4 @@ export class AuthRegistrationService {
throw error
}
}
private async generateUniqueSlug(base: string): Promise<string> {
const sanitizedBase = base.length > 0 ? base : 'tenant'
for (let attempt = 0; attempt < 50; attempt += 1) {
const candidate = attempt === 0 ? sanitizedBase : `${sanitizedBase}-${attempt + 1}`
const existing = await this.tenantRepository.findBySlug(candidate)
if (!existing) {
return candidate
}
}
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '无法生成唯一的租户标识,请尝试使用不同的名称',
})
}
}

View File

@@ -1,42 +1,38 @@
import { env } from '@afilmory/env'
import { injectable } from 'tsyringe'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
export interface SocialProviderOptions {
clientId: string
clientSecret: string
redirectPath?: string | null
}
export interface SocialProvidersConfig {
google?: { clientId: string; clientSecret: string; redirectUri?: string }
github?: { clientId: string; clientSecret: string; redirectUri?: string }
zoom?: { clientId: string; clientSecret: string; redirectUri?: string }
google?: SocialProviderOptions
github?: SocialProviderOptions
}
export interface AuthModuleOptions {
prefix: string
useDrizzle: boolean
socialProviders: SocialProvidersConfig
baseDomain: string
}
@injectable()
export class AuthConfig {
getOptions(): AuthModuleOptions {
constructor(private readonly superAdminSettings: SuperAdminSettingService) {}
async getOptions(): Promise<AuthModuleOptions> {
const prefix = '/auth'
const socialProviders: SocialProvidersConfig = {}
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
socialProviders.google = {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
}
}
if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) {
socialProviders.github = {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
}
}
const { socialProviders, baseDomain } = await this.superAdminSettings.getAuthModuleConfig()
return {
prefix,
useDrizzle: true,
socialProviders,
baseDomain,
}
}
}

View File

@@ -7,9 +7,48 @@ 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 { getTenantContext } from '../tenant/tenant.context'
import type { SocialProvidersConfig } from './auth.config'
import { AuthProvider } from './auth.provider'
import { AuthRegistrationService } from './auth-registration.service'
const SOCIAL_PROVIDER_METADATA: Record<string, { name: string; icon: string }> = {
google: {
name: 'Google',
icon: 'i-simple-icons-google',
},
github: {
name: 'GitHub',
icon: 'i-simple-icons-github',
},
}
function resolveSocialProviderMetadata(id: string): { name: string; icon: string } {
const metadata = SOCIAL_PROVIDER_METADATA[id]
if (metadata) {
return metadata
}
const formattedId = id.replaceAll(/[-_]/g, ' ').replaceAll(/\b\w/g, (match) => match.toUpperCase())
return {
name: formattedId.trim() || id,
icon: 'i-mingcute-earth-2-line',
}
}
function buildProviderResponse(socialProviders: SocialProvidersConfig) {
return Object.entries(socialProviders)
.filter(([, config]) => Boolean(config))
.map(([id, config]) => {
const metadata = resolveSocialProviderMetadata(id)
return {
id,
name: metadata.name,
icon: metadata.icon,
callbackPath: config?.redirectPath ?? null,
}
})
}
type TenantSignUpRequest = {
account?: {
email?: string
@@ -22,6 +61,15 @@ type TenantSignUpRequest = {
}
}
type SocialSignInRequest = {
provider: string
requestSignUp?: boolean
callbackURL?: string
errorCallbackURL?: string
newUserCallbackURL?: string
disableRedirect?: boolean
}
@Controller('auth')
export class AuthController {
constructor(
@@ -40,10 +88,15 @@ export class AuthController {
return {
user: authContext.user,
session: authContext.session,
source: authContext.source ?? 'global',
}
}
@Get('/social/providers')
async getSocialProviders() {
const { socialProviders } = await this.superAdminSettings.getAuthModuleConfig()
return { providers: buildProviderResponse(socialProviders) }
}
@Post('/sign-in/email')
async signInEmail(@ContextParam() context: Context, @Body() body: { email: string; password: string }) {
const email = body.email.trim()
@@ -67,7 +120,7 @@ export class AuthController {
}
}
const auth = this.auth.getAuth()
const auth = await this.auth.getAuth()
const headers = new Headers(context.req.raw.headers)
const tenant = (context as any).var?.tenant
if (tenant?.tenant?.id) {
@@ -85,13 +138,62 @@ export class AuthController {
return response
}
@Post('/tenants/sign-up')
async signUpTenant(@ContextParam() context: Context, @Body() body: TenantSignUpRequest) {
if (!body?.account || !body?.tenant) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少注册信息' })
@Post('/social')
async signInSocial(@ContextParam() context: Context, @Body() body: SocialSignInRequest) {
const provider = body?.provider?.trim()
if (!provider) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth Provider 参数' })
}
const headers = new Headers(context.req.raw.headers)
const tenantContext = getTenantContext()
if (tenantContext) {
headers.set('x-tenant-id', tenantContext.tenant.id)
if (tenantContext.tenant.slug) {
headers.set('x-tenant-slug', tenantContext.tenant.slug)
}
}
const auth = await this.auth.getAuth()
const response = await auth.api.signInSocial({
body: {
...body,
provider,
requestSignUp: body.requestSignUp ?? Boolean(tenantContext),
},
headers,
asResponse: true,
})
if (tenantContext) {
context.header('x-tenant-id', tenantContext.tenant.id)
if (tenantContext.tenant.slug) {
context.header('x-tenant-slug', tenantContext.tenant.slug)
}
}
return response
}
@Post('/sign-up/email')
async signUpEmail(@ContextParam() context: Context, @Body() body: TenantSignUpRequest) {
if (!body?.account) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少注册账号信息' })
}
const tenantContext = getTenantContext()
if (!tenantContext && !body.tenant) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少租户信息' })
}
const headers = new Headers(context.req.raw.headers)
if (tenantContext) {
headers.set('x-tenant-id', tenantContext.tenant.id)
if (tenantContext.tenant.slug) {
headers.set('x-tenant-slug', tenantContext.tenant.slug)
}
}
const result = await this.registration.registerTenant(
{
@@ -100,10 +202,12 @@ export class AuthController {
password: body.account.password ?? '',
name: body.account.name ?? '',
},
tenant: {
name: body.tenant.name ?? '',
slug: body.tenant.slug ?? null,
},
tenant: body.tenant
? {
name: body.tenant.name ?? '',
slug: body.tenant.slug ?? null,
}
: undefined,
},
headers,
)

View File

@@ -11,6 +11,7 @@ import { injectable } from 'tsyringe'
import { DrizzleProvider } from '../../database/database.provider'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import type { AuthModuleOptions, SocialProviderOptions, SocialProvidersConfig } from './auth.config'
import { AuthConfig } from './auth.config'
export type BetterAuthInstance = ReturnType<typeof betterAuth>
@@ -19,7 +20,8 @@ const logger = createLogger('Auth')
@injectable()
export class AuthProvider implements OnModuleInit {
private instance?: ReturnType<typeof this.createAuth>
private moduleOptionsPromise?: Promise<AuthModuleOptions>
private instances = new Map<string, Promise<BetterAuthInstance>>()
constructor(
private readonly config: AuthConfig,
@@ -27,13 +29,127 @@ export class AuthProvider implements OnModuleInit {
private readonly superAdminSettings: SuperAdminSettingService,
) {}
onModuleInit(): void {
this.instance = this.getAuth()
async onModuleInit(): Promise<void> {
await this.getAuth()
}
private createAuth() {
const options = this.config.getOptions()
private resolveTenantIdFromContext(): string | null {
try {
const tenantContext = HttpContext.getValue('tenant') as { tenant?: { id?: string | null } } | undefined
const tenantId = tenantContext?.tenant?.id
return tenantId ?? null
} catch {
return null
}
}
private resolveTenantSlugFromContext(): string | null {
try {
const tenantContext = HttpContext.getValue('tenant') as { tenant?: { slug?: string | null } } | undefined
const slug = tenantContext?.tenant?.slug
return slug ? slug.toLowerCase() : null
} catch {
return null
}
}
private async getModuleOptions(): Promise<AuthModuleOptions> {
if (!this.moduleOptionsPromise) {
this.moduleOptionsPromise = this.config.getOptions()
}
return this.moduleOptionsPromise
}
private resolveRequestEndpoint(): { host: string | null; protocol: string | null } {
try {
const hono = HttpContext.getValue('hono') as Context | undefined
if (!hono) {
return { host: null, protocol: null }
}
const forwardedHost = hono.req.header('x-forwarded-host')
const forwardedProto = hono.req.header('x-forwarded-proto')
const hostHeader = hono.req.header('host')
return {
host: (forwardedHost ?? hostHeader ?? '').trim() || null,
protocol: (forwardedProto ?? '').trim() || null,
}
} catch {
return { host: null, protocol: null }
}
}
private determineProtocol(host: string, provided: string | null): string {
if (provided && (provided === 'http' || provided === 'https')) {
return provided
}
if (host.includes('localhost') || host.startsWith('127.') || host.startsWith('0.0.0.0')) {
return 'http'
}
return 'https'
}
private applyTenantSlugToHost(host: string, fallbackHost: string, tenantSlug: string | null): string {
if (!tenantSlug) {
return host
}
const [hostName, hostPort] = host.split(':') as [string, string?]
if (hostName.startsWith(`${tenantSlug}.`)) {
return host
}
const [fallbackName, fallbackPort] = fallbackHost.split(':') as [string, string?]
if (hostName !== fallbackName) {
return host
}
const portSegment = hostPort ?? fallbackPort
return portSegment ? `${tenantSlug}.${fallbackName}:${portSegment}` : `${tenantSlug}.${fallbackName}`
}
private buildBetterAuthProvidersForHost(
host: string,
protocol: string,
providers: SocialProvidersConfig,
): Record<string, { clientId: string; clientSecret: string; redirectUri?: string }> {
const entries: Array<[keyof SocialProvidersConfig, SocialProviderOptions]> = Object.entries(providers).filter(
(entry): entry is [keyof SocialProvidersConfig, SocialProviderOptions] => Boolean(entry[1]),
)
return entries.reduce<Record<string, { clientId: string; clientSecret: string; redirectUri?: string }>>(
(acc, [key, value]) => {
const redirectUri = this.buildRedirectUri(protocol, host, key, value)
acc[key] = {
clientId: value.clientId,
clientSecret: value.clientSecret,
...(redirectUri ? { redirectUri } : {}),
}
return acc
},
{},
)
}
private buildRedirectUri(
protocol: string,
host: string,
provider: keyof SocialProvidersConfig,
options: SocialProviderOptions,
): string | null {
const basePath = options.redirectPath ?? `/api/auth/callback/${provider}`
if (!basePath.startsWith('/')) {
return null
}
return `${protocol}://${host}${basePath}`
}
private async createAuthForEndpoint(host: string, protocol: string): Promise<BetterAuthInstance> {
const options = await this.getModuleOptions()
const db = this.drizzleProvider.getDb()
const socialProviders = this.buildBetterAuthProvidersForHost(host, protocol, options.socialProviders)
return betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
@@ -43,25 +159,58 @@ export class AuthProvider implements OnModuleInit {
account: authAccounts,
},
}),
socialProviders: options.socialProviders,
socialProviders: socialProviders as any,
emailAndPassword: { enabled: true },
user: {
// Ensure tenantId and role are part of the typed/session payload
additionalFields: {
tenantId: { type: 'string', input: false },
role: { type: 'string', input: false },
},
},
databaseHooks: {
user: {
create: {
before: async (user) => {
const tenantId = this.resolveTenantIdFromContext()
if (!tenantId) {
return { data: user }
}
return {
data: {
...user,
tenantId,
role: user.role ?? 'guest',
},
}
},
},
},
session: {
create: {
before: async (session) => {
// Attach tenantId from our request-scoped context to the auth session record
const tenant = HttpContext.getValue('tenant') as { tenant: { id: string } } | undefined
const tenantId = this.resolveTenantIdFromContext()
return {
data: {
...session,
tenantId: tenant?.tenant.id ?? null,
tenantId: tenantId ?? session.tenantId ?? null,
},
}
},
},
},
account: {
create: {
before: async (account) => {
const tenantId = this.resolveTenantIdFromContext()
if (!tenantId) {
return { data: account }
}
return {
data: {
...account,
tenantId,
},
}
},
@@ -101,19 +250,33 @@ export class AuthProvider implements OnModuleInit {
},
})
}
getAuth() {
if (!this.instance) {
this.instance = this.createAuth()
logger.info('Better Auth initialized')
async getAuth(): Promise<BetterAuthInstance> {
const options = await this.getModuleOptions()
const endpoint = this.resolveRequestEndpoint()
const fallbackHost = options.baseDomain.trim().toLowerCase()
const requestedHost = (endpoint.host ?? fallbackHost).trim().toLowerCase()
const tenantSlug = this.resolveTenantSlugFromContext()
const host = this.applyTenantSlugToHost(requestedHost || fallbackHost, fallbackHost, tenantSlug)
const protocol = this.determineProtocol(host, endpoint.protocol)
const cacheKey = `${protocol}://${host}`
if (!this.instances.has(cacheKey)) {
const instancePromise = this.createAuthForEndpoint(host, protocol).then((instance) => {
logger.info(`Better Auth initialized for ${cacheKey}`)
return instance
})
this.instances.set(cacheKey, instancePromise)
}
return this.instance
return await this.instances.get(cacheKey)!
}
handler(context: Context): Promise<Response> {
const auth = this.getAuth()
async handler(context: Context): Promise<Response> {
const auth = await this.getAuth()
return auth.handler(context.req.raw)
}
}
export type AuthInstance = ReturnType<AuthProvider['createAuth']>
export type AuthInstance = BetterAuthInstance
export type AuthSession = BetterAuthInstance['$Infer']['Session']

View File

@@ -15,10 +15,10 @@ import { PhotoModule } from './photo/photo.module'
import { ReactionModule } from './reaction/reaction.module'
import { SettingModule } from './setting/setting.module'
import { StaticWebModule } from './static-web/static-web.module'
import { StorageSettingModule } from './storage-setting/storage-setting.module'
import { SuperAdminModule } from './super-admin/super-admin.module'
import { SystemSettingModule } from './system-setting/system-setting.module'
import { TenantModule } from './tenant/tenant.module'
import { TenantAuthModule } from './tenant-auth/tenant-auth.module'
function createEventModuleOptions(redis: RedisAccessor) {
return {
@@ -32,6 +32,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
RedisModule,
AuthModule,
SettingModule,
StorageSettingModule,
SystemSettingModule,
SuperAdminModule,
OnboardingModule,
@@ -39,7 +40,6 @@ function createEventModuleOptions(redis: RedisAccessor) {
ReactionModule,
DashboardModule,
TenantModule,
TenantAuthModule,
DataSyncModule,
StaticWebModule,
EventModule.forRootAsync({

View File

@@ -58,7 +58,7 @@ export class OnboardingService {
await this.settings.setMany(entriesWithTenant)
}
const auth = this.auth.getAuth()
const auth = await this.auth.getAuth()
// Create initial admin for this tenant
const adminResult = await auth.api.signUpEmail({

View File

@@ -15,44 +15,6 @@ export const DEFAULT_SETTING_DEFINITIONS = {
// isSensitive: false,
// schema: z.string().min(1, 'AI Model name cannot be empty'),
// },
'auth.google.clientId': {
isSensitive: false,
schema: z.string().min(1, 'Google Client ID cannot be empty'),
},
'auth.google.clientSecret': {
isSensitive: true,
schema: z.string().min(1, 'Google Client secret cannot be empty'),
},
'auth.github.clientId': {
isSensitive: false,
schema: z.string().min(1, 'GitHub Client ID cannot be empty'),
},
'auth.github.clientSecret': {
isSensitive: true,
schema: z.string().min(1, 'GitHub Client secret cannot be empty'),
},
'auth.tenant.config': {
isSensitive: true,
schema: z
.string()
.transform((value) => value.trim())
.transform((value, ctx) => {
if (value.length === 0) {
return '{}'
}
try {
const parsed = JSON.parse(value)
return JSON.stringify(parsed)
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Tenant auth configuration must be valid JSON',
})
return z.NEVER
}
}),
},
'builder.storage.providers': {
isSensitive: false,
schema: z.string().transform((value, ctx) => {
@@ -84,13 +46,6 @@ export const DEFAULT_SETTING_DEFINITIONS = {
isSensitive: false,
schema: z.string().transform((value) => value.trim()),
},
'http.cors.allowedOrigins': {
isSensitive: false,
schema: z
.string()
.min(1, 'CORS allowed origins cannot be empty')
.transform((value) => value.trim()),
},
} as const satisfies Record<string, SettingDefinition>
export const DEFAULT_SETTING_METADATA = Object.fromEntries(

View File

@@ -9,7 +9,7 @@ import { DeleteSettingDto, GetSettingDto, SetSettingDto } from './setting.dto'
import { SettingService } from './setting.service'
@Controller('settings')
@Roles('admin')
@Roles('superadmin')
export class SettingController {
constructor(private readonly settingService: SettingService) {}

View File

@@ -8,5 +8,6 @@ import { SettingService } from './setting.service'
imports: [DatabaseModule],
providers: [SettingService],
controllers: [SettingController],
exports: [SettingService],
})
export class SettingModule {}

View File

@@ -36,7 +36,7 @@ declare module '@afilmory/framework' {
'setting.deleted': { tenantId: string; key: string }
}
}
type SettingEntryInput = {
export type SettingEntryInput = {
[K in SettingKeyType]: { key: K; value: SettingValueMap[K]; options?: SetSettingOptions }
}[SettingKeyType]

View File

@@ -1,135 +1,13 @@
import type { UiNode } from '../ui-schema/ui-schema.type'
import { DEFAULT_SETTING_METADATA } from './setting.constant'
import type { SettingKeyType, SettingUiSchema } from './setting.type'
function getIsSensitive(key: SettingKeyType): boolean {
return DEFAULT_SETTING_METADATA[key]?.isSensitive ?? false
}
export const SETTING_UI_SCHEMA_VERSION = '1.1.0'
export const SETTING_UI_SCHEMA_VERSION = '1.3.0'
export const SETTING_UI_SCHEMA: SettingUiSchema = {
version: SETTING_UI_SCHEMA_VERSION,
title: '系统设置',
description: '管理 AFilmory 系统的全局行为与第三方服务接入。',
sections: [
{
type: 'section',
id: 'auth',
title: '登录与认证',
description: '配置第三方 OAuth 登录用于后台访问控制。',
icon: 'shield-check',
children: [
{
type: 'group',
id: 'auth-google',
title: 'Google OAuth',
description: '在 Google Cloud Console 中创建 OAuth 应用后填写以下信息。',
icon: 'badge-check',
children: [
{
type: 'field',
id: 'auth.google.clientId',
title: 'Client ID',
description: 'Google OAuth 的客户端 ID。',
key: 'auth.google.clientId',
helperText: '通常以 .apps.googleusercontent.com 结尾。',
isSensitive: getIsSensitive('auth.google.clientId'),
component: {
type: 'text',
placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com',
autoComplete: 'off',
},
},
{
type: 'field',
id: 'auth.google.clientSecret',
title: 'Client Secret',
description: 'Google OAuth 的客户端密钥。',
key: 'auth.google.clientSecret',
isSensitive: getIsSensitive('auth.google.clientSecret'),
component: {
type: 'secret',
placeholder: '************',
autoComplete: 'off',
revealable: true,
},
},
],
},
{
type: 'group',
id: 'auth-github',
title: 'GitHub OAuth',
description: '在 GitHub OAuth Apps 中创建应用后填写。',
icon: 'github',
children: [
{
type: 'field',
id: 'auth.github.clientId',
title: 'Client ID',
description: 'GitHub OAuth 的客户端 ID。',
key: 'auth.github.clientId',
helperText: '在 GitHub Developer settings 中可以找到。',
isSensitive: getIsSensitive('auth.github.clientId'),
component: {
type: 'text',
placeholder: 'Iv1.xxxxxxxxxxxxxxxx',
autoComplete: 'off',
},
},
{
type: 'field',
id: 'auth.github.clientSecret',
title: 'Client Secret',
description: 'GitHub OAuth 的客户端密钥。',
key: 'auth.github.clientSecret',
isSensitive: getIsSensitive('auth.github.clientSecret'),
component: {
type: 'secret',
placeholder: '****************',
autoComplete: 'off',
revealable: true,
},
},
],
},
],
},
{
type: 'section',
id: 'http',
title: 'HTTP 与安全',
description: '控制跨域访问等 Web 层配置。',
icon: 'globe-2',
children: [
{
type: 'group',
id: 'http-cors',
title: '跨域策略 (CORS)',
description: '配置允许访问后台接口的来源列表。',
icon: 'shield-alert',
children: [
{
type: 'field',
id: 'http.cors.allowedOrigins',
title: '允许的域名列表',
description: '以逗号分隔的域名或通配符,必须至少填写一个。',
helperText: '例如 https://example.com, https://admin.example.com',
key: 'http.cors.allowedOrigins',
isSensitive: getIsSensitive('http.cors.allowedOrigins'),
component: {
type: 'textarea',
placeholder: 'https://example.com, https://admin.example.com',
minRows: 3,
maxRows: 6,
},
},
],
},
],
},
],
description: '管理 AFilmory 系统的全局行为与服务接入。',
sections: [],
} satisfies SettingUiSchema
function collectKeys(nodes: ReadonlyArray<UiNode<SettingKeyType>>): SettingKeyType[] {

View File

@@ -0,0 +1,82 @@
import { Body, Controller, Delete, Get, Param, Post } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { Roles } from 'core/guards/roles.decorator'
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
import type { GetSettingsBodyDto } from '../setting/setting.dto'
import { DeleteSettingDto, GetSettingDto, SetSettingDto } from '../setting/setting.dto'
import type { SettingEntryInput } from '../setting/setting.service'
import { StorageSettingService } from './storage-setting.service'
const STORAGE_SETTING_KEYS = ['builder.storage.providers', 'builder.storage.activeProvider'] as const
type StorageSettingKey = (typeof STORAGE_SETTING_KEYS)[number]
@Controller('storage/settings')
@Roles('superadmin')
export class StorageSettingController {
constructor(private readonly storageSettingService: StorageSettingService) {}
@Get('/ui-schema')
@BypassResponseTransform()
async getUiSchema() {
return await this.storageSettingService.getUiSchema()
}
@Get('/:key')
@BypassResponseTransform()
async get(@Param() { key }: GetSettingDto) {
this.ensureKeyAllowed(key)
const value = await this.storageSettingService.get(key as StorageSettingKey)
return { key, value }
}
@Get('/')
@BypassResponseTransform()
async getAll() {
const values = await this.storageSettingService.getMany(STORAGE_SETTING_KEYS)
return { values }
}
@Post('/batch')
@BypassResponseTransform()
async getMany(@Body() { keys }: GetSettingsBodyDto) {
if (!keys) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: 'settings keys is required',
})
}
keys.forEach(this.ensureKeyAllowed)
const typedKeys = keys as StorageSettingKey[]
const values = await this.storageSettingService.getMany(typedKeys)
return { values }
}
@Post('/')
async set(@Body() { entries }: SetSettingDto) {
entries.forEach((entry) => this.ensureKeyAllowed(entry.key))
await this.storageSettingService.setMany(entries as readonly SettingEntryInput[])
return { updated: entries }
}
@Delete('/:key')
async delete(@Param() { key }: GetSettingDto) {
this.ensureKeyAllowed(key)
await this.storageSettingService.delete(key as StorageSettingKey)
return { key, deleted: true }
}
@Delete('/')
async deleteMany(@Body() { keys }: DeleteSettingDto) {
keys.forEach(this.ensureKeyAllowed)
const typedKeys = keys as StorageSettingKey[]
await this.storageSettingService.deleteMany(typedKeys)
return { keys, deleted: true }
}
private ensureKeyAllowed(key: string) {
if (!key.startsWith('builder.storage.')) {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, { message: 'Only storage settings are available' })
}
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@afilmory/framework'
import { SettingModule } from '../setting/setting.module'
import { StorageSettingController } from './storage-setting.controller'
import { StorageSettingService } from './storage-setting.service'
@Module({
imports: [SettingModule],
controllers: [StorageSettingController],
providers: [StorageSettingService],
})
export class StorageSettingModule {}

View File

@@ -0,0 +1,42 @@
import { injectable } from 'tsyringe'
import type { SettingEntryInput } from '../setting/setting.service'
import { SettingService } from '../setting/setting.service'
type StorageSettingKey = 'builder.storage.providers' | 'builder.storage.activeProvider'
@injectable()
export class StorageSettingService {
constructor(private readonly settingService: SettingService) {}
async getUiSchema() {
const schema = await this.settingService.getUiSchema()
return {
...schema,
schema: {
...schema.schema,
sections: schema.schema.sections.filter((section) => section.id.startsWith('builder-storage')),
},
}
}
async get(key: StorageSettingKey): Promise<string | null> {
return await this.settingService.get(key, {})
}
async getMany(keys: readonly StorageSettingKey[]): Promise<Record<StorageSettingKey, string | null>> {
return await this.settingService.getMany(keys, {})
}
async setMany(entries: readonly SettingEntryInput[]): Promise<void> {
await this.settingService.setMany(entries)
}
async delete(key: StorageSettingKey): Promise<void> {
await this.settingService.delete(key)
}
async deleteMany(keys: readonly StorageSettingKey[]): Promise<void> {
await this.settingService.deleteMany(keys)
}
}

View File

@@ -1,6 +1,13 @@
import { createZodDto } from '@afilmory/framework'
import { z } from 'zod'
const redirectPathInputSchema = z
.string()
.trim()
.refine((value) => value.length === 0 || value.startsWith('/'), {
message: '路径必须以 / 开头',
})
const updateSuperAdminSettingsSchema = z
.object({
allowRegistration: z.boolean().optional(),
@@ -12,6 +19,12 @@ const updateSuperAdminSettingsSchema = z
.min(1)
.regex(/^[a-z0-9.-]+$/i, { message: '无效的基础域名' })
.optional(),
oauthGoogleClientId: z.string().trim().min(1).nullable().optional(),
oauthGoogleClientSecret: z.string().trim().min(1).nullable().optional(),
oauthGoogleRedirectUri: redirectPathInputSchema.nullable().optional(),
oauthGithubClientId: z.string().trim().min(1).nullable().optional(),
oauthGithubClientSecret: z.string().trim().min(1).nullable().optional(),
oauthGithubRedirectUri: redirectPathInputSchema.nullable().optional(),
})
.refine((value) => Object.values(value).some((entry) => entry !== undefined), {
message: '至少需要更新一项设置',

View File

@@ -1,21 +1,28 @@
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
import { z } from 'zod'
const nonEmptyString = z.string().trim().min(1)
const nullableNonEmptyString = nonEmptyString.nullable()
const nullableUrl = z.string().trim().url({ message: '必须是有效的 URL' }).nullable()
export const SUPER_ADMIN_SETTING_DEFINITIONS = {
allowRegistration: {
key: 'system.registration.allow',
schema: z.boolean(),
defaultValue: true,
isSensitive: false,
},
maxRegistrableUsers: {
key: 'system.registration.maxUsers',
schema: z.number().int().min(0).nullable(),
defaultValue: null as number | null,
isSensitive: false,
},
localProviderEnabled: {
key: 'system.auth.localProvider.enabled',
schema: z.boolean(),
defaultValue: true,
isSensitive: false,
},
baseDomain: {
key: 'system.domain.base',
@@ -27,6 +34,43 @@ export const SUPER_ADMIN_SETTING_DEFINITIONS = {
message: '域名只能包含字母、数字、连字符和点',
}),
defaultValue: DEFAULT_BASE_DOMAIN,
isSensitive: false,
},
oauthGoogleClientId: {
key: 'system.auth.oauth.google.clientId',
schema: nullableNonEmptyString,
defaultValue: null as string | null,
isSensitive: false,
},
oauthGoogleClientSecret: {
key: 'system.auth.oauth.google.clientSecret',
schema: nullableNonEmptyString,
defaultValue: null as string | null,
isSensitive: true,
},
oauthGoogleRedirectUri: {
key: 'system.auth.oauth.google.redirectUri',
schema: nullableUrl,
defaultValue: null as string | null,
isSensitive: false,
},
oauthGithubClientId: {
key: 'system.auth.oauth.github.clientId',
schema: nullableNonEmptyString,
defaultValue: null as string | null,
isSensitive: false,
},
oauthGithubClientSecret: {
key: 'system.auth.oauth.github.clientSecret',
schema: nullableNonEmptyString,
defaultValue: null as string | null,
isSensitive: true,
},
oauthGithubRedirectUri: {
key: 'system.auth.oauth.github.redirectUri',
schema: nullableUrl,
defaultValue: null as string | null,
isSensitive: false,
},
} as const

View File

@@ -5,8 +5,10 @@ import { injectable } from 'tsyringe'
import type { ZodType } from 'zod'
import { DbAccessor } from '../../database/database.provider'
import type { SocialProvidersConfig } from '../auth/auth.config'
import { SUPER_ADMIN_SETTING_DEFINITIONS, SUPER_ADMIN_SETTING_KEYS } from './super-admin-setting.constants'
import type {
SuperAdminSettingField,
SuperAdminSettings,
SuperAdminSettingsOverview,
SuperAdminSettingsStats,
@@ -51,11 +53,53 @@ export class SuperAdminSettingService {
const baseDomain = baseDomainRaw.trim().toLowerCase()
const oauthGoogleClientId = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientId.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientId.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientId.defaultValue,
)
const oauthGoogleClientSecret = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientSecret.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientSecret.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientSecret.defaultValue,
)
const oauthGoogleRedirectUri = this.normalizeRedirectPath(
this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleRedirectUri.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleRedirectUri.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleRedirectUri.defaultValue,
),
)
const oauthGithubClientId = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientId.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientId.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientId.defaultValue,
)
const oauthGithubClientSecret = this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientSecret.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientSecret.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientSecret.defaultValue,
)
const oauthGithubRedirectUri = this.normalizeRedirectPath(
this.parseSetting(
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubRedirectUri.key],
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubRedirectUri.schema,
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubRedirectUri.defaultValue,
),
)
return {
allowRegistration,
maxRegistrableUsers,
localProviderEnabled,
baseDomain,
oauthGoogleClientId,
oauthGoogleClientSecret,
oauthGoogleRedirectUri,
oauthGithubClientId,
oauthGithubClientSecret,
oauthGithubRedirectUri,
}
}
@@ -82,22 +126,19 @@ export class SuperAdminSettingService {
}
const current = await this.getSettings()
const updates: Array<{ key: string; value: SuperAdminSettings[keyof SuperAdminSettings] | null }> = []
const updates: Array<{ field: SuperAdminSettingField; value: SuperAdminSettings[SuperAdminSettingField] }> = []
const enqueueUpdate = <K extends SuperAdminSettingField>(field: K, value: SuperAdminSettings[K]) => {
updates.push({ field, value })
current[field] = value
}
if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) {
updates.push({
key: SUPER_ADMIN_SETTING_DEFINITIONS.allowRegistration.key,
value: patch.allowRegistration,
})
current.allowRegistration = patch.allowRegistration
enqueueUpdate('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
enqueueUpdate('localProviderEnabled', patch.localProviderEnabled)
}
if (patch.maxRegistrableUsers !== undefined) {
@@ -112,28 +153,58 @@ export class SuperAdminSettingService {
}
}
updates.push({
key: SUPER_ADMIN_SETTING_DEFINITIONS.maxRegistrableUsers.key,
value: normalized,
})
current.maxRegistrableUsers = normalized
enqueueUpdate('maxRegistrableUsers', normalized)
}
}
if (patch.baseDomain !== undefined) {
const sanitized = patch.baseDomain === null ? null : String(patch.baseDomain).trim().toLowerCase()
if (!sanitized) {
updates.push({
key: SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.key,
value: SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.defaultValue,
})
current.baseDomain = SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.defaultValue
enqueueUpdate('baseDomain', SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.defaultValue)
} else if (sanitized !== current.baseDomain) {
updates.push({
key: SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.key,
value: sanitized,
})
current.baseDomain = sanitized
enqueueUpdate('baseDomain', sanitized)
}
}
if (patch.oauthGoogleClientId !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientId)
if (sanitized !== current.oauthGoogleClientId) {
enqueueUpdate('oauthGoogleClientId', sanitized)
}
}
if (patch.oauthGoogleClientSecret !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientSecret)
if (sanitized !== current.oauthGoogleClientSecret) {
enqueueUpdate('oauthGoogleClientSecret', sanitized)
}
}
if (patch.oauthGoogleRedirectUri !== undefined) {
const sanitized = this.normalizeRedirectPath(patch.oauthGoogleRedirectUri)
if (sanitized !== current.oauthGoogleRedirectUri) {
enqueueUpdate('oauthGoogleRedirectUri', sanitized)
}
}
if (patch.oauthGithubClientId !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGithubClientId)
if (sanitized !== current.oauthGithubClientId) {
enqueueUpdate('oauthGithubClientId', sanitized)
}
}
if (patch.oauthGithubClientSecret !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGithubClientSecret)
if (sanitized !== current.oauthGithubClientSecret) {
enqueueUpdate('oauthGithubClientSecret', sanitized)
}
}
if (patch.oauthGithubRedirectUri !== undefined) {
const sanitized = this.normalizeRedirectPath(patch.oauthGithubRedirectUri)
if (sanitized !== current.oauthGithubRedirectUri) {
enqueueUpdate('oauthGithubRedirectUri', sanitized)
}
}
@@ -142,10 +213,14 @@ export class SuperAdminSettingService {
}
await this.systemSettingService.setMany(
updates.map((entry) => ({
key: entry.key,
value: entry.value,
})),
updates.map((entry) => {
const definition = SUPER_ADMIN_SETTING_DEFINITIONS[entry.field]
return {
key: definition.key,
value: (entry.value ?? null) as SuperAdminSettings[typeof entry.field] | null,
options: { isSensitive: definition.isSensitive ?? false },
}
}),
)
return current
@@ -172,6 +247,14 @@ export class SuperAdminSettingService {
}
}
async getAuthModuleConfig(): Promise<{ baseDomain: string; socialProviders: SocialProvidersConfig }> {
const settings = await this.getSettings()
return {
baseDomain: settings.baseDomain,
socialProviders: this.buildSocialProviders(settings),
}
}
private parseSetting<T>(raw: unknown, schema: ZodType<T>, defaultValue: T): T {
if (raw === null || raw === undefined) {
return defaultValue
@@ -181,6 +264,66 @@ export class SuperAdminSettingService {
return parsed.success ? parsed.data : defaultValue
}
private buildSocialProviders(settings: SuperAdminSettings): SocialProvidersConfig {
const providers: SocialProvidersConfig = {}
if (settings.oauthGoogleClientId && settings.oauthGoogleClientSecret) {
providers.google = {
clientId: settings.oauthGoogleClientId,
clientSecret: settings.oauthGoogleClientSecret,
redirectPath: settings.oauthGoogleRedirectUri,
}
}
if (settings.oauthGithubClientId && settings.oauthGithubClientSecret) {
providers.github = {
clientId: settings.oauthGithubClientId,
clientSecret: settings.oauthGithubClientSecret,
redirectPath: settings.oauthGithubRedirectUri,
}
}
return providers
}
private normalizeNullableString(value: string | null | undefined): string | null {
if (value === undefined || value === null) {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
private normalizeRedirectPath(value: string | null | undefined): string | null {
if (value === undefined || value === null) {
return null
}
const trimmed = value.trim()
if (trimmed.length === 0) {
return null
}
const ensureLeadingSlash = (input: string): string | null => {
if (!input.startsWith('/')) {
return null
}
return input
}
try {
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
const url = new URL(trimmed)
const pathWithQuery = `${url.pathname}${url.search ?? ''}`
return ensureLeadingSlash(pathWithQuery) ?? null
}
} catch {
// fall through to path handling
}
return ensureLeadingSlash(trimmed)
}
private buildStats(settings: SuperAdminSettings, totalUsers: number): SuperAdminSettingsStats {
const remaining =
settings.maxRegistrableUsers === null ? null : Math.max(settings.maxRegistrableUsers - totalUsers, 0)

View File

@@ -6,6 +6,12 @@ export interface SuperAdminSettings {
maxRegistrableUsers: number | null
localProviderEnabled: boolean
baseDomain: string
oauthGoogleClientId: string | null
oauthGoogleClientSecret: string | null
oauthGoogleRedirectUri: string | null
oauthGithubClientId: string | null
oauthGithubClientSecret: string | null
oauthGithubRedirectUri: string | null
}
export type SuperAdminSettingValueMap = {
@@ -24,3 +30,5 @@ export interface SuperAdminSettingsOverview {
}
export type UpdateSuperAdminSettingsInput = Partial<SuperAdminSettings>
export { type SuperAdminSettingField } from './super-admin-setting.constants'

View File

@@ -1,12 +1,12 @@
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_VERSION = '1.1.0'
export const SUPER_ADMIN_SETTING_UI_SCHEMA: UiSchema<SuperAdminSettingField> = {
version: SUPER_ADMIN_SETTING_UI_SCHEMA_VERSION,
title: '超级管理员设置',
description: '管理整个平台的注册入口和本地登录策略。',
description: '管理整个平台的注册入口登录策略与第三方 OAuth 配置。',
sections: [
{
type: 'section',
@@ -62,6 +62,103 @@ export const SUPER_ADMIN_SETTING_UI_SCHEMA: UiSchema<SuperAdminSettingField> = {
},
],
},
{
type: 'section',
id: 'oauth-providers',
title: 'OAuth 登录渠道',
description: '统一配置所有租户可用的第三方登录渠道。',
icon: 'shield-check',
children: [
{
type: 'group',
id: 'oauth-google',
title: 'Google OAuth',
description: '在 Google Cloud Console 中创建 OAuth 应用后填入以下信息。',
icon: 'badge-check',
children: [
{
type: 'field',
id: 'oauth-google-client-id',
title: 'Client ID',
description: 'Google OAuth 的客户端 ID。',
key: 'oauthGoogleClientId',
component: {
type: 'text',
placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com',
},
},
{
type: 'field',
id: 'oauth-google-client-secret',
title: 'Client Secret',
description: 'Google OAuth 的客户端密钥。',
key: 'oauthGoogleClientSecret',
component: {
type: 'secret',
placeholder: '************',
revealable: true,
autoComplete: 'off',
},
},
{
type: 'field',
id: 'oauth-google-redirect-uri',
title: 'Redirect URI',
description: 'OAuth 回调路径,域名会自动使用当前租户如 slug.主域名。',
key: 'oauthGoogleRedirectUri',
component: {
type: 'text',
placeholder: '/api/auth/callback/google',
},
},
],
},
{
type: 'group',
id: 'oauth-github',
title: 'GitHub OAuth',
description: 'GitHub Developer settings 中创建 OAuth App 后填入以下信息。',
icon: 'github',
children: [
{
type: 'field',
id: 'oauth-github-client-id',
title: 'Client ID',
description: 'GitHub OAuth 的客户端 ID。',
key: 'oauthGithubClientId',
component: {
type: 'text',
placeholder: 'Iv1.xxxxxxxxxxxxxxxx',
},
},
{
type: 'field',
id: 'oauth-github-client-secret',
title: 'Client Secret',
description: 'GitHub OAuth 的客户端密钥。',
key: 'oauthGithubClientSecret',
component: {
type: 'secret',
placeholder: '****************',
revealable: true,
autoComplete: 'off',
},
},
{
type: 'field',
id: 'oauth-github-redirect-uri',
title: 'Redirect URI',
description: 'GitHub 回调路径,域名会自动使用租户的 subdomain。',
key: 'oauthGithubRedirectUri',
component: {
type: 'text',
placeholder: '/api/auth/callback/github',
},
},
],
},
],
},
],
}

View File

@@ -1,76 +0,0 @@
import { injectable } from 'tsyringe'
import { z } from 'zod'
import type { SocialProvidersConfig } from '../auth/auth.config'
import { SettingService } from '../setting/setting.service'
export const TENANT_AUTH_CONFIG_SETTING_KEY = 'auth.tenant.config'
export interface TenantAuthOptions {
localProviderEnabled: boolean
socialProviders: SocialProvidersConfig
}
const providerConfigSchema = z.object({
clientId: z.string().min(1),
clientSecret: z.string().min(1),
redirectUri: z.string().min(1).optional(),
})
const tenantAuthConfigSchema = z
.object({
localProviderEnabled: z.boolean().default(true),
socialProviders: z
.object({
google: providerConfigSchema.optional(),
github: providerConfigSchema.optional(),
zoom: providerConfigSchema.optional(),
})
.partial()
.default({}),
})
.default({ localProviderEnabled: true, socialProviders: {} })
type TenantAuthConfig = z.infer<typeof tenantAuthConfigSchema>
function normalizeConfig(raw: TenantAuthConfig): TenantAuthOptions {
const socialProviders: SocialProvidersConfig = {}
if (raw.socialProviders?.google) {
socialProviders.google = raw.socialProviders.google
}
if (raw.socialProviders?.github) {
socialProviders.github = raw.socialProviders.github
}
if (raw.socialProviders?.zoom) {
socialProviders.zoom = raw.socialProviders.zoom
}
return {
localProviderEnabled: raw.localProviderEnabled ?? true,
socialProviders,
}
}
@injectable()
export class TenantAuthConfigService {
constructor(private readonly settingService: SettingService) {}
async getOptions(tenantId: string): Promise<TenantAuthOptions> {
const rawValue = await this.settingService.get(TENANT_AUTH_CONFIG_SETTING_KEY, { tenantId })
if (!rawValue) {
return normalizeConfig({ localProviderEnabled: true, socialProviders: {} })
}
try {
const parsed = tenantAuthConfigSchema.parse(JSON.parse(rawValue))
return normalizeConfig(parsed)
} catch {
// Fall back to defaults if parsing fails; tenant admins can fix configuration later.
return normalizeConfig({ localProviderEnabled: true, socialProviders: {} })
}
}
}

View File

@@ -1,124 +0,0 @@
import { Body, ContextParam, Controller, Get, Post } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { requireTenantContext } from 'core/modules/tenant/tenant.context'
import type { Context } from 'hono'
import { TenantAuthConfigService } from './tenant-auth.config'
import { TenantAuthProvider } from './tenant-auth.provider'
type TenantAuthEmailPayload = {
email: string
password: string
name?: string
}
@Controller('tenant-auth')
export class TenantAuthController {
constructor(
private readonly tenantAuthProvider: TenantAuthProvider,
private readonly tenantAuthConfig: TenantAuthConfigService,
) {}
@Get('/session')
async getSession(@ContextParam() context: Context) {
const tenant = requireTenantContext()
const auth = await this.tenantAuthProvider.getAuth(tenant.tenant.id)
const session = await auth.api.getSession({ headers: context.req.raw.headers })
if (!session) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
}
context.header('x-tenant-id', tenant.tenant.id)
context.header('x-tenant-slug', tenant.tenant.slug)
return { user: session.user, session: session.session, source: 'tenant' as const }
}
@Post('/sign-in/email')
async signInEmail(@ContextParam() context: Context, @Body() body: TenantAuthEmailPayload) {
const tenant = requireTenantContext()
const email = body?.email?.trim() ?? ''
const password = body?.password ?? ''
if (!email) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '邮箱不能为空' })
}
if (!password) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '密码不能为空' })
}
const config = await this.tenantAuthConfig.getOptions(tenant.tenant.id)
if (!config.localProviderEnabled) {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '当前租户已关闭邮箱密码登录,请联系管理员获取访问权限。',
})
}
const auth = await this.tenantAuthProvider.getAuth(tenant.tenant.id)
const response = await auth.api.signInEmail({
body: { email, password },
headers: context.req.raw.headers,
asResponse: true,
})
context.header('x-tenant-id', tenant.tenant.id)
context.header('x-tenant-slug', tenant.tenant.slug)
return response
}
@Post('/sign-up/email')
async signUpEmail(@ContextParam() context: Context, @Body() body: TenantAuthEmailPayload) {
const tenant = requireTenantContext()
const email = body?.email?.trim() ?? ''
const password = body?.password ?? ''
const name = body?.name?.trim() || email
if (!email) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '邮箱不能为空' })
}
if (!password) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '密码不能为空' })
}
const config = await this.tenantAuthConfig.getOptions(tenant.tenant.id)
if (!config.localProviderEnabled) {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '当前租户已关闭邮箱注册,请联系管理员开启后再试。',
})
}
const auth = await this.tenantAuthProvider.getAuth(tenant.tenant.id)
const response = await auth.api.signUpEmail({
body: {
email,
password,
name,
},
headers: context.req.raw.headers,
asResponse: true,
})
context.header('x-tenant-id', tenant.tenant.id)
context.header('x-tenant-slug', tenant.tenant.slug)
return response
}
@Get('/*')
async passthroughGet(@ContextParam() context: Context) {
const tenant = requireTenantContext()
return await this.tenantAuthProvider.handler(context, tenant.tenant.id)
}
@Post('/*')
async passthroughPost(@ContextParam() context: Context) {
const tenant = requireTenantContext()
return await this.tenantAuthProvider.handler(context, tenant.tenant.id)
}
}

View File

@@ -1,14 +0,0 @@
import { Module } from '@afilmory/framework'
import { DatabaseModule } from '../../database/database.module'
import { SettingModule } from '../setting/setting.module'
import { TenantAuthConfigService } from './tenant-auth.config'
import { TenantAuthController } from './tenant-auth.controller'
import { TenantAuthProvider } from './tenant-auth.provider'
@Module({
imports: [DatabaseModule, SettingModule],
controllers: [TenantAuthController],
providers: [TenantAuthProvider, TenantAuthConfigService],
})
export class TenantAuthModule {}

View File

@@ -1,135 +0,0 @@
import { generateId, tenantAuthAccounts, tenantAuthSessions, tenantAuthUsers } from '@afilmory/db'
import type { OnModuleDestroy, OnModuleInit } from '@afilmory/framework'
import { EventEmitterService } from '@afilmory/framework'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { createAuthMiddleware } from 'better-auth/api'
import type { Context } from 'hono'
import { injectable } from 'tsyringe'
import { DrizzleProvider } from '../../database/database.provider'
import { TENANT_AUTH_CONFIG_SETTING_KEY, TenantAuthConfigService } from './tenant-auth.config'
export type TenantBetterAuthInstance = ReturnType<typeof betterAuth>
export type TenantAuthSession = TenantBetterAuthInstance['$Infer']['Session']
@injectable()
export class TenantAuthProvider implements OnModuleInit, OnModuleDestroy {
private readonly cache = new Map<string, TenantBetterAuthInstance>()
constructor(
private readonly drizzleProvider: DrizzleProvider,
private readonly configService: TenantAuthConfigService,
private readonly eventEmitter: EventEmitterService,
) {}
async onModuleInit(): Promise<void> {
this.eventEmitter.on('setting.updated', this.handleSettingUpdated)
this.eventEmitter.on('setting.deleted', this.handleSettingDeleted)
}
async onModuleDestroy(): Promise<void> {
this.eventEmitter.off('setting.updated', this.handleSettingUpdated)
this.eventEmitter.off('setting.deleted', this.handleSettingDeleted)
this.cache.clear()
}
private readonly handleSettingUpdated = ({ tenantId, key }: { tenantId: string; key: string }) => {
if (key !== TENANT_AUTH_CONFIG_SETTING_KEY) {
return
}
this.cache.delete(tenantId)
}
private readonly handleSettingDeleted = ({ tenantId, key }: { tenantId: string; key: string }) => {
if (key !== TENANT_AUTH_CONFIG_SETTING_KEY) {
return
}
this.cache.delete(tenantId)
}
async getAuth(tenantId: string): Promise<TenantBetterAuthInstance> {
const cached = this.cache.get(tenantId)
if (cached) {
return cached
}
const db = this.drizzleProvider.getDb()
const tenantOptions = await this.configService.getOptions(tenantId)
const instance = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
user: tenantAuthUsers,
session: tenantAuthSessions,
account: tenantAuthAccounts,
},
}),
emailAndPassword: { enabled: tenantOptions.localProviderEnabled },
socialProviders: tenantOptions.socialProviders,
user: {
additionalFields: {
tenantId: { type: 'string', input: false },
role: { type: 'string', input: false },
},
},
databaseHooks: {
user: {
create: {
before: async (user) => ({
data: {
...user,
tenantId,
role: user.role ?? 'guest',
},
}),
},
},
session: {
create: {
before: async (session) => ({
data: {
...session,
tenantId,
},
}),
},
},
account: {
create: {
before: async (account) => ({
data: {
...account,
tenantId,
},
}),
},
},
},
advanced: {
database: {
generateId: () => generateId(),
},
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path === '/sign-up/email' && !tenantOptions.localProviderEnabled) {
throw new Response(JSON.stringify({ message: '当前租户未启用邮件注册,请联系管理员获取访问权限。' }), {
status: 403,
headers: { 'content-type': 'application/json' },
})
}
}),
},
})
this.cache.set(tenantId, instance)
return instance
}
async handler(context: Context, tenantId: string): Promise<Response> {
const auth = await this.getAuth(tenantId)
return auth.handler(context.req.raw)
}
}

View File

@@ -87,7 +87,7 @@ export function usePageRedirect() {
const logout = useCallback(async () => {
try {
await signOutBySource(sessionQuery.data?.source)
await signOutBySource()
} catch (error) {
console.error('Logout error:', error)
} finally {
@@ -96,7 +96,7 @@ export function usePageRedirect() {
setAuthUser(null)
navigate(DEFAULT_LOGIN_PATH, { replace: true })
}
}, [navigate, queryClient, sessionQuery.data?.source, setAuthUser])
}, [navigate, queryClient, setAuthUser])
// Sync auth user to atom
useEffect(() => {

View File

@@ -19,7 +19,7 @@ export interface RegisterTenantPayload {
export type RegisterTenantResult = FetchResponse<unknown>
export async function registerTenant(payload: RegisterTenantPayload): Promise<RegisterTenantResult> {
return await coreApi.raw('/auth/tenants/sign-up', {
return await coreApi.raw('/auth/sign-up/email', {
method: 'POST',
body: payload,
})

View File

@@ -1,5 +1,3 @@
import { FetchError } from 'ofetch'
import { coreApi } from '~/lib/api-client'
import type { BetterAuthSession, BetterAuthUser } from '../types'
@@ -7,28 +5,11 @@ import type { BetterAuthSession, BetterAuthUser } from '../types'
export type SessionResponse = {
user: BetterAuthUser
session: BetterAuthSession
source?: 'global' | 'tenant'
}
export const AUTH_SESSION_QUERY_KEY = ['auth', 'session'] as const
export async function fetchSession() {
const fallbackStatus = new Set([401, 403, 404])
try {
const tenantSession = await coreApi<SessionResponse>('/tenant-auth/session', { method: 'GET' })
return { ...tenantSession, source: tenantSession.source ?? 'tenant' }
} catch (error) {
if (!(error instanceof FetchError)) {
throw error
}
const status = error.statusCode ?? error.response?.status ?? null
if (!status || !fallbackStatus.has(status)) {
throw error
}
}
const globalSession = await coreApi<SessionResponse>('/auth/session', { method: 'GET' })
return { ...globalSession, source: globalSession.source ?? 'global' }
const session = await coreApi<SessionResponse>('/auth/session', { method: 'GET' })
return session
}

View File

@@ -0,0 +1,16 @@
import { coreApi } from '~/lib/api-client'
export interface SocialProviderDefinition {
id: string
name: string
icon: string
callbackPath: string | null
}
export interface SocialProvidersResponse {
providers: SocialProviderDefinition[]
}
export async function fetchSocialProviders(): Promise<SocialProvidersResponse> {
return await coreApi<SocialProvidersResponse>('/auth/social/providers', { method: 'GET' })
}

View File

@@ -3,8 +3,7 @@ import { FetchError } from 'ofetch'
const apiBase = import.meta.env.VITE_APP_API_BASE?.replace(/\/$/, '') || '/api'
const globalAuthBase = resolveUrl(`${apiBase}/auth`)
const tenantAuthBase = resolveUrl(`${apiBase}/tenant-auth`)
const authBase = resolveUrl(`${apiBase}/auth`)
const commonOptions = {
fetchOptions: {
@@ -12,53 +11,48 @@ const commonOptions = {
},
}
export const globalAuthClient = createAuthClient({
baseURL: globalAuthBase,
export const authClient = createAuthClient({
baseURL: authBase,
...commonOptions,
})
export const tenantAuthClient = createAuthClient({
baseURL: tenantAuthBase,
...commonOptions,
})
const { useSession } = globalAuthClient
const { signIn: globalRawSignIn, signOut: globalRawSignOut } = globalAuthClient
const { signIn: tenantRawSignIn, signOut: tenantRawSignOut } = tenantAuthClient
const { useSession } = authClient
const { signIn, signOut } = authClient
export { useSession }
export const signInGlobal = globalRawSignIn
export const signInTenant = tenantRawSignIn
export const signInAuth = signIn
export const signOutAuth = signOut
export const signOutGlobal = globalRawSignOut
export const signOutTenant = tenantRawSignOut
export interface SocialSignInOptions {
provider: string
requestSignUp?: boolean
callbackURL?: string
errorCallbackURL?: string
newUserCallbackURL?: string
disableRedirect?: boolean
}
export async function signOutBySource(source?: 'global' | 'tenant') {
const targets: Array<'global' | 'tenant'> = source ? [source] : ['tenant', 'global']
let lastError: unknown = null
const recoverableStatuses = new Set([401, 403, 404])
for (const target of targets) {
try {
if (target === 'tenant') {
await tenantAuthClient.signOut()
} else {
await globalAuthClient.signOut()
}
} catch (error) {
if (error instanceof FetchError) {
const status = error.statusCode ?? error.response?.status ?? null
if (status && recoverableStatuses.has(status)) {
continue
}
}
lastError = error
}
export async function signInSocial(options: SocialSignInOptions): Promise<unknown> {
const socialSignIn = (signIn as unknown as { social?: (opts: SocialSignInOptions) => Promise<unknown> }).social
if (!socialSignIn) {
throw new Error('Social sign-in is not available in this build.')
}
return await socialSignIn(options)
}
if (lastError) {
throw lastError
export async function signOutBySource() {
try {
await signOutAuth()
} catch (error) {
if (error instanceof FetchError) {
const status = error.statusCode ?? error.response?.status ?? null
const recoverableStatuses = new Set([401, 403, 404])
if (status && recoverableStatuses.has(status)) {
return
}
}
throw error
}
}

View File

@@ -5,6 +5,7 @@ import type { FC, KeyboardEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router'
import { SocialAuthButtons } from '~/modules/auth/components/SocialAuthButtons'
import { useRegisterTenant } from '~/modules/auth/hooks/useRegisterTenant'
import type { TenantRegistrationFormState } from '~/modules/auth/hooks/useRegistrationForm'
import { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
@@ -322,6 +323,11 @@ const AdminStep: FC<StepCommonProps> = ({ values, errors, onFieldChange, isLoadi
<p className="text-text-tertiary text-xs">
We recommend using a secure password manager to store credentials for critical roles like the administrator.
</p>
<SocialAuthButtons
className="border border-white/5 bg-fill/40 rounded-2xl p-4"
title="Or use single sign-on"
requestSignUp
/>
</div>
)

View File

@@ -0,0 +1,93 @@
import { Button } from '@afilmory/ui'
import { cx } from '@afilmory/utils'
import { memo, useCallback, useMemo } from 'react'
import { signInSocial } from '../auth-client'
import { useSocialProviders } from '../hooks/useSocialProviders'
export interface SocialAuthButtonsProps {
className?: string
title?: string
requestSignUp?: boolean
callbackURL?: string
errorCallbackURL?: string
newUserCallbackURL?: string
disableRedirect?: boolean
layout?: 'grid' | 'row'
}
export const SocialAuthButtons = memo(function SocialAuthButtons({
className,
title = 'Or continue with',
requestSignUp = false,
callbackURL,
errorCallbackURL,
newUserCallbackURL,
disableRedirect,
layout = 'grid',
}: SocialAuthButtonsProps) {
const { data, isLoading } = useSocialProviders()
const providers = data?.providers ?? []
const resolvedCallbackURL = useMemo(() => {
if (callbackURL) {
return callbackURL
}
if (typeof window !== 'undefined') {
return window.location.href
}
return
}, [callbackURL])
const handleSocialClick = useCallback(
async (providerId: string) => {
try {
await signInSocial({
provider: providerId,
requestSignUp,
callbackURL: resolvedCallbackURL,
errorCallbackURL,
newUserCallbackURL,
disableRedirect,
})
} catch (error) {
console.error('Failed to initiate social sign-in', error)
}
},
[disableRedirect, errorCallbackURL, newUserCallbackURL, requestSignUp, resolvedCallbackURL],
)
if (isLoading) {
return <div className={cx('text-text-tertiary text-xs italic', className)}>Loading available providers...</div>
}
if (providers.length === 0) {
return null
}
const containerClass = layout === 'row' ? 'flex flex-wrap gap-2' : 'grid gap-2 sm:grid-cols-2'
return (
<div className={cx('space-y-3', className)}>
{title ? <p className="text-text-tertiary text-xs uppercase tracking-wide">{title}</p> : null}
<div className={containerClass}>
{providers.map((provider) => (
<Button
key={provider.id}
type="button"
variant="ghost"
size="md"
className="justify-start gap-3"
onClick={() => handleSocialClick(provider.id)}
>
<span className="flex items-center gap-3">
<i className={cx('text-lg', provider.icon)} aria-hidden />
<span>{provider.name}</span>
</span>
</Button>
))}
</div>
</div>
)
})

View File

@@ -6,7 +6,7 @@ import { useNavigate } from 'react-router'
import { useSetAuthUser } from '~/atoms/auth'
import { AUTH_SESSION_QUERY_KEY, fetchSession } from '~/modules/auth/api/session'
import { signInGlobal, signInTenant } from '../auth-client'
import { signInAuth } from '../auth-client'
export interface LoginRequest {
email: string
@@ -23,36 +23,8 @@ export function useLogin() {
const loginMutation = useMutation({
mutationFn: async (data: LoginRequest) => {
const rememberMe = data.rememberMe ?? true
const fallbackStatuses = new Set([400, 401, 403, 404])
const attemptTenant = async () => {
try {
await signInTenant.email({
email: data.email,
password: data.password,
rememberMe,
})
return await queryClient.fetchQuery({
queryKey: AUTH_SESSION_QUERY_KEY,
queryFn: fetchSession,
})
} catch (error) {
if (error instanceof FetchError) {
const status = error.statusCode ?? error.response?.status ?? null
if (status && fallbackStatuses.has(status)) {
return null
}
}
throw error
}
}
const tenantSession = await attemptTenant()
if (tenantSession) {
return tenantSession
}
await signInGlobal.email({
await signInAuth.email({
email: data.email,
password: data.password,
rememberMe,

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import { fetchSocialProviders } from '../api/socialProviders'
export const AUTH_SOCIAL_PROVIDERS_QUERY_KEY = ['auth', 'social-providers'] as const
export function useSocialProviders() {
return useQuery({
queryKey: AUTH_SOCIAL_PROVIDERS_QUERY_KEY,
queryFn: fetchSocialProviders,
staleTime: 5 * 60 * 1000,
})
}

View File

@@ -22,5 +22,4 @@ export interface BetterAuthSession {
export interface AuthState {
user: BetterAuthUser
session: BetterAuthSession
source?: 'global' | 'tenant'
}

View File

@@ -2,10 +2,6 @@ export type OnboardingSettingKey =
| 'ai.openai.apiKey'
| 'ai.openai.baseUrl'
| 'ai.embedding.model'
| 'auth.google.clientId'
| 'auth.google.clientSecret'
| 'auth.github.clientId'
| 'auth.github.clientSecret'
| 'http.cors.allowedOrigins'
| 'services.amap.apiKey'
@@ -27,40 +23,6 @@ export type SettingSectionDefinition = {
}
export const ONBOARDING_SETTING_SECTIONS: SettingSectionDefinition[] = [
{
id: 'auth',
title: 'Authentication Providers',
description:
'Configure OAuth providers that will be available to your team. You can add them later from the settings panel as well.',
fields: [
{
key: 'auth.google.clientId',
label: 'Google Client ID',
description: 'Public identifier issued by Google OAuth.',
placeholder: '1234567890-abc.apps.googleusercontent.com',
},
{
key: 'auth.google.clientSecret',
label: 'Google Client Secret',
description: 'Keep this secret safe. Required together with the client ID to enable Google sign-in.',
placeholder: 'GOCSPX-xxxxxxxxxxxxxxxxxx',
sensitive: true,
},
{
key: 'auth.github.clientId',
label: 'GitHub Client ID',
description: 'Public identifier for your GitHub OAuth App.',
placeholder: 'Iv1.0123456789abcdef',
},
{
key: 'auth.github.clientSecret',
label: 'GitHub Client Secret',
description: 'Used to authorize GitHub OAuth callbacks.',
placeholder: 'e3a2f9c0f2bdc...',
sensitive: true,
},
],
},
{
id: 'ai',
title: 'AI & Embeddings',

View File

@@ -1,8 +1,10 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router'
import { toast } from 'sonner'
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { PageTabs } from '~/components/navigation/PageTabs'
import { StorageProvidersManager } from '~/modules/storage-providers'
import { getPhotoStorageUrl } from '../api'
import {
@@ -29,7 +31,7 @@ import { PhotoSyncConflictsPanel } from './sync/PhotoSyncConflictsPanel'
import { PhotoSyncProgressPanel } from './sync/PhotoSyncProgressPanel'
import { PhotoSyncResultPanel } from './sync/PhotoSyncResultPanel'
type PhotoPageTab = 'sync' | 'library'
type PhotoPageTab = 'sync' | 'library' | 'storage'
const BATCH_RESOLVING_ID = '__batch__'
@@ -56,13 +58,21 @@ function createInitialStages(totals: PhotoSyncProgressState['totals']): PhotoSyn
}
export function PhotoPage() {
const [activeTab, setActiveTab] = useState<PhotoPageTab>('sync')
const [searchParams, setSearchParams] = useSearchParams()
const initialTabParam = searchParams.get('tab')
const normalizedInitialTab: PhotoPageTab =
initialTabParam === 'library' || initialTabParam === 'storage' ? (initialTabParam as PhotoPageTab) : 'sync'
const [activeTab, setActiveTab] = useState<PhotoPageTab>(normalizedInitialTab)
const [result, setResult] = useState<PhotoSyncResult | null>(null)
const [lastWasDryRun, setLastWasDryRun] = useState<boolean | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [resolvingConflictId, setResolvingConflictId] = useState<string | null>(null)
const [syncProgress, setSyncProgress] = useState<PhotoSyncProgressState | null>(null)
useEffect(() => {
setActiveTab(normalizedInitialTab)
}, [normalizedInitialTab])
const summaryQuery = usePhotoAssetSummaryQuery()
const listQuery = usePhotoAssetListQuery({ enabled: activeTab === 'library' })
const deleteMutation = useDeletePhotoAssetsMutation()
@@ -321,7 +331,15 @@ export function PhotoPage() {
const handleTabChange = (tab: PhotoPageTab) => {
setActiveTab(tab)
const next = new URLSearchParams(searchParams.toString())
if (tab === 'sync') {
next.delete('tab')
} else {
next.set('tab', tab)
}
setSearchParams(next, { replace: true })
if (tab !== 'library') {
setSelectedIds([])
}
}
@@ -331,32 +349,34 @@ export function PhotoPage() {
return (
<MainPageLayout title="照片库" description="在此同步和管理服务器中的照片资产。">
<MainPageLayout.Actions>
{activeTab === 'sync' ? (
<PhotoSyncActions
onCompleted={(data, context) => {
setResult(data)
setLastWasDryRun(context.dryRun)
setSyncProgress(null)
void summaryQuery.refetch()
void listQuery.refetch()
}}
onProgress={handleProgressEvent}
onError={handleSyncError}
/>
) : (
<PhotoLibraryActionBar
selectionCount={selectedIds.length}
isUploading={uploadMutation.isPending}
isDeleting={deleteMutation.isPending}
onUpload={handleUploadAssets}
onDeleteSelected={() => {
void handleDeleteAssets(selectedIds)
}}
onClearSelection={handleClearSelection}
/>
)}
</MainPageLayout.Actions>
{activeTab !== 'storage' ? (
<MainPageLayout.Actions>
{activeTab === 'sync' ? (
<PhotoSyncActions
onCompleted={(data, context) => {
setResult(data)
setLastWasDryRun(context.dryRun)
setSyncProgress(null)
void summaryQuery.refetch()
void listQuery.refetch()
}}
onProgress={handleProgressEvent}
onError={handleSyncError}
/>
) : (
<PhotoLibraryActionBar
selectionCount={selectedIds.length}
isUploading={uploadMutation.isPending}
isDeleting={deleteMutation.isPending}
onUpload={handleUploadAssets}
onDeleteSelected={() => {
void handleDeleteAssets(selectedIds)
}}
onClearSelection={handleClearSelection}
/>
)}
</MainPageLayout.Actions>
) : null}
<div className="space-y-6">
<PageTabs
@@ -365,42 +385,49 @@ export function PhotoPage() {
items={[
{ id: 'sync', label: '同步结果' },
{ id: 'library', label: '图库管理' },
{ id: 'storage', label: '素材存储' },
]}
/>
{activeTab === 'sync' && syncProgress ? <PhotoSyncProgressPanel progress={syncProgress} /> : null}
{activeTab === 'sync' ? (
<div className="space-y-6">
{showConflictsPanel ? (
<PhotoSyncConflictsPanel
conflicts={conflictsQuery.data}
isLoading={conflictsQuery.isLoading || conflictsQuery.isFetching}
resolvingId={resolvingConflictId}
isBatchResolving={resolvingConflictId === BATCH_RESOLVING_ID}
onResolve={handleResolveConflict}
onResolveBatch={handleResolveConflictsBatch}
onRequestStorageUrl={getPhotoStorageUrl}
/>
) : null}
<PhotoSyncResultPanel
result={result}
lastWasDryRun={lastWasDryRun}
baselineSummary={summaryQuery.data}
isSummaryLoading={summaryQuery.isLoading}
onRequestStorageUrl={getPhotoStorageUrl}
/>
</div>
{activeTab === 'storage' ? (
<StorageProvidersManager />
) : (
<PhotoLibraryGrid
assets={listQuery.data}
isLoading={isListLoading}
selectedIds={selectedSet}
onToggleSelect={handleToggleSelect}
onOpenAsset={handleOpenAsset}
onDeleteAsset={handleDeleteSingle}
isDeleting={deleteMutation.isPending}
/>
<>
{activeTab === 'sync' && syncProgress ? <PhotoSyncProgressPanel progress={syncProgress} /> : null}
{activeTab === 'sync' ? (
<div className="space-y-6">
{showConflictsPanel ? (
<PhotoSyncConflictsPanel
conflicts={conflictsQuery.data}
isLoading={conflictsQuery.isLoading || conflictsQuery.isFetching}
resolvingId={resolvingConflictId}
isBatchResolving={resolvingConflictId === BATCH_RESOLVING_ID}
onResolve={handleResolveConflict}
onResolveBatch={handleResolveConflictsBatch}
onRequestStorageUrl={getPhotoStorageUrl}
/>
) : null}
<PhotoSyncResultPanel
result={result}
lastWasDryRun={lastWasDryRun}
baselineSummary={summaryQuery.data}
isSummaryLoading={summaryQuery.isLoading}
onRequestStorageUrl={getPhotoStorageUrl}
/>
</div>
) : (
<PhotoLibraryGrid
assets={listQuery.data}
isLoading={isListLoading}
selectedIds={selectedSet}
onToggleSelect={handleToggleSelect}
onOpenAsset={handleOpenAsset}
onDeleteAsset={handleDeleteSingle}
isDeleting={deleteMutation.isPending}
/>
)}
</>
)}
</div>
</MainPageLayout>

View File

@@ -2,22 +2,24 @@ import { coreApi } from '~/lib/api-client'
import type { SettingEntryInput, SettingUiSchemaResponse } from './types'
const STORAGE_SETTINGS_ENDPOINT = '/storage/settings'
export async function getSettingUiSchema() {
return await coreApi<SettingUiSchemaResponse>('/settings/ui-schema')
return await coreApi<SettingUiSchemaResponse>(`${STORAGE_SETTINGS_ENDPOINT}/ui-schema`)
}
export async function getSettings(keys: readonly string[]) {
return await coreApi<{
keys: string[]
values: Record<string, string | null>
}>('/settings/batch', {
}>(`${STORAGE_SETTINGS_ENDPOINT}/batch`, {
body: { keys },
method: 'POST',
})
}
export async function updateSettings(entries: readonly SettingEntryInput[]) {
return await coreApi<{ updated: readonly SettingEntryInput[] }>('/settings', {
return await coreApi<{ updated: readonly SettingEntryInput[] }>(`${STORAGE_SETTINGS_ENDPOINT}`, {
method: 'POST',
body: { entries },
})

View File

@@ -7,12 +7,6 @@ const SETTINGS_TABS = [
path: '/settings',
end: true,
},
{
id: 'storage',
label: '素材存储',
path: '/settings/storage',
end: false,
},
] as const
type SettingsNavigationProps = {

View File

@@ -1,17 +0,0 @@
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { SettingsNavigation } from '~/modules/settings'
import { StorageProvidersManager } from '~/modules/storage-providers'
export function Component() {
return (
<MainPageLayout
title="素材存储与 Builder"
description="在此配置多个素材存储提供商,并选择一个作为 Builder 的活跃源。保存后请重新运行 Builder 以加载最新配置。"
>
<div className="space-y-6">
<SettingsNavigation active="storage" />
<StorageProvidersManager />
</div>
</MainPageLayout>
)
}

View File

@@ -3,6 +3,7 @@ import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useState } from 'react'
import { SocialAuthButtons } from '~/modules/auth/components/SocialAuthButtons'
import { useLogin } from '~/modules/auth/hooks/useLogin'
import { LinearBorderContainer } from '~/modules/onboarding/components/LinearBorderContainer'
@@ -39,6 +40,8 @@ export function Component() {
<div className="p-12">
<h1 className="text-text mb-10 text-3xl font-bold">Login</h1>
<SocialAuthButtons className="mb-8" />
{/* Error Message */}
{error && (
<m.div

View File

@@ -12,6 +12,7 @@ export default defineConfig(
},
},
rules: {
'unicorn/no-array-callback-reference': 'off',
'unicorn/no-abusive-eslint-disable': 0,
'unicorn/no-useless-undefined': 0,
'@typescript-eslint/no-unsafe-function-type': 0,