feat: enhance storage provider management and settings

- Added functionality to manage storage providers, including adding, editing, and toggling active status.
- Introduced utility functions for normalizing and serializing storage provider configurations.
- Implemented masking of sensitive fields in storage provider settings.
- Updated the dashboard UI components to reflect the new storage provider management features.
- Added new constants and utility functions for better handling of storage provider data.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-10-28 23:49:16 +08:00
parent e99b211ac6
commit 33a56b629b
19 changed files with 407 additions and 209 deletions

22
.vscode/settings.json vendored
View File

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

View File

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

View File

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

View File

@@ -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<K extends readonly SettingKeyType[]>(
@@ -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<void> {

View File

@@ -0,0 +1,6 @@
export const STORAGE_PROVIDERS_SETTING_KEY = 'builder.storage.providers'
export const STORAGE_PROVIDER_SENSITIVE_FIELDS: Record<string, readonly string[]> = {
s3: ['secretAccessKey'],
github: ['token'],
}

View File

@@ -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<string, string>
createdAt?: string
updatedAt?: string
}
function normalizeConfig (config: unknown): Record<string, string> {
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return {}
}
const result: Record<string, string> = {}
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<string, unknown>
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<string, string> = { ...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<string, string> = { ...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)),
}
}

View File

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

View File

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

View File

@@ -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<AddProviderCardProps> = ({ onClick }) => {
return (
<m.button
type="button"
onClick={onClick}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={Spring.presets.smooth}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={clsxm(
'group relative flex flex-col items-center justify-center gap-3 overflow-hidden bg-background-tertiary p-5 transition-all duration-200',
'hover:shadow-lg',
'min-h-[180px]',
)}
>
{/* Linear gradient borders with accent color on hover */}
<div className="via-text/20 group-hover:via-accent/60 absolute top-0 right-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent transition-opacity" />
<div className="via-text/20 group-hover:via-accent/60 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent transition-opacity" />
<div className="via-text/20 group-hover:via-accent/60 absolute right-0 bottom-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent transition-opacity" />
<div className="via-text/20 group-hover:via-accent/60 absolute top-0 bottom-0 left-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent transition-opacity" />
{/* Icon */}
<div className="relative">
<div className="border-accent/30 bg-accent/5 group-hover:border-accent/60 group-hover:bg-accent/10 flex h-12 w-12 items-center justify-center rounded-lg border-2 border-dashed transition-all duration-200">
<DynamicIcon
name="plus"
className="text-accent h-6 w-6 transition-transform duration-200 group-hover:scale-110"
/>
</div>
</div>
{/* Text */}
<div className="relative text-center">
<p className="text-text text-sm font-semibold">Add Provider</p>
<p className="text-text-tertiary text-xs">Configure a new storage</p>
</div>
</m.button>
)
}

View File

