mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '无法生成唯一的租户标识,请尝试使用不同的名称',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ import { SettingService } from './setting.service'
|
||||
imports: [DatabaseModule],
|
||||
providers: [SettingService],
|
||||
controllers: [SettingController],
|
||||
exports: [SettingService],
|
||||
})
|
||||
export class SettingModule {}
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: '至少需要更新一项设置',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {} })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
16
be/apps/dashboard/src/modules/auth/api/socialProviders.ts
Normal file
16
be/apps/dashboard/src/modules/auth/api/socialProviders.ts
Normal 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' })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -22,5 +22,4 @@ export interface BetterAuthSession {
|
||||
export interface AuthState {
|
||||
user: BetterAuthUser
|
||||
session: BetterAuthSession
|
||||
source?: 'global' | 'tenant'
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -7,12 +7,6 @@ const SETTINGS_TABS = [
|
||||
path: '/settings',
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
label: '素材存储',
|
||||
path: '/settings/storage',
|
||||
end: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
type SettingsNavigationProps = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user