diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7e9dd7de..dc619a20 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -11,8 +11,28 @@
},
"tailwindCSS.experimental.classRegex": [
[
- "([\"'`][^\"'`]*.*?[\"'`])",
+ "cn\\(([^)]*)\\)",
"[\"'`]([^\"'`]*).*?[\"'`]"
+ ],
+ [
+ "clsxm\\(([^)]*)\\)",
+ "[\"'`]([^\"'`]*).*?[\"'`]"
+ ],
+ [
+ "tv\\(([^)]*)\\)",
+ "(?:'|\"|`)([^']*)(?:'|\"|`)"
+ ],
+ [
+ "tw`([^`]*)`",
+ "([^`]*)"
+ ],
+ [
+ "[a-zA-Z]+[cC]lass[nN]ame[\"'`]?:\\s*[\"'`]([^\"'`]*)[\"'`]",
+ "([^\"'`]*)"
+ ],
+ [
+ "[a-zA-Z]+[cC]lass[nN]ame\\s*=\\s*[\"'`]([^\"'`]*)[\"'`]",
+ "([^\"'`]*)"
]
],
// If you do not want to autofix some rules on save
@@ -52,4 +72,4 @@
"exportall.config.folderListener": [
"/apps/ssr/src/schemas"
]
-}
+}
\ No newline at end of file
diff --git a/be/apps/core/package.json b/be/apps/core/package.json
index a3eee8f1..f456b54e 100644
--- a/be/apps/core/package.json
+++ b/be/apps/core/package.json
@@ -22,6 +22,7 @@
"@afilmory/framework": "workspace:*",
"@afilmory/redis": "workspace:*",
"@afilmory/task-queue": "workspace:*",
+ "@afilmory/utils": "workspace:*",
"@aws-sdk/client-s3": "3.916.0",
"@hono/node-server": "^1.19.5",
"better-auth": "1.3.29",
diff --git a/be/apps/core/src/modules/setting/setting.controller.ts b/be/apps/core/src/modules/setting/setting.controller.ts
index a39a68cb..7196c00d 100644
--- a/be/apps/core/src/modules/setting/setting.controller.ts
+++ b/be/apps/core/src/modules/setting/setting.controller.ts
@@ -18,12 +18,14 @@ export class SettingController {
}
@Get('/:key')
+ @BypassResponseTransform()
async get(@Param() { key }: GetSettingDto) {
const value = await this.settingService.get(key, {})
return { key, value }
}
@Get('/')
+ @BypassResponseTransform()
async getMany(@Query() query: GetSettingsQueryDto) {
const keys = query?.keys ?? []
const targetKeys = keys.length > 0 ? keys : Array.from(SettingKeys)
diff --git a/be/apps/core/src/modules/setting/setting.service.ts b/be/apps/core/src/modules/setting/setting.service.ts
index 588ddac5..5eeb2185 100644
--- a/be/apps/core/src/modules/setting/setting.service.ts
+++ b/be/apps/core/src/modules/setting/setting.service.ts
@@ -12,6 +12,8 @@ import { TenantService } from '../tenant/tenant.service'
import { AES_ALGORITHM, AUTH_TAG_LENGTH, DEFAULT_SETTING_METADATA, IV_LENGTH } from './setting.constant'
import type { SettingKeyType, SettingRecord, SettingUiSchemaResponse, SettingValueMap } from './setting.type'
import { SETTING_UI_SCHEMA, SETTING_UI_SCHEMA_KEYS } from './setting.ui-schema'
+import { STORAGE_PROVIDERS_SETTING_KEY } from './storage-provider.constants'
+import { prepareStorageProvidersForPersist, sanitizeStorageProviders } from './storage-provider.utils'
export type SettingOption = {
tenantId?: string
@@ -55,8 +57,13 @@ export class SettingService {
if (!record) {
return null
}
- const value = record.isSensitive ? this.decrypt(record.value) : record.value
- return value
+ const rawValue = record.isSensitive ? this.decrypt(record.value) : record.value
+
+ if (key === STORAGE_PROVIDERS_SETTING_KEY) {
+ return sanitizeStorageProviders(rawValue)
+ }
+
+ return rawValue
}
async getMany(
@@ -85,7 +92,14 @@ export class SettingService {
acc[key] = null
return acc
}
- acc[key] = record.isSensitive ? this.decrypt(record.value) : record.value
+ const rawValue = record.isSensitive ? this.decrypt(record.value) : record.value
+
+ if (key === STORAGE_PROVIDERS_SETTING_KEY) {
+ acc[key] = sanitizeStorageProviders(rawValue)
+ return acc
+ }
+
+ acc[key] = rawValue
return acc
}, {})
}
@@ -96,8 +110,19 @@ export class SettingService {
const tenantId = await this.resolveTenantId(options)
const existing = await this.findSettingRecord(key, tenantId)
const defaultMetadata = isSettingKey(key) ? DEFAULT_SETTING_METADATA[key] : undefined
- const isSensitive = options.isSensitive ?? defaultMetadata?.isSensitive ?? existing?.isSensitive ?? false
- const payload = isSensitive ? this.encrypt(value) : value
+ let isSensitive = options.isSensitive ?? defaultMetadata?.isSensitive ?? existing?.isSensitive ?? false
+ let persistedValue = value
+ let maskedValue = value
+
+ if (key === STORAGE_PROVIDERS_SETTING_KEY) {
+ const existingRaw = existing ? (existing.isSensitive ? this.decrypt(existing.value) : existing.value) : null
+ const result = prepareStorageProvidersForPersist(value, existingRaw)
+ persistedValue = result.stored
+ maskedValue = result.masked
+ isSensitive = true
+ }
+
+ const payload = isSensitive ? this.encrypt(persistedValue) : persistedValue
const db = this.dbAccessor.get()
const insertPayload: typeof settings.$inferInsert = {
@@ -121,7 +146,7 @@ export class SettingService {
set: updatePayload,
})
- await this.eventEmitter.emit('setting.updated', { tenantId, key, value })
+ await this.eventEmitter.emit('setting.updated', { tenantId, key, value: maskedValue })
}
async setMany(entries: readonly SettingEntryInput[]): Promise {
diff --git a/be/apps/core/src/modules/setting/storage-provider.constants.ts b/be/apps/core/src/modules/setting/storage-provider.constants.ts
new file mode 100644
index 00000000..535a1262
--- /dev/null
+++ b/be/apps/core/src/modules/setting/storage-provider.constants.ts
@@ -0,0 +1,6 @@
+export const STORAGE_PROVIDERS_SETTING_KEY = 'builder.storage.providers'
+
+export const STORAGE_PROVIDER_SENSITIVE_FIELDS: Record = {
+ s3: ['secretAccessKey'],
+ github: ['token'],
+}
diff --git a/be/apps/core/src/modules/setting/storage-provider.utils.ts b/be/apps/core/src/modules/setting/storage-provider.utils.ts
new file mode 100644
index 00000000..685a7d4f
--- /dev/null
+++ b/be/apps/core/src/modules/setting/storage-provider.utils.ts
@@ -0,0 +1,134 @@
+import { randomUUID } from 'node:crypto'
+
+import { STORAGE_PROVIDER_SECRET_PLACEHOLDER } from '@afilmory/utils'
+
+import { STORAGE_PROVIDER_SENSITIVE_FIELDS } from './storage-provider.constants'
+
+export interface BuilderStorageProvider {
+ id: string
+ name: string
+ type: string
+ config: Record
+ createdAt?: string
+ updatedAt?: string
+}
+
+function normalizeConfig (config: unknown): Record {
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
+ return {}
+ }
+
+ const result: Record = {}
+
+ for (const [key, value] of Object.entries(config)) {
+ result[key] = typeof value === 'string' ? value : value == null ? '' : String(value)
+ }
+
+ return result
+}
+
+function normalizeProvider (input: unknown): BuilderStorageProvider | null {
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
+ return null
+ }
+
+ const record = input as Record
+ const now = new Date().toISOString()
+
+ return {
+ id: typeof record.id === 'string' && record.id.trim().length > 0 ? record.id.trim() : randomUUID(),
+ name: typeof record.name === 'string' && record.name.trim().length > 0 ? record.name.trim() : '未命名存储',
+ type: typeof record.type === 'string' ? record.type : 'local',
+ config: normalizeConfig(record.config),
+ createdAt: typeof record.createdAt === 'string' ? record.createdAt : now,
+ updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now,
+ }
+}
+
+export function parseStorageProviders (raw: string | null): BuilderStorageProvider[] {
+ if (!raw) {
+ return []
+ }
+
+ try {
+ const parsed = JSON.parse(raw)
+ if (!Array.isArray(parsed)) {
+ return []
+ }
+
+ return parsed.map((item) => normalizeProvider(item)).filter((item): item is BuilderStorageProvider => item !== null)
+ } catch {
+ return []
+ }
+}
+
+export function serializeStorageProviders (providers: BuilderStorageProvider[]): string {
+ return JSON.stringify(providers)
+}
+
+export function maskStorageProviderSecrets (providers: BuilderStorageProvider[]): BuilderStorageProvider[] {
+ return providers.map((provider) => {
+ const sensitiveKeys = STORAGE_PROVIDER_SENSITIVE_FIELDS[provider.type] ?? []
+ const maskedConfig: Record = { ...provider.config }
+
+ for (const key of sensitiveKeys) {
+ if (maskedConfig[key] && maskedConfig[key].length > 0) {
+ maskedConfig[key] = STORAGE_PROVIDER_SECRET_PLACEHOLDER
+ }
+ }
+
+ return {
+ ...provider,
+ config: maskedConfig,
+ }
+ })
+}
+
+export function mergeStorageProviderSecrets (incoming: BuilderStorageProvider[],
+ existing: BuilderStorageProvider[]): BuilderStorageProvider[] {
+ const existingMap = new Map(existing.map((provider) => [provider.id, provider]))
+ const now = new Date().toISOString()
+
+ return incoming.map((provider) => {
+ const previous = existingMap.get(provider.id)
+ const sensitiveKeys = STORAGE_PROVIDER_SENSITIVE_FIELDS[provider.type] ?? []
+ const mergedConfig: Record = { ...provider.config }
+
+ for (const key of sensitiveKeys) {
+ const value = mergedConfig[key] ?? ''
+ if (value === STORAGE_PROVIDER_SECRET_PLACEHOLDER) {
+ mergedConfig[key] = previous?.config[key] ?? ''
+ continue
+ }
+ }
+
+ return {
+ ...provider,
+ config: mergedConfig,
+ createdAt: previous?.createdAt ?? provider.createdAt ?? now,
+ updatedAt: now,
+ }
+ })
+}
+
+export function sanitizeStorageProviders (raw: string | null): string {
+ const normalized = typeof raw === 'string' ? raw.trim() : ''
+ if (!normalized) {
+ return '[]'
+ }
+
+ const providers = parseStorageProviders(normalized)
+ const masked = maskStorageProviderSecrets(providers)
+ return serializeStorageProviders(masked)
+}
+
+export function prepareStorageProvidersForPersist (incomingRaw: string,
+ existingRaw: string | null): { stored: string; masked: string } {
+ const incomingProviders = parseStorageProviders(incomingRaw)
+ const existingProviders = parseStorageProviders(existingRaw)
+ const merged = mergeStorageProviderSecrets(incomingProviders, existingProviders)
+ return {
+ stored: serializeStorageProviders(merged),
+ masked: serializeStorageProviders(maskStorageProviderSecrets(merged)),
+ }
+}
diff --git a/be/apps/core/src/modules/super-admin/super-admin.controller.ts b/be/apps/core/src/modules/super-admin/super-admin.controller.ts
index 546dc3b3..bd050884 100644
--- a/be/apps/core/src/modules/super-admin/super-admin.controller.ts
+++ b/be/apps/core/src/modules/super-admin/super-admin.controller.ts
@@ -1,5 +1,6 @@
import { Body, Controller, Get, Patch } from '@afilmory/framework'
import { Roles } from 'core/guards/roles.decorator'
+import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { UpdateSuperAdminSettingsDto } from './super-admin.dto'
@@ -10,11 +11,13 @@ export class SuperAdminSettingController {
constructor(private readonly superAdminSettings: SuperAdminSettingService) {}
@Get('/')
+ @BypassResponseTransform()
async getOverview() {
return await this.superAdminSettings.getOverview()
}
@Patch('/')
+ @BypassResponseTransform()
async update(@Body() dto: UpdateSuperAdminSettingsDto) {
await this.superAdminSettings.updateSettings(dto)
return await this.superAdminSettings.getOverview()
diff --git a/be/apps/dashboard/package.json b/be/apps/dashboard/package.json
index 49f1e677..c0fe8196 100644
--- a/be/apps/dashboard/package.json
+++ b/be/apps/dashboard/package.json
@@ -42,6 +42,7 @@
"jotai": "2.15.0",
"lucide-react": "0.547.0",
"motion": "12.23.24",
+ "nanoid": "5.1.6",
"ofetch": "1.4.1",
"radix-ui": "1.4.3",
"react": "19.2.0",
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/AddProviderCard.tsx b/be/apps/dashboard/src/modules/storage-providers/components/AddProviderCard.tsx
deleted file mode 100644
index b05c2037..00000000
--- a/be/apps/dashboard/src/modules/storage-providers/components/AddProviderCard.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { clsxm, Spring } from '@afilmory/utils'
-import { DynamicIcon } from 'lucide-react/dynamic'
-import { m } from 'motion/react'
-import type { FC } from 'react'
-
-type AddProviderCardProps = {
- onClick: () => void
-}
-
-export const AddProviderCard: FC = ({ onClick }) => {
- return (
-
- {/* Linear gradient borders with accent color on hover */}
-
-
-
-
-
- {/* Icon */}
-
-
- {/* Text */}
-
-
Add Provider
-
Configure a new storage
-
-
- )
-}
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx
index e147a261..b51ab299 100644
--- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx
+++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx
@@ -1,6 +1,6 @@
-import { clsxm, Spring } from '@afilmory/utils'
+import { Button } from '@afilmory/ui'
+import { clsxm } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
-import { m } from 'motion/react'
import type { FC } from 'react'
import type { StorageProvider } from '../types'
@@ -41,13 +41,15 @@ const providerTypeConfig = {
type ProviderCardProps = {
provider: StorageProvider
isActive: boolean
- onClick: () => void
+ onEdit: () => void
+ onToggleActive: () => void
}
export const ProviderCard: FC = ({
provider,
isActive,
- onClick,
+ onEdit,
+ onToggleActive,
}) => {
const config =
providerTypeConfig[provider.type as keyof typeof providerTypeConfig] ||
@@ -76,16 +78,9 @@ export const ProviderCard: FC = ({
}
return (
-
@@ -131,13 +126,36 @@ export const ProviderCard: FC = ({
- {/* Hover Edit Indicator */}
-
-
-
- Edit
-
+ {/* Actions - bottom right */}
+
+ {isActive ? (
+
+ ) : (
+
+ )}
+
-
+
)
}
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx
index 4aad742a..a6b7e78f 100644
--- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx
+++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx
@@ -15,6 +15,7 @@ import {
import { clsxm, Spring } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
import { m } from 'motion/react'
+import { nanoid } from 'nanoid'
import { useEffect, useMemo, useState } from 'react'
import {
@@ -28,15 +29,13 @@ type ProviderEditModalProps = ModalComponentProps & {
activeProviderId: string | null
onSave: (provider: StorageProvider) => void
onSetActive: (id: string) => void
- onDelete: (id: string) => void
}
export const ProviderEditModal = ({
provider,
- activeProviderId,
+
onSave,
- onSetActive,
- onDelete,
+
dismiss,
}: ProviderEditModalProps) => {
const [formData, setFormData] = useState(provider)
@@ -50,7 +49,6 @@ export const ProviderEditModal = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providerKey])
- const isActive = provider?.id === activeProviderId
const isNewProvider = !provider?.id
const selectedFields = useMemo(() => {
@@ -88,23 +86,11 @@ export const ProviderEditModal = ({
const handleSave = () => {
if (!formData) return
+ formData.id = formData.id ?? nanoid()
onSave(formData)
dismiss()
}
- const handleDelete = () => {
- if (!formData?.id) return
- if (window.confirm('确定要删除这个存储提供商吗?此操作无法撤销。')) {
- onDelete(formData.id)
- dismiss()
- }
- }
-
- const handleSetActive = () => {
- if (!formData?.id) return
- onSetActive(formData.id)
- }
-
if (!formData) return null
return (
@@ -261,10 +247,7 @@ export const ProviderEditModal = ({
{/* Footer */}
-
- {/* Horizontal divider */}
-
-
+
{isNewProvider ? (
// Add mode: Simple cancel + create actions
@@ -283,64 +266,23 @@ export const ProviderEditModal = ({
variant="primary"
size="sm"
>
-
+
Create Provider
) : (
// Edit mode: Delete + cancel + set active + save
-
+
-
-
-
-
- {isActive ? (
-
-
- Active
-
- ) : (
-
- )}
-
-
-
)}
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx
index 66a5968c..f89489e1 100644
--- a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx
+++ b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx
@@ -14,7 +14,6 @@ import {
} from '../hooks'
import type { StorageProvider } from '../types'
import { createEmptyProvider, reorderProvidersByActive } from '../utils'
-import { AddProviderCard } from './AddProviderCard'
import { ProviderCard } from './ProviderCard'
import { ProviderEditModal } from './ProviderEditModal'
@@ -54,7 +53,6 @@ export const StorageProvidersManager = () => {
activeProviderId,
onSave: handleSaveProvider,
onSetActive: handleSetActive,
- onDelete: handleDeleteProvider,
})
}
@@ -82,20 +80,6 @@ export const StorageProvidersManager = () => {
markDirty()
}
- const handleDeleteProvider = (providerId: string) => {
- setProviders((prev) => {
- const next = prev.filter((provider) => provider.id !== providerId)
- const nextActive = next.some(
- (provider) => provider.id === activeProviderId,
- )
- ? activeProviderId
- : (next[0]?.id ?? null)
- setActiveProviderId(nextActive)
- return next
- })
- markDirty()
- }
-
const handleSetActive = (providerId: string) => {
setActiveProviderId(providerId)
markDirty()
@@ -106,7 +90,7 @@ export const StorageProvidersManager = () => {
activeProviderId &&
providers.some((provider) => provider.id === activeProviderId)
? activeProviderId
- : (providers[0]?.id ?? null)
+ : null
updateMutation.mutate(
{
@@ -146,6 +130,14 @@ export const StorageProvidersManager = () => {
const headerActionPortal = (
+