@@ -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<ProviderCardProps> = ({
provider,
isActive,
onClick,
onEdit,
onToggleActive,
}) => {
const config =
providerTypeConfig[provider.type as keyof typeof providerTypeConfig] ||
@@ -76,16 +78,9 @@ export const ProviderCard: FC<ProviderCardProps> = ({
}
return (
<m.button
type="button"
onClick={onClick}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={Spring.presets.smooth}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
<div
className={clsxm(
'group relative flex flex-col gap-3 overflow-hidden bg-background-tertiary p-5 text-left transition-all duration-200',
'group relative flex size-full flex-col gap-3 overflow-hidden bg-background-tertiary p-5 text-left transition-all duration-200',
'hover:shadow-lg',
)}
>
@@ -131,13 +126,36 @@ export const ProviderCard: FC<ProviderCardProps> = ({
</p>
</div>
{/* Hover Edit Indicator */}
<div className="bg-accent/0 group-hover:bg-accent/5 absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-200 group-hover:opacity-100">
<span className="bg-accent flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium text-white shadow-lg">
<DynamicIcon name="pencil" className="h-3.5 w-3.5" />
Edit
</span>
{/* Actions - bottom right */}
<div className="absolute bottom-3 right-3 flex items-center">
{isActive ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-text-secondary hover:text-text"
onClick={onToggleActive}
>
<DynamicIcon name="x-circle" className="h-3.5 w-3.5 mr-1" />
<span>Make Inactive</span>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="border-accent/30 bg-accent/10 text-accent hover:bg-accent/20 border"
onClick={onToggleActive}
>
<DynamicIcon name="check" className="h-3.5 w-3.5" />
<span>Make Active</span>
</Button>
)}
<Button type="button" variant="ghost" size="sm" onClick={onEdit}>
<DynamicIcon name="pencil" className="h-3.5 w-3.5 mr-1" />
<span>Edit</span>
</Button>
</div>
</m.button>
</div>
)
}

View File

@@ -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<StorageProvider | null>(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 = ({
</div>
{/* Footer */}
<div className="shrink-0 px-6 pt-4 pb-6">
{/* Horizontal divider */}
<div className="via-text/20 mb-4 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="shrink-0 px-6 pt-4 pb-6 border-t">
{isNewProvider ? (
// Add mode: Simple cancel + create actions
<div className="flex items-center justify-end gap-2">
@@ -283,64 +266,23 @@ export const ProviderEditModal = ({
variant="primary"
size="sm"
>
<DynamicIcon name="plus" className="h-3.5 w-3.5" />
<DynamicIcon name="plus" className="h-3.5 w-3.5 mr-2" />
<span>Create Provider</span>
</Button>
</div>
) : (
// Edit mode: Delete + cancel + set active + save
<div className="flex items-center justify-between gap-3">
<div className="flex items-center justify-end gap-3">
<Button
type="button"
onClick={handleDelete}
variant="ghost"
onClick={handleSave}
disabled={!isDirty}
variant="primary"
size="sm"
className="text-red hover:bg-red/10"
>
<DynamicIcon name="trash-2" className="h-3.5 w-3.5" />
<span>Delete</span>
<DynamicIcon name="save" className="h-3.5 w-3.5 mr-2" />
<span>Save Changes</span>
</Button>
<div className="flex gap-2">
<Button
type="button"
onClick={dismiss}
variant="ghost"
size="sm"
className="text-text-secondary hover:text-text"
>
Cancel
</Button>
{isActive ? (
<span className="bg-accent inline-flex items-center gap-1.5 rounded px-4 py-2 text-xs font-semibold text-white uppercase">
<DynamicIcon name="check-circle" className="h-3.5 w-3.5" />
Active
</span>
) : (
<Button
type="button"
onClick={handleSetActive}
variant="ghost"
size="sm"
className="border-accent/30 bg-accent/10 text-accent hover:bg-accent/20 border"
>
<DynamicIcon name="check" className="h-3.5 w-3.5" />
<span>Set Active</span>
</Button>
)}
<Button
type="button"
onClick={handleSave}
disabled={!isDirty}
variant="primary"
size="sm"
>
<DynamicIcon name="save" className="h-3.5 w-3.5" />
<span>Save Changes</span>
</Button>
</div>
</div>
)}
</div>

View File

@@ -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 = (
<MainPageLayout.Actions>
<Button
type="button"
onClick={handleAddProvider}
size="sm"
variant="secondary"
>
</Button>
<Button
type="button"
onClick={handleSave}
@@ -216,20 +208,16 @@ export const StorageProvidersManager = () => {
<ProviderCard
provider={provider}
isActive={provider.id === activeProviderId}
onClick={() => handleEditProvider(provider)}
onEdit={() => handleEditProvider(provider)}
onToggleActive={() => {
setActiveProviderId((prev) =>
prev === provider.id ? null : provider.id,
)
markDirty()
}}
/>
</m.div>
))}
<m.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
...Spring.presets.smooth,
delay: orderedProviders.length * 0.05,
}}
>
<AddProviderCard onClick={handleAddProvider} />
</m.div>
</m.div>
{/* Status Message */}

View File

@@ -3,14 +3,18 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getSettings, updateSettings } from '~/modules/settings'
import { STORAGE_SETTING_KEYS } from './constants'
import type { StorageProvidersPayload } from './types'
import type { StorageProvider, StorageProvidersPayload } from './types'
import {
ensureActiveProviderId,
normalizeStorageProviderConfig,
parseStorageProviders,
serializeStorageProviders,
} from './utils'
export const STORAGE_PROVIDERS_QUERY_KEY = ['settings', 'storage-providers'] as const
export const STORAGE_PROVIDERS_QUERY_KEY = [
'settings',
'storage-providers',
] as const
export const useStorageProvidersQuery = () => {
return useQuery({
@@ -21,12 +25,16 @@ export const useStorageProvidersQuery = () => {
STORAGE_SETTING_KEYS.activeProvider,
])
const rawProviders = response.values[STORAGE_SETTING_KEYS.providers] ?? '[]'
const providers = parseStorageProviders(rawProviders)
const rawProviders =
response.values[STORAGE_SETTING_KEYS.providers] ?? '[]'
const providers = parseStorageProviders(rawProviders).map((provider) =>
normalizeStorageProviderConfig(provider),
)
const activeProviderRaw =
response.values[STORAGE_SETTING_KEYS.activeProvider] ?? ''
const activeProviderId =
typeof activeProviderRaw === 'string' && activeProviderRaw.trim().length > 0
typeof activeProviderRaw === 'string' &&
activeProviderRaw.trim().length > 0
? activeProviderRaw.trim()
: null
@@ -43,10 +51,23 @@ export const useUpdateStorageProvidersMutation = () => {
return useMutation({
mutationFn: async (payload: StorageProvidersPayload) => {
const currentProviders = payload.providers.map((provider) =>
normalizeStorageProviderConfig(provider),
)
const previousProviders = queryClient.getQueryData<{
providers: StorageProvider[]
activeProviderId: string | null
}>(STORAGE_PROVIDERS_QUERY_KEY)?.providers
const resolvedProviders = restoreProviderSecrets(
currentProviders,
previousProviders ?? [],
)
await updateSettings([
{
key: STORAGE_SETTING_KEYS.providers,
value: serializeStorageProviders(payload.providers),
value: serializeStorageProviders(resolvedProviders),
},
{
key: STORAGE_SETTING_KEYS.activeProvider,
@@ -61,3 +82,25 @@ export const useUpdateStorageProvidersMutation = () => {
},
})
}
const restoreProviderSecrets = (
nextProviders: StorageProvider[],
previousProviders: StorageProvider[],
): StorageProvider[] => {
const previousMap = new Map(
previousProviders.map((provider) => [provider.id, provider]),
)
return nextProviders.map((provider) => {
const previous = previousMap.get(provider.id)
const config: Record<string, string> = { ...provider.config }
for (const [key, value] of Object.entries(config)) {
if (value.trim().length === 0 && previous) {
config[key] = previous.config[key] ?? ''
}
}
return { ...provider, config }
})
}

View File

@@ -104,6 +104,15 @@ export const serializeStorageProviders = (
)
}
export const normalizeStorageProviderConfig = (
provider: StorageProvider,
): StorageProvider => {
return {
...provider,
config: normaliseConfigForType(provider.type, provider.config),
}
}
export const getDefaultConfigForType = (
type: StorageProviderType,
): Record<string, string> => {

View File

@@ -17,19 +17,18 @@ const buttonVariants = tv({
variant: {
primary: [
'border-transparent',
'text-white dark:text-white',
'bg-accent dark:bg-accent',
'hover:bg-accent/90 dark:hover:bg-accent/90',
'disabled:bg-accent/50 disabled:text-white/70',
'disabled:dark:bg-accent/30 disabled:dark:text-white/50',
'text-text',
'bg-accent',
'hover:bg-accent/90',
'disabled:bg-accent/50 disabled:text-text/70',
],
secondary: [
'border border-gray-200 dark:border-gray-700',
'text-gray-700 dark:text-gray-200',
'bg-gray-50 dark:bg-gray-800',
'hover:bg-gray-100 dark:hover:bg-gray-750',
'disabled:bg-gray-50 disabled:text-gray-400',
'disabled:dark:bg-gray-800 disabled:dark:text-gray-500',
'border border-fill-tertiary dark:border-fill-tertiary',
'text-text',
'bg-fill-tertiary',
'hover:bg-fill-tertiary/10',
'disabled:bg-fill-tertiary/10',
'disabled:dark:bg-fill-tertiary/10',
],
light: [
'shadow-none',

View File

@@ -162,8 +162,8 @@ const SelectItem = ({
<SelectPrimitive.Item
ref={ref}
className={cn(
'cursor-menu focus:bg-theme-selection-active focus:text-theme-selection-foreground relative flex items-center rounded px-2.5 py-1 outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'data-[highlighted]:bg-theme-selection-hover focus-within:outline-transparent',
'cursor-menu data-[highlighted]:text-accent-foreground relative flex items-center rounded px-2.5 py-1 outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'data-[highlighted]:bg-accent/20 focus-within:outline-transparent',
'h-[28px] w-full',
inset && 'pl-8',
className,

View File

@@ -2,4 +2,5 @@ export * from './backoff'
export * from './cn'
export * from './semaphore'
export * from './spring'
export * from './storage-provider'
export * from './u8array'

View File

@@ -0,0 +1,2 @@
export const STORAGE_PROVIDER_SECRET_PLACEHOLDER =
'__afilmory_secret__preserved__'

83
pnpm-lock.yaml generated
View File

@@ -98,7 +98,7 @@ importers:
version: 9.38.0(jiti@2.6.1)
eslint-config-hyoban:
specifier: 4.0.10
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.38.0(jiti@2.6.1))(tailwindcss@4.1.16)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.38.0(jiti@2.6.1))(tailwindcss@4.1.16)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
fast-glob:
specifier: 3.3.3
version: 3.3.3
@@ -592,6 +592,9 @@ importers:
'@afilmory/task-queue':
specifier: workspace:*
version: link:../../packages/task-queue
'@afilmory/utils':
specifier: workspace:*
version: link:../../../packages/utils
'@aws-sdk/client-s3':
specifier: 3.916.0
version: 3.916.0
@@ -600,7 +603,7 @@ importers:
version: 1.19.5(hono@4.10.2)
better-auth:
specifier: 1.3.29
version: 1.3.29(next@16.0.0(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
version: 1.3.29(next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
drizzle-orm:
specifier: ^0.44.7
version: 0.44.7(@types/pg@8.15.5)(@vercel/postgres@0.10.0)(kysely@0.28.8)(pg@8.16.3)(postgres@3.4.7)
@@ -703,7 +706,7 @@ importers:
version: 5.90.5(react@19.2.0)
better-auth:
specifier: 1.3.29
version: 1.3.29(next@16.0.0(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
version: 1.3.29(next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@@ -721,13 +724,16 @@ importers:
version: 10.1.3
jotai:
specifier: 2.15.0
version: 2.15.0(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
version: 2.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
lucide-react:
specifier: 0.547.0
version: 0.547.0(react@19.2.0)
motion:
specifier: 12.23.24
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
nanoid:
specifier: 5.1.6
version: 5.1.6
ofetch:
specifier: 1.4.1
version: 1.4.1
@@ -745,7 +751,7 @@ importers:
version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-scan:
specifier: 0.4.3
version: 0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5)
version: 0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5)
sonner:
specifier: 2.0.7
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -800,7 +806,7 @@ importers:
version: 9.38.0(jiti@2.6.1)
eslint-config-hyoban:
specifier: 4.0.10
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.38.0(jiti@2.6.1))(tailwindcss@4.1.16)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.38.0(jiti@2.6.1))(tailwindcss@4.1.16)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
lint-staged:
specifier: 16.2.6
version: 16.2.6
@@ -1317,7 +1323,7 @@ importers:
version: 0.15.9(typescript@5.9.3)
unplugin-dts:
specifier: 1.0.0-beta.6
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.1))(esbuild@0.25.11)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.1))(esbuild@0.25.11)(rolldown@1.0.0-beta.44)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
vite:
specifier: 7.1.12
version: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
@@ -8815,6 +8821,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.6:
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
engines: {node: ^18 || >=20}
hasBin: true
nanostores@1.0.1:
resolution: {integrity: sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw==}
engines: {node: ^20.0.0 || >=22.0.0}
@@ -17344,7 +17355,7 @@ snapshots:
batch-cluster@15.0.1: {}
better-auth@1.3.29(next@16.0.0(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
better-auth@1.3.29(next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@better-auth/core': 1.3.29(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
'@better-auth/telemetry': 1.3.29(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
@@ -17361,7 +17372,7 @@ snapshots:
nanostores: 1.0.1
zod: 4.1.12
optionalDependencies:
next: 16.0.0(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: 16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
@@ -19830,6 +19841,13 @@ snapshots:
jose@6.1.0: {}
jotai@2.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0):
optionalDependencies:
'@babel/core': 7.28.4
'@babel/template': 7.27.2
'@types/react': 19.2.2
react: 19.2.0
jotai@2.15.0(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0):
optionalDependencies:
'@babel/core': 7.28.5
@@ -20844,6 +20862,8 @@ snapshots:
nanoid@3.3.11: {}
nanoid@5.1.6: {}
nanostores@1.0.1: {}
napi-postinstall@0.3.3: {}
@@ -20876,6 +20896,30 @@ snapshots:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.0
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001751
postcss: 8.4.31
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.0.0
'@next/swc-darwin-x64': 16.0.0
'@next/swc-linux-arm64-gnu': 16.0.0
'@next/swc-linux-arm64-musl': 16.0.0
'@next/swc-linux-x64-gnu': 16.0.0
'@next/swc-linux-x64-musl': 16.0.0
'@next/swc-win32-arm64-msvc': 16.0.0
'@next/swc-win32-x64-msvc': 16.0.0
sharp: 0.34.4
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
optional: true
next@16.0.0(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.0
@@ -22001,7 +22045,7 @@ snapshots:
optionalDependencies:
react-dom: 19.2.0(react@19.2.0)
react-scan@0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2):
react-scan@0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5):
dependencies:
'@babel/core': 7.28.4
'@babel/generator': 7.28.3
@@ -22010,7 +22054,7 @@ snapshots:
'@clack/prompts': 0.8.2
'@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@preact/signals': 1.3.2(preact@10.27.2)
'@rollup/pluginutils': 5.3.0(rollup@2.79.2)
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
'@types/node': 20.19.23
bippy: 0.3.27(@types/react@19.2.2)(react@19.2.0)
esbuild: 0.25.11
@@ -22023,7 +22067,7 @@ snapshots:
react-dom: 19.2.0(react@19.2.0)
tsx: 4.20.6
optionalDependencies:
next: 16.0.0(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: 16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-router: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-router-dom: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
unplugin: 2.1.0
@@ -22032,7 +22076,7 @@ snapshots:
- rollup
- supports-color
react-scan@0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5):
react-scan@0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2):
dependencies:
'@babel/core': 7.28.4
'@babel/generator': 7.28.3
@@ -22041,7 +22085,7 @@ snapshots:
'@clack/prompts': 0.8.2
'@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@preact/signals': 1.3.2(preact@10.27.2)
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
'@rollup/pluginutils': 5.3.0(rollup@2.79.2)
'@types/node': 20.19.23
bippy: 0.3.27(@types/react@19.2.2)(react@19.2.0)
esbuild: 0.25.11
@@ -22900,6 +22944,14 @@ snapshots:
dependencies:
inline-style-parser: 0.2.4
styled-jsx@5.1.6(@babel/core@7.28.4)(react@19.2.0):
dependencies:
client-only: 0.0.1
react: 19.2.0
optionalDependencies:
'@babel/core': 7.28.4
optional: true
styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.0):
dependencies:
client-only: 0.0.1
@@ -23338,7 +23390,7 @@ snapshots:
magic-string-ast: 1.0.3
unplugin: 2.3.10
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.1))(esbuild@0.25.11)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.1))(esbuild@0.25.11)(rolldown@1.0.0-beta.44)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
'@volar/typescript': 2.4.23
@@ -23352,6 +23404,7 @@ snapshots:
optionalDependencies:
'@microsoft/api-extractor': 7.52.13(@types/node@24.9.1)
esbuild: 0.25.11
rolldown: 1.0.0-beta.44
rollup: 4.52.5
vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
transitivePeerDependencies: