feat: implement managed storage plans and provider settings

- Added new UI schema for managing storage plans, including catalog, pricing, and product configurations.
- Introduced StoragePlanService to handle storage plan operations and integrate with existing billing services.
- Updated SuperAdmin settings to include managed storage provider configurations.
- Enhanced localization files with new keys for storage plan management.
- Implemented API endpoints for fetching and updating storage plans.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-21 16:23:13 +08:00
parent 914342807d
commit f0678038c2
37 changed files with 3537 additions and 171 deletions

View File

@@ -298,6 +298,37 @@ const enUiSchema = {
},
},
},
'storage-plans': {
title: 'Storage plans',
description: 'Managed storage catalog, pricing, and Creem products for storage subscriptions.',
fields: {
catalog: {
title: 'Plan catalog',
description:
'Manage storage plans for managed B2 space. Use the dashboard editor; JSON is no longer required.',
placeholder: 'Configured via dashboard',
helper: 'Plans include name/description/capacity and active flag.',
},
pricing: {
title: 'Storage pricing',
description: 'Monthly price and currency per storage plan.',
placeholder: 'Configured via dashboard',
helper: 'Blank values fall back to defaults or hide pricing.',
},
products: {
title: 'Creem products',
description: 'Creem product per storage plan for checkout and portal.',
placeholder: 'Configured via dashboard',
helper: 'Blank values hide the upgrade entry for that plan.',
},
'managed-provider': {
title: 'Managed provider key',
description: 'Provider ID from storage providers list that backs managed storage plans (e.g., b2-managed).',
placeholder: 'b2-managed',
helper: 'Used by backend to issue upload/read credentials for managed tenants.',
},
},
},
oauth: {
title: 'OAuth providers',
description: 'Configure shared third-party login providers for all tenants.',

View File

@@ -296,6 +296,36 @@ const zhCnUiSchema = {
},
},
},
'storage-plans': {
title: '存储计划',
description: '管理托管存储方案的目录、定价以及 Creem 商品映射。',
fields: {
catalog: {
title: '计划目录',
description: '存储计划的名称/描述/容量与启用状态,建议在控制台中编辑,无需手填 JSON。',
placeholder: '通过控制台编辑',
helper: '包含 plan id、name、description、capacityBytes、isActive 等字段。',
},
pricing: {
title: '存储定价',
description: '每个存储计划的月费与币种。',
placeholder: '通过控制台编辑',
helper: '留空回退到默认值或隐藏价格。',
},
products: {
title: 'Creem 商品',
description: '为存储计划绑定 Creem 商品 ID用于结算与用户门户。',
placeholder: '通过控制台编辑',
helper: '留空则该计划不会展示升级入口。',
},
'managed-provider': {
title: '托管存储 Provider Key',
description: '与存储提供商列表中的配置项对应(例如 b2-managed。',
placeholder: 'b2-managed',
helper: '后端将用该 Provider 为托管存储租户发放上传/读取权限。',
},
},
},
oauth: {
title: 'OAuth 登录渠道',
description: '统一配置所有租户可用的第三方登录渠道。',

View File

@@ -7,6 +7,13 @@ import {
BILLING_PLAN_PRODUCTS_SETTING_KEY,
} from 'core/modules/platform/billing/billing-plan.constants'
import type { BillingPlanId, BillingPlanQuota } from 'core/modules/platform/billing/billing-plan.types'
import {
DEFAULT_STORAGE_PLAN_CATALOG,
STORAGE_PLAN_CATALOG_SETTING_KEY,
STORAGE_PLAN_PRICING_SETTING_KEY,
STORAGE_PLAN_PRODUCTS_SETTING_KEY,
} from 'core/modules/platform/billing/storage-plan.constants'
import type { StoragePlanCatalog } from 'core/modules/platform/billing/storage-plan.types'
import { z } from 'zod'
const nonEmptyString = z.string().trim().min(1)
@@ -114,6 +121,36 @@ export const SYSTEM_SETTING_DEFINITIONS = {
defaultValue: {},
isSensitive: false,
},
storagePlanCatalog: {
key: STORAGE_PLAN_CATALOG_SETTING_KEY,
schema: z.record(z.string(), z.any()),
defaultValue: DEFAULT_STORAGE_PLAN_CATALOG satisfies StoragePlanCatalog,
isSensitive: false,
},
storagePlanProducts: {
key: STORAGE_PLAN_PRODUCTS_SETTING_KEY,
schema: z.record(z.string(), z.any()),
defaultValue: {},
isSensitive: false,
},
storagePlanPricing: {
key: STORAGE_PLAN_PRICING_SETTING_KEY,
schema: z.record(z.string(), z.any()),
defaultValue: {},
isSensitive: false,
},
managedStorageProvider: {
key: 'system.storage.managed.provider',
schema: z.string().trim().min(1).nullable(),
defaultValue: null as string | null,
isSensitive: false,
},
managedStorageProviders: {
key: 'system.storage.managed.providers',
schema: z.string(),
defaultValue: '[]',
isSensitive: true,
},
} as const
const BILLING_PLAN_QUOTA_KEYS = [

View File

@@ -16,12 +16,24 @@ import type {
BillingPlanProductConfigs,
BillingPlanQuota,
} from 'core/modules/platform/billing/billing-plan.types'
import type {
StoragePlanCatalog,
StoragePlanPricingConfigs,
StoragePlanProductConfigs,
} from 'core/modules/platform/billing/storage-plan.types'
import { sql } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import type { ZodType } from 'zod'
import { z } from 'zod'
import { getUiSchemaTranslator } from '../../ui/ui-schema/ui-schema.i18n'
import type { BuilderStorageProvider } from '../setting/storage-provider.utils'
import {
maskStorageProviderSecrets,
mergeStorageProviderSecrets,
parseStorageProviders,
serializeStorageProviders,
} from '../setting/storage-provider.utils'
import type { SystemSettingDbField } from './system-setting.constants'
import {
BILLING_PLAN_FIELD_DESCRIPTORS,
@@ -135,6 +147,29 @@ export class SystemSettingService {
BILLING_PLAN_PRICING_SCHEMA,
{},
) as BillingPlanPricingConfigs
const storagePlanCatalog = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.storagePlanCatalog.key],
STORAGE_PLAN_CATALOG_SCHEMA,
SYSTEM_SETTING_DEFINITIONS.storagePlanCatalog.defaultValue as StoragePlanCatalog,
) as StoragePlanCatalog
const storagePlanProducts = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.storagePlanProducts.key],
STORAGE_PLAN_PRODUCTS_SCHEMA,
{},
) as StoragePlanProductConfigs
const storagePlanPricing = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.storagePlanPricing.key],
STORAGE_PLAN_PRICING_SCHEMA,
{},
) as StoragePlanPricingConfigs
const managedStorageProvider = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.managedStorageProvider.key],
SYSTEM_SETTING_DEFINITIONS.managedStorageProvider.schema,
SYSTEM_SETTING_DEFINITIONS.managedStorageProvider.defaultValue,
)
const managedStorageProviders = this.parseManagedStorageProviders(
rawValues[SYSTEM_SETTING_DEFINITIONS.managedStorageProviders.key],
)
return {
allowRegistration,
maxRegistrableUsers,
@@ -151,6 +186,11 @@ export class SystemSettingService {
billingPlanOverrides,
billingPlanProducts,
billingPlanPricing,
storagePlanCatalog,
storagePlanProducts,
storagePlanPricing,
managedStorageProvider,
managedStorageProviders,
}
}
@@ -169,6 +209,26 @@ export class SystemSettingService {
return settings.billingPlanPricing ?? {}
}
async getStoragePlanCatalog(): Promise<StoragePlanCatalog> {
const settings = await this.getSettings()
return settings.storagePlanCatalog ?? {}
}
async getStoragePlanProducts(): Promise<StoragePlanProductConfigs> {
const settings = await this.getSettings()
return settings.storagePlanProducts ?? {}
}
async getStoragePlanPricing(): Promise<StoragePlanPricingConfigs> {
const settings = await this.getSettings()
return settings.storagePlanPricing ?? {}
}
async getManagedStorageProviderKey(): Promise<string | null> {
const settings = await this.getSettings()
return settings.managedStorageProvider ?? null
}
async getOverview(): Promise<SystemSettingOverview> {
const settings = await this.getSettings()
const totalUsers = await this.getTotalUserCount()
@@ -194,9 +254,17 @@ export class SystemSettingService {
const updates: Array<{ field: SystemSettingDbField; value: unknown }> = []
const enqueueUpdate = <K extends SystemSettingDbField>(field: K, value: unknown) => {
const enqueueUpdate = <K extends SystemSettingDbField>(
field: K,
value: unknown,
currentValue?: SystemSettings[K],
) => {
updates.push({ field, value })
;(current as unknown as Record<string, unknown>)[field] = value
if (currentValue !== undefined) {
;(current as unknown as Record<string, unknown>)[field] = currentValue
} else {
;(current as unknown as Record<string, unknown>)[field] = value
}
}
if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) {
@@ -287,6 +355,37 @@ export class SystemSettingService {
}
}
if (patch.storagePlanCatalog !== undefined) {
const parsed = STORAGE_PLAN_CATALOG_SCHEMA.parse(patch.storagePlanCatalog)
enqueueUpdate('storagePlanCatalog', parsed)
}
if (patch.storagePlanProducts !== undefined) {
const parsed = STORAGE_PLAN_PRODUCTS_SCHEMA.parse(patch.storagePlanProducts)
enqueueUpdate('storagePlanProducts', parsed)
}
if (patch.storagePlanPricing !== undefined) {
const parsed = STORAGE_PLAN_PRICING_SCHEMA.parse(patch.storagePlanPricing)
enqueueUpdate('storagePlanPricing', parsed)
}
if (patch.managedStorageProvider !== undefined && patch.managedStorageProvider !== current.managedStorageProvider) {
enqueueUpdate('managedStorageProvider', this.normalizeNullableString(patch.managedStorageProvider))
}
if (patch.managedStorageProviders !== undefined) {
const normalizedProviders = this.normalizeManagedStorageProvidersPatch(
patch.managedStorageProviders,
current.managedStorageProviders ?? [],
)
const nextSerialized = serializeStorageProviders(normalizedProviders)
const currentSerialized = serializeStorageProviders(current.managedStorageProviders ?? [])
if (nextSerialized !== currentSerialized) {
enqueueUpdate('managedStorageProviders', nextSerialized, normalizedProviders)
}
}
if (updates.length === 0) {
return current
}
@@ -309,6 +408,10 @@ export class SystemSettingService {
const map = {} as SystemSettingValueMap
;(Object.keys(SYSTEM_SETTING_DEFINITIONS) as SystemSettingDbField[]).forEach((field) => {
if (field === 'managedStorageProviders') {
;(map as Record<string, unknown>)[field] = maskStorageProviderSecrets(settings.managedStorageProviders ?? [])
return
}
;(map as Record<string, unknown>)[field] = settings[field]
})
@@ -342,6 +445,51 @@ export class SystemSettingService {
return map
}
private parseManagedStorageProviders(raw: unknown): BuilderStorageProvider[] {
if (!raw) {
return []
}
if (Array.isArray(raw) || typeof raw === 'object') {
try {
return parseStorageProviders(JSON.stringify(raw))
} catch {
return []
}
}
if (typeof raw === 'string') {
const normalized = raw.trim()
if (!normalized) {
return []
}
return parseStorageProviders(normalized)
}
return []
}
private normalizeManagedStorageProvidersPatch(
patch: unknown,
current: BuilderStorageProvider[],
): BuilderStorageProvider[] {
let normalized: string
if (typeof patch === 'string') {
normalized = patch
} else if (patch == null) {
normalized = '[]'
} else {
try {
normalized = JSON.stringify(patch)
} catch {
normalized = '[]'
}
}
const incoming = parseStorageProviders(normalized)
return mergeStorageProviderSecrets(incoming, current ?? [])
}
private extractPlanFieldUpdates(patch: UpdateSystemSettingsInput): PlanFieldUpdateSummary {
const summary: PlanFieldUpdateSummary = {
hasUpdates: false,
@@ -608,6 +756,17 @@ const PLAN_PRICING_ENTRY_SCHEMA = z.object({
const BILLING_PLAN_PRICING_SCHEMA = z.record(z.string(), PLAN_PRICING_ENTRY_SCHEMA).default({})
const STORAGE_PLAN_CATALOG_ENTRY_SCHEMA = z.object({
name: z.string().trim().min(1),
description: z.string().trim().nullable().optional(),
capacityBytes: z.number().int().min(0).nullable().optional(),
isActive: z.boolean().optional(),
})
const STORAGE_PLAN_CATALOG_SCHEMA = z.record(z.string(), STORAGE_PLAN_CATALOG_ENTRY_SCHEMA).default({})
const STORAGE_PLAN_PRODUCTS_SCHEMA = z.record(z.string(), PLAN_PRODUCT_ENTRY_SCHEMA).default({})
const STORAGE_PLAN_PRICING_SCHEMA = z.record(z.string(), PLAN_PRICING_ENTRY_SCHEMA).default({})
type PlanQuotaUpdateMap = Partial<Record<BillingPlanId, Partial<BillingPlanQuota>>>
type PlanPricingUpdateMap = Partial<Record<BillingPlanId, Partial<BillingPlanPricing>>>
type PlanProductUpdateMap = Partial<Record<BillingPlanId, BillingPlanPaymentInfo>>

View File

@@ -3,8 +3,14 @@ import type {
BillingPlanPricingConfigs,
BillingPlanProductConfigs,
} from 'core/modules/platform/billing/billing-plan.types'
import type {
StoragePlanCatalog,
StoragePlanPricingConfigs,
StoragePlanProductConfigs,
} from 'core/modules/platform/billing/storage-plan.types'
import type { UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type'
import type { BuilderStorageProvider } from '../setting/storage-provider.utils'
import type { BillingPlanSettingField, SystemSettingDbField, SystemSettingField } from './system-setting.constants'
export interface SystemSettings {
@@ -23,6 +29,11 @@ export interface SystemSettings {
billingPlanOverrides: BillingPlanOverrides
billingPlanProducts: BillingPlanProductConfigs
billingPlanPricing: BillingPlanPricingConfigs
storagePlanCatalog: StoragePlanCatalog
storagePlanProducts: StoragePlanProductConfigs
storagePlanPricing: StoragePlanPricingConfigs
managedStorageProvider: string | null
managedStorageProviders: BuilderStorageProvider[]
}
export type SystemSettingValueMap = {

View File

@@ -1,6 +1,6 @@
import { BILLING_PLAN_IDS } from 'core/modules/platform/billing/billing-plan.constants'
import type {UiSchemaTFunction} from 'core/modules/ui/ui-schema/ui-schema.i18n';
import { identityUiSchemaT } from 'core/modules/ui/ui-schema/ui-schema.i18n'
import type { UiSchemaTFunction } from 'core/modules/ui/ui-schema/ui-schema.i18n'
import { identityUiSchemaT } from 'core/modules/ui/ui-schema/ui-schema.i18n'
import type { UiNode, UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type'
import type { SystemSettingField } from './system-setting.constants'
@@ -62,7 +62,7 @@ const PLAN_PAYMENT_FIELDS = [
},
] as const
function buildBillingPlanGroups (t: UiSchemaTFunction): ReadonlyArray<UiNode<SystemSettingField>> {
function buildBillingPlanGroups(t: UiSchemaTFunction): ReadonlyArray<UiNode<SystemSettingField>> {
return BILLING_PLAN_IDS.map((planId) => {
const quotaFields = PLAN_QUOTA_FIELDS.map((field) => ({
type: 'field' as const,
@@ -115,163 +115,211 @@ function buildBillingPlanGroups (t: UiSchemaTFunction): ReadonlyArray<UiNode<Sys
})
}
export function createSystemSettingUiSchema (t: UiSchemaTFunction): UiSchema<SystemSettingField> {
export function createSystemSettingUiSchema(t: UiSchemaTFunction): UiSchema<SystemSettingField> {
return {
version: SYSTEM_SETTING_UI_SCHEMA_VERSION,
title: t('system.title'),
description: t('system.description'),
sections: [
{
type: 'section',
id: 'registration-control',
title: t('system.sections.registration.title'),
description: t('system.sections.registration.description'),
icon: 'user-cog',
children: [
{
type: 'field',
id: 'registration-allow',
title: t('system.sections.registration.fields.allow-registration.title'),
description: t('system.sections.registration.fields.allow-registration.description'),
key: 'allowRegistration',
component: {
type: 'switch',
},
},
{
type: 'field',
id: 'local-provider-enabled',
title: t('system.sections.registration.fields.local-provider.title'),
description: t('system.sections.registration.fields.local-provider.description'),
key: 'localProviderEnabled',
component: {
type: 'switch',
},
},
{
type: 'field',
id: 'platform-base-domain',
title: t('system.sections.registration.fields.base-domain.title'),
description: t('system.sections.registration.fields.base-domain.description'),
helperText: t('system.sections.registration.fields.base-domain.helper'),
key: 'baseDomain',
component: {
type: 'text',
placeholder: t('system.sections.registration.fields.base-domain.placeholder'),
},
},
{
type: 'field',
id: 'registration-max-users',
title: t('system.sections.registration.fields.max-users.title'),
description: t('system.sections.registration.fields.max-users.description'),
helperText: t('system.sections.registration.fields.max-users.helper'),
key: 'maxRegistrableUsers',
component: {
type: 'text' as const,
inputType: 'number',
placeholder: t('system.sections.registration.fields.max-users.placeholder'),
},
},
],
},
{
type: 'section',
id: 'billing-plan-settings',
title: t('system.sections.billing.title'),
description: t('system.sections.billing.description'),
icon: 'badge-dollar-sign',
children: buildBillingPlanGroups(t),
},
{
type: 'section',
id: 'oauth-providers',
title: t('system.sections.oauth.title'),
description: t('system.sections.oauth.description'),
icon: 'shield-check',
children: [
{
type: 'field',
id: 'oauth-gateway-url',
title: t('system.sections.oauth.fields.gateway.title'),
description: t('system.sections.oauth.fields.gateway.description'),
helperText: t('system.sections.oauth.fields.gateway.helper'),
key: 'oauthGatewayUrl',
component: {
type: 'text',
placeholder: t('system.sections.oauth.fields.gateway.placeholder'),
},
},
{
type: 'group',
id: 'oauth-google',
title: t('system.sections.oauth.groups.google.title'),
description: t('system.sections.oauth.groups.google.description'),
icon: 'badge-check',
children: [
{
type: 'field',
id: 'oauth-google-client-id',
title: t('system.sections.oauth.groups.google.fields.client-id.title'),
description: t('system.sections.oauth.groups.google.fields.client-id.description'),
key: 'oauthGoogleClientId',
component: {
type: 'text',
placeholder: t('system.sections.oauth.groups.google.fields.client-id.placeholder'),
},
version: SYSTEM_SETTING_UI_SCHEMA_VERSION,
title: t('system.title'),
description: t('system.description'),
sections: [
{
type: 'section',
id: 'registration-control',
title: t('system.sections.registration.title'),
description: t('system.sections.registration.description'),
icon: 'user-cog',
children: [
{
type: 'field',
id: 'registration-allow',
title: t('system.sections.registration.fields.allow-registration.title'),
description: t('system.sections.registration.fields.allow-registration.description'),
key: 'allowRegistration',
component: {
type: 'switch',
},
{
type: 'field',
id: 'oauth-google-client-secret',
title: t('system.sections.oauth.groups.google.fields.client-secret.title'),
description: t('system.sections.oauth.groups.google.fields.client-secret.description'),
key: 'oauthGoogleClientSecret',
component: {
type: 'secret',
placeholder: t('system.sections.oauth.groups.google.fields.client-secret.placeholder'),
revealable: true,
autoComplete: 'off',
},
},
{
type: 'field',
id: 'local-provider-enabled',
title: t('system.sections.registration.fields.local-provider.title'),
description: t('system.sections.registration.fields.local-provider.description'),
key: 'localProviderEnabled',
component: {
type: 'switch',
},
],
},
{
type: 'group',
id: 'oauth-github',
title: t('system.sections.oauth.groups.github.title'),
description: t('system.sections.oauth.groups.github.description'),
icon: 'github',
children: [
{
type: 'field',
id: 'oauth-github-client-id',
title: t('system.sections.oauth.groups.github.fields.client-id.title'),
description: t('system.sections.oauth.groups.github.fields.client-id.description'),
key: 'oauthGithubClientId',
component: {
type: 'text',
placeholder: t('system.sections.oauth.groups.github.fields.client-id.placeholder'),
},
},
{
type: 'field',
id: 'platform-base-domain',
title: t('system.sections.registration.fields.base-domain.title'),
description: t('system.sections.registration.fields.base-domain.description'),
helperText: t('system.sections.registration.fields.base-domain.helper'),
key: 'baseDomain',
component: {
type: 'text',
placeholder: t('system.sections.registration.fields.base-domain.placeholder'),
},
{
type: 'field',
id: 'oauth-github-client-secret',
title: t('system.sections.oauth.groups.github.fields.client-secret.title'),
description: t('system.sections.oauth.groups.github.fields.client-secret.description'),
key: 'oauthGithubClientSecret',
component: {
type: 'secret',
placeholder: t('system.sections.oauth.groups.github.fields.client-secret.placeholder'),
revealable: true,
autoComplete: 'off',
},
},
{
type: 'field',
id: 'registration-max-users',
title: t('system.sections.registration.fields.max-users.title'),
description: t('system.sections.registration.fields.max-users.description'),
helperText: t('system.sections.registration.fields.max-users.helper'),
key: 'maxRegistrableUsers',
component: {
type: 'text' as const,
inputType: 'number',
placeholder: t('system.sections.registration.fields.max-users.placeholder'),
},
],
},
],
},
],
}
},
],
},
{
type: 'section',
id: 'billing-plan-settings',
title: t('system.sections.billing.title'),
description: t('system.sections.billing.description'),
icon: 'badge-dollar-sign',
children: buildBillingPlanGroups(t),
},
{
type: 'section',
id: 'storage-plan-settings',
title: t('system.sections.storage-plans.title'),
description: t('system.sections.storage-plans.description'),
icon: 'database',
children: [
{
type: 'field',
id: 'storage-plan-catalog',
title: t('system.sections.storage-plans.fields.catalog.title'),
description: t('system.sections.storage-plans.fields.catalog.description'),
helperText: t('system.sections.storage-plans.fields.catalog.helper'),
key: 'storagePlanCatalog',
component: { type: 'slot', name: 'storage-plan-catalog' } as const,
},
{
type: 'field',
id: 'storage-plan-pricing',
title: t('system.sections.storage-plans.fields.pricing.title'),
description: t('system.sections.storage-plans.fields.pricing.description'),
helperText: t('system.sections.storage-plans.fields.pricing.helper'),
key: 'storagePlanPricing',
component: { type: 'slot', name: 'storage-plan-pricing' } as const,
},
{
type: 'field',
id: 'storage-plan-products',
title: t('system.sections.storage-plans.fields.products.title'),
description: t('system.sections.storage-plans.fields.products.description'),
helperText: t('system.sections.storage-plans.fields.products.helper'),
key: 'storagePlanProducts',
component: { type: 'slot', name: 'storage-plan-products' } as const,
},
{
type: 'field',
id: 'storage-provider-managed',
title: t('system.sections.storage-plans.fields.managed-provider.title'),
description: t('system.sections.storage-plans.fields.managed-provider.description'),
helperText: t('system.sections.storage-plans.fields.managed-provider.helper'),
key: 'managedStorageProvider',
component: {
type: 'text',
placeholder: t('system.sections.storage-plans.fields.managed-provider.placeholder'),
},
},
],
},
{
type: 'section',
id: 'oauth-providers',
title: t('system.sections.oauth.title'),
description: t('system.sections.oauth.description'),
icon: 'shield-check',
children: [
{
type: 'field',
id: 'oauth-gateway-url',
title: t('system.sections.oauth.fields.gateway.title'),
description: t('system.sections.oauth.fields.gateway.description'),
helperText: t('system.sections.oauth.fields.gateway.helper'),
key: 'oauthGatewayUrl',
component: {
type: 'text',
placeholder: t('system.sections.oauth.fields.gateway.placeholder'),
},
},
{
type: 'group',
id: 'oauth-google',
title: t('system.sections.oauth.groups.google.title'),
description: t('system.sections.oauth.groups.google.description'),
icon: 'badge-check',
children: [
{
type: 'field',
id: 'oauth-google-client-id',
title: t('system.sections.oauth.groups.google.fields.client-id.title'),
description: t('system.sections.oauth.groups.google.fields.client-id.description'),
key: 'oauthGoogleClientId',
component: {
type: 'text',
placeholder: t('system.sections.oauth.groups.google.fields.client-id.placeholder'),
},
},
{
type: 'field',
id: 'oauth-google-client-secret',
title: t('system.sections.oauth.groups.google.fields.client-secret.title'),
description: t('system.sections.oauth.groups.google.fields.client-secret.description'),
key: 'oauthGoogleClientSecret',
component: {
type: 'secret',
placeholder: t('system.sections.oauth.groups.google.fields.client-secret.placeholder'),
revealable: true,
autoComplete: 'off',
},
},
],
},
{
type: 'group',
id: 'oauth-github',
title: t('system.sections.oauth.groups.github.title'),
description: t('system.sections.oauth.groups.github.description'),
icon: 'github',
children: [
{
type: 'field',
id: 'oauth-github-client-id',
title: t('system.sections.oauth.groups.github.fields.client-id.title'),
description: t('system.sections.oauth.groups.github.fields.client-id.description'),
key: 'oauthGithubClientId',
component: {
type: 'text',
placeholder: t('system.sections.oauth.groups.github.fields.client-id.placeholder'),
},
},
{
type: 'field',
id: 'oauth-github-client-secret',
title: t('system.sections.oauth.groups.github.fields.client-secret.title'),
description: t('system.sections.oauth.groups.github.fields.client-secret.description'),
key: 'oauthGithubClientSecret',
component: {
type: 'secret',
placeholder: t('system.sections.oauth.groups.github.fields.client-secret.placeholder'),
revealable: true,
autoComplete: 'off',
},
},
],
},
],
},
],
}
}
const SYSTEM_SETTING_SCHEMA_FOR_KEYS = createSystemSettingUiSchema(identityUiSchemaT)

View File

@@ -7,6 +7,7 @@ export const BILLING_PLAN_DEFINITIONS: Record<BillingPlanId, BillingPlanDefiniti
id: 'free',
name: 'Free',
description: '默认入门方案,适用于个人与试用场景。',
includedStorageBytes: 0,
quotas: {
monthlyAssetProcessLimit: 300,
libraryItemLimit: 500,
@@ -18,6 +19,7 @@ export const BILLING_PLAN_DEFINITIONS: Record<BillingPlanId, BillingPlanDefiniti
id: 'pro',
name: 'Pro',
description: '专业方案,预留给即将上线的订阅。',
includedStorageBytes: 0,
quotas: {
monthlyAssetProcessLimit: 1000,
libraryItemLimit: 5000,
@@ -29,6 +31,7 @@ export const BILLING_PLAN_DEFINITIONS: Record<BillingPlanId, BillingPlanDefiniti
id: 'friend',
name: 'Friend',
description: '内部使用的好友方案,没有任何限制,仅超级管理员可设置。',
includedStorageBytes: null,
quotas: {
monthlyAssetProcessLimit: null,
libraryItemLimit: null,

View File

@@ -50,6 +50,21 @@ export class BillingPlanService {
return this.applyOverrides(definition.quotas, overrides[planId])
}
async getPlanIdForTenant(tenantId: string): Promise<BillingPlanId> {
return await this.resolvePlanIdForTenant(tenantId)
}
getIncludedStorageBytes(planId: BillingPlanId): number {
const definition = BILLING_PLAN_DEFINITIONS[planId]
if (!definition) {
return 0
}
if (definition.includedStorageBytes === null) {
return Number.POSITIVE_INFINITY
}
return definition.includedStorageBytes ?? 0
}
getPlanDefinitions(): BillingPlanDefinition[] {
return BILLING_PLAN_IDS.map((id) => {
const definition = BILLING_PLAN_DEFINITIONS[id]

View File

@@ -11,6 +11,7 @@ export interface BillingPlanDefinition {
id: BillingPlanId
name: string
description: string
includedStorageBytes?: number | null
quotas: BillingPlanQuota
}

View File

@@ -1,4 +1,4 @@
import { Controller, createZodSchemaDto, Get, Query } from '@afilmory/framework'
import { Body, Controller, createZodSchemaDto, Get, Patch, Query } from '@afilmory/framework'
import { Roles } from 'core/guards/roles.decorator'
import { inject } from 'tsyringe'
import z from 'zod'
@@ -7,18 +7,25 @@ import type { BillingPlanSummary } from './billing-plan.service'
import { BillingPlanService } from './billing-plan.service'
import type { BillingUsageOverview } from './billing-usage.service'
import { BillingUsageService } from './billing-usage.service'
import { StoragePlanService } from './storage-plan.service'
const usageQuerySchema = z.object({
limit: z.coerce.number().positive().int().optional().default(10),
})
class UsageQueryDto extends createZodSchemaDto(usageQuerySchema) {}
const updateStoragePlanSchema = z.object({
planId: z.string().trim().min(1).nullable().optional(),
})
class UpdateStoragePlanDto extends createZodSchemaDto(updateStoragePlanSchema) {}
@Controller('billing')
@Roles('admin')
export class BillingController {
constructor(
@inject(BillingUsageService) private readonly billingUsageService: BillingUsageService,
@inject(BillingPlanService) private readonly billingPlanService: BillingPlanService,
@inject(StoragePlanService) private readonly storagePlanService: StoragePlanService,
) {}
@Get('usage')
@@ -34,4 +41,15 @@ export class BillingController {
])
return { plan, availablePlans }
}
@Get('storage')
async getStoragePlans() {
return await this.storagePlanService.getOverviewForCurrentTenant()
}
@Patch('storage')
async updateStoragePlan(@Body() payload: UpdateStoragePlanDto) {
const planId = payload.planId ?? null
return await this.storagePlanService.updateCurrentTenantPlan(planId)
}
}

View File

@@ -5,10 +5,11 @@ import { SystemSettingModule } from 'core/modules/configuration/system-setting/s
import { BillingController } from './billing.controller'
import { BillingPlanService } from './billing-plan.service'
import { BillingUsageService } from './billing-usage.service'
import { StoragePlanService } from './storage-plan.service'
@Module({
imports: [DatabaseModule, SystemSettingModule],
controllers: [BillingController],
providers: [BillingUsageService, BillingPlanService],
providers: [BillingUsageService, BillingPlanService, StoragePlanService],
})
export class BillingModule {}

View File

@@ -0,0 +1,20 @@
import type { StoragePlanCatalog } from './storage-plan.types'
export const STORAGE_PLAN_CATALOG_SETTING_KEY = 'system.storage.planCatalog'
export const STORAGE_PLAN_PRODUCTS_SETTING_KEY = 'system.storage.planProducts'
export const STORAGE_PLAN_PRICING_SETTING_KEY = 'system.storage.planPricing'
export const DEFAULT_STORAGE_PLAN_CATALOG: StoragePlanCatalog = {
'managed-5gb': {
name: 'Managed B2 • 5GB',
description: '适用于入门用户的托管存储方案。',
capacityBytes: 5 * 1024 * 1024 * 1024,
isActive: true,
},
'managed-50gb': {
name: 'Managed B2 • 50GB',
description: '适用于成长阶段的托管存储方案。',
capacityBytes: 50 * 1024 * 1024 * 1024,
isActive: true,
},
}

View File

@@ -0,0 +1,210 @@
import { tenants } from '@afilmory/db'
import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { BillingPlanService } from './billing-plan.service'
import { DEFAULT_STORAGE_PLAN_CATALOG } from './storage-plan.constants'
import type {
StoragePlanCatalog,
StoragePlanDefinition,
StoragePlanOverview,
StoragePlanPaymentInfo,
StoragePlanPricing,
StoragePlanSummary,
} from './storage-plan.types'
export interface StorageQuotaSummary {
appIncludedBytes: number
storagePlanBytes: number | null
totalBytes: number | null
}
@injectable()
export class StoragePlanService {
constructor(
private readonly dbAccessor: DbAccessor,
private readonly systemSettingService: SystemSettingService,
private readonly billingPlanService: BillingPlanService,
) {}
async getPlanSummaries(): Promise<StoragePlanSummary[]> {
const [catalog, pricing, products] = await Promise.all([
this.getPlanCatalog(),
this.systemSettingService.getStoragePlanPricing(),
this.systemSettingService.getStoragePlanProducts(),
])
return Object.entries(catalog)
.map(([id, entry]) =>
this.buildPlanSummary(
{
id,
...entry,
},
pricing[id],
products[id],
),
)
.filter((plan) => plan.isActive !== false)
}
async getPlanById(planId: string): Promise<StoragePlanSummary | null> {
const [catalog, pricing, products] = await Promise.all([
this.getPlanCatalog(),
this.systemSettingService.getStoragePlanPricing(),
this.systemSettingService.getStoragePlanProducts(),
])
const definition = catalog[planId]
if (!definition) {
return null
}
return this.buildPlanSummary({ id: planId, ...definition }, pricing[planId], products[planId])
}
async getQuotaForTenant(tenantId: string): Promise<StorageQuotaSummary> {
const [tenantPlanId, resolvedPlanId, catalog] = await Promise.all([
this.resolveStoragePlanIdForTenant(tenantId),
this.billingPlanService.getPlanIdForTenant(tenantId),
this.getPlanCatalog(),
])
const appIncluded = this.billingPlanService.getIncludedStorageBytes(resolvedPlanId)
const storagePlan = tenantPlanId ? catalog[tenantPlanId] : undefined
const storagePlanCapacity = storagePlan?.capacityBytes
const storagePlanBytes = storagePlanCapacity === undefined ? 0 : storagePlanCapacity
const totalBytes =
appIncluded === Number.POSITIVE_INFINITY || storagePlanCapacity === null
? null
: (appIncluded || 0) + (storagePlanBytes || 0)
return {
appIncludedBytes: appIncluded,
storagePlanBytes,
totalBytes,
}
}
async getPlanSummaryForTenant(tenantId: string): Promise<StoragePlanSummary | null> {
const planId = await this.resolveStoragePlanIdForTenant(tenantId)
if (!planId) {
return null
}
const plan = await this.getPlanById(planId)
if (!plan) {
return null
}
return plan
}
async getOverviewForCurrentTenant(): Promise<StoragePlanOverview> {
const tenant = requireTenantContext()
const [plans, currentPlan, providerKey] = await Promise.all([
this.getPlanSummaries(),
this.getPlanSummaryForTenant(tenant.tenant.id),
this.systemSettingService.getManagedStorageProviderKey(),
])
return {
managedStorageEnabled: Boolean(providerKey),
managedProviderKey: providerKey ?? null,
currentPlanId: currentPlan?.id ?? null,
currentPlan,
availablePlans: plans,
}
}
async updateCurrentTenantPlan(planId: string | null): Promise<StoragePlanOverview> {
const tenant = requireTenantContext()
await this.assignPlanToTenant(tenant.tenant.id, planId)
return await this.getOverviewForCurrentTenant()
}
private async resolveStoragePlanIdForTenant(tenantId: string): Promise<string | null> {
const db = this.dbAccessor.get()
const [record] = await db
.select({ storagePlanId: tenants.storagePlanId })
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1)
if (!record) {
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
}
const planId = record.storagePlanId?.trim()
return planId && planId.length > 0 ? planId : null
}
private async getPlanCatalog(): Promise<Record<string, StoragePlanDefinition>> {
const config = await this.systemSettingService.getStoragePlanCatalog()
const merged: StoragePlanCatalog = { ...DEFAULT_STORAGE_PLAN_CATALOG, ...config }
return Object.entries(merged).reduce<Record<string, StoragePlanDefinition>>((acc, [id, entry]) => {
if (!id) {
return acc
}
acc[id] = {
id,
name: entry.name,
description: entry.description ?? null,
capacityBytes: entry.capacityBytes ?? 0,
isActive: entry.isActive ?? true,
}
return acc
}, {})
}
private buildPlanSummary(
definition: StoragePlanDefinition,
pricing?: StoragePlanPricing,
payment?: StoragePlanPaymentInfo,
): StoragePlanSummary {
return {
...definition,
pricing,
payment,
}
}
private async assignPlanToTenant(tenantId: string, planId: string | null): Promise<void> {
const normalizedPlanId = this.normalizePlanId(planId)
const managedProviderKey = await this.systemSettingService.getManagedStorageProviderKey()
if (!managedProviderKey) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '托管存储尚未启用,暂时无法订阅托管存储方案。',
})
}
if (normalizedPlanId) {
const plan = await this.getPlanById(normalizedPlanId)
if (!plan || plan.isActive === false) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: `未知或未启用的托管存储方案:${normalizedPlanId}`,
})
}
}
await this.persistTenantStoragePlan(tenantId, normalizedPlanId)
}
private async persistTenantStoragePlan(tenantId: string, planId: string | null): Promise<void> {
const db = this.dbAccessor.get()
await db
.update(tenants)
.set({ storagePlanId: planId, updatedAt: new Date().toISOString() })
.where(eq(tenants.id, tenantId))
}
private normalizePlanId(planId?: string | null): string | null {
if (typeof planId !== 'string') {
return null
}
const trimmed = planId.trim()
return trimmed.length > 0 ? trimmed : null
}
}

View File

@@ -0,0 +1,34 @@
export interface StoragePlanDefinition {
id: string
name: string
description?: string | null
capacityBytes: number | null
isActive?: boolean
}
export type StoragePlanCatalog = Record<string, Omit<StoragePlanDefinition, 'id'>>
export interface StoragePlanPricing {
monthlyPrice: number | null
currency: string | null
}
export interface StoragePlanPaymentInfo {
creemProductId?: string | null
}
export type StoragePlanPricingConfigs = Record<string, StoragePlanPricing | undefined>
export type StoragePlanProductConfigs = Record<string, StoragePlanPaymentInfo | undefined>
export interface StoragePlanSummary extends StoragePlanDefinition {
pricing?: StoragePlanPricing
payment?: StoragePlanPaymentInfo
}
export interface StoragePlanOverview {
managedStorageEnabled: boolean
managedProviderKey: string | null
currentPlanId: string | null
currentPlan: StoragePlanSummary | null
availablePlans: StoragePlanSummary[]
}

View File

@@ -31,6 +31,14 @@ const planProductFields = (() => {
return fields
})()
const storageProviderConfigSchema = z.record(z.string(), z.string())
const storageProviderSchema = z.object({
id: z.string().trim().min(1).optional(),
name: z.string().trim().optional(),
type: z.string().trim().min(1),
config: storageProviderConfigSchema.optional(),
})
const updateSuperAdminSettingsSchema = z
.object({
allowRegistration: z.boolean().optional(),
@@ -55,6 +63,11 @@ const updateSuperAdminSettingsSchema = z
oauthGoogleClientSecret: z.string().trim().min(1).nullable().optional(),
oauthGithubClientId: z.string().trim().min(1).nullable().optional(),
oauthGithubClientSecret: z.string().trim().min(1).nullable().optional(),
storagePlanCatalog: z.record(z.string(), z.any()).optional(),
storagePlanPricing: z.record(z.string(), z.any()).optional(),
storagePlanProducts: z.record(z.string(), z.any()).optional(),
managedStorageProvider: z.string().trim().min(1).nullable().optional(),
managedStorageProviders: z.array(storageProviderSchema).optional(),
...planQuotaFields,
...planPricingFields,
...planProductFields,

View File

@@ -31,7 +31,12 @@ export class TenantRepository {
return { tenant }
}
async createTenant(payload: { name: string; slug: string; planId?: BillingPlanId }): Promise<TenantAggregate> {
async createTenant(payload: {
name: string
slug: string
planId?: BillingPlanId
storagePlanId?: string | null
}): Promise<TenantAggregate> {
const db = this.dbAccessor.get()
const tenantId = generateId()
const tenantRecord: typeof tenants.$inferInsert = {
@@ -39,6 +44,7 @@ export class TenantRepository {
name: payload.name,
slug: payload.slug,
planId: payload.planId ?? 'free',
storagePlanId: payload.storagePlanId ?? null,
status: 'active',
}

View File

@@ -16,7 +16,12 @@ import type { TenantAggregate, TenantContext, TenantResolutionInput } from './te
export class TenantService {
constructor(private readonly repository: TenantRepository) {}
async createTenant(payload: { name: string; slug: string; planId?: BillingPlanId }): Promise<TenantAggregate> {
async createTenant(payload: {
name: string
slug: string
planId?: BillingPlanId
storagePlanId?: string | null
}): Promise<TenantAggregate> {
const normalizedSlug = this.normalizeSlug(payload.slug)
if (!normalizedSlug) {

View File

@@ -0,0 +1,21 @@
import { coreApi } from '~/lib/api-client'
import { camelCaseKeys } from '~/lib/case'
import type { ManagedStorageOverview } from './types'
const STORAGE_BILLING_ENDPOINT = '/billing/storage'
export async function getManagedStorageOverview(): Promise<ManagedStorageOverview> {
return camelCaseKeys<ManagedStorageOverview>(
await coreApi(STORAGE_BILLING_ENDPOINT, {
method: 'GET',
}),
)
}
export async function updateManagedStoragePlan(planId: string | null): Promise<ManagedStorageOverview> {
return await coreApi<ManagedStorageOverview>(STORAGE_BILLING_ENDPOINT, {
method: 'PATCH',
body: { planId },
})
}

View File

@@ -0,0 +1,24 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getManagedStorageOverview, updateManagedStoragePlan } from './api'
import type { ManagedStorageOverview } from './types'
export const MANAGED_STORAGE_PLAN_QUERY_KEY = ['billing', 'storage-plan'] as const
export function useManagedStoragePlansQuery() {
return useQuery({
queryKey: MANAGED_STORAGE_PLAN_QUERY_KEY,
queryFn: getManagedStorageOverview,
})
}
export function useUpdateManagedStoragePlanMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (planId: string | null) => updateManagedStoragePlan(planId),
onSuccess: (data) => {
queryClient.setQueryData<ManagedStorageOverview>(MANAGED_STORAGE_PLAN_QUERY_KEY, data)
},
})
}

View File

@@ -0,0 +1,2 @@
export * from './hooks'
export * from './types'

View File

@@ -0,0 +1,25 @@
export interface ManagedStoragePricing {
monthlyPrice: number | null
currency: string | null
}
export interface ManagedStoragePaymentInfo {
creemProductId?: string | null
}
export interface ManagedStoragePlanSummary {
id: string
name: string
description?: string | null
capacityBytes: number | null
pricing?: ManagedStoragePricing
payment?: ManagedStoragePaymentInfo
}
export interface ManagedStorageOverview {
managedStorageEnabled: boolean
managedProviderKey: string | null
currentPlanId: string | null
currentPlan: ManagedStoragePlanSummary | null
availablePlans: ManagedStoragePlanSummary[]
}

View File

@@ -0,0 +1,76 @@
import { Button, Modal } from '@afilmory/ui'
import { DynamicIcon } from 'lucide-react/dynamic'
import { m } from 'motion/react'
import { useTranslation } from 'react-i18next'
import { useManagedStoragePlansQuery } from '~/modules/storage-plans'
import { ManagedStoragePlansModal } from './ManagedStoragePlansModal'
const managedStorageI18nKeys = {
title: 'photos.storage.managed.title',
description: 'photos.storage.managed.description',
unavailable: 'photos.storage.managed.unavailable',
empty: 'photos.storage.managed.empty',
action: 'photos.storage.managed.actions.subscribe',
seePlans: 'photos.storage.managed.actions.switch',
loading: 'photos.storage.managed.actions.loading',
} as const
export function ManagedStorageEntryCard() {
const { t } = useTranslation()
const plansQuery = useManagedStoragePlansQuery()
const openModal = () => {
Modal.present(ManagedStoragePlansModal, {}, { dismissOnOutsideClick: true })
}
return (
<m.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}>
<div className="group relative flex h-full flex-col gap-3 overflow-hidden bg-background-tertiary p-5 text-left transition-all duration-200 hover:shadow-lg">
<div className="via-text/20 group-hover:via-accent/40 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/40 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/40 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/40 absolute top-0 bottom-0 left-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent transition-opacity" />
<div className="relative">
<div className="bg-accent/15 inline-flex h-12 w-12 items-center justify-center rounded-lg">
<DynamicIcon name="hard-drive" className="h-6 w-6 text-accent" />
</div>
</div>
<div className="flex-1 space-y-1">
<h3 className="text-text text-sm font-semibold">{t(managedStorageI18nKeys.title)}</h3>
<p className="text-text-tertiary text-xs">{t(managedStorageI18nKeys.description)}</p>
</div>
{/*
<div className="text-text-tertiary/80 text-xs">
{plansQuery.isLoading
? t(managedStorageI18nKeys.loading)
: plansQuery.isError
? t(managedStorageI18nKeys.unavailable)
: plansQuery.data?.managedStorageEnabled
? t(managedStorageI18nKeys.seePlans)
: t(managedStorageI18nKeys.unavailable)}
</div> */}
<div className="flex justify-end -mb-3 -mt-2 -mr-3.5">
<Button
type="button"
variant="secondary"
size="sm"
disabled={
plansQuery.isLoading ||
plansQuery.isError ||
!plansQuery.data ||
(!plansQuery.data.managedStorageEnabled && plansQuery.data.availablePlans.length === 0)
}
onClick={openModal}
>
{t(managedStorageI18nKeys.action)}
</Button>
</div>
</div>
</m.div>
)
}

View File

@@ -0,0 +1,221 @@
import { Button, DialogDescription, DialogHeader, DialogTitle } from '@afilmory/ui'
import { clsxm } from '@afilmory/utils'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getI18n } from '~/i18n'
import type { ManagedStoragePlanSummary } from '~/modules/storage-plans'
import { useManagedStoragePlansQuery, useUpdateManagedStoragePlanMutation } from '~/modules/storage-plans'
const managedStorageI18nKeys = {
title: 'photos.storage.managed.title',
description: 'photos.storage.managed.description',
unavailable: 'photos.storage.managed.unavailable',
empty: 'photos.storage.managed.empty',
capacityLabel: 'photos.storage.managed.capacity.label',
capacityUnlimited: 'photos.storage.managed.capacity.unlimited',
capacityUnknown: 'photos.storage.managed.capacity.unknown',
priceLabel: 'photos.storage.managed.price.label',
priceFree: 'photos.storage.managed.price.free',
actionsSubscribe: 'photos.storage.managed.actions.subscribe',
actionsSwitch: 'photos.storage.managed.actions.switch',
actionsCurrent: 'photos.storage.managed.actions.current',
actionsCancel: 'photos.storage.managed.actions.cancel',
actionsLoading: 'photos.storage.managed.actions.loading',
errorLoad: 'photos.storage.managed.error.load',
toastSuccess: 'photos.storage.managed.toast.success',
toastError: 'photos.storage.managed.toast.error',
} as const
export function ManagedStoragePlansModal() {
const { t, i18n } = useTranslation()
const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en'
const plansQuery = useManagedStoragePlansQuery()
const updateMutation = useUpdateManagedStoragePlanMutation()
const numberFormatter = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 })
const priceFormatter = new Intl.NumberFormat(locale, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
const handleSelect = async (planId: string | null) => {
try {
await updateMutation.mutateAsync(planId)
toast.success(t(managedStorageI18nKeys.toastSuccess))
} catch (error) {
toast.error(
t(managedStorageI18nKeys.toastError, {
reason: extractErrorMessage(error, t('common.unknown-error')),
}),
)
}
}
return (
<div className="flex h-full max-h-[85vh] flex-col">
<DialogHeader>
<DialogTitle className="text-lg font-semibold leading-none tracking-tight">
{t(managedStorageI18nKeys.title)}
</DialogTitle>
<DialogDescription className="text-text-secondary text-sm">
{t(managedStorageI18nKeys.description)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
{plansQuery.isLoading ? (
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 2 }).map((_, index) => (
<div key={index} className="bg-background-tertiary h-40 animate-pulse rounded-xl" />
))}
</div>
) : plansQuery.isError ? (
<div className="rounded-xl border border-red/30 bg-red/10 p-4 text-sm text-red">
{t(managedStorageI18nKeys.errorLoad)}
</div>
) : !plansQuery.data?.managedStorageEnabled ? (
<div className="rounded-xl border border-border/30 bg-background-secondary/30 p-4 text-sm text-text-secondary">
{t(managedStorageI18nKeys.unavailable)}
</div>
) : plansQuery.data.availablePlans.length === 0 ? (
<div className="rounded-xl border border-border/30 bg-background-secondary/30 p-4 text-sm text-text-secondary">
{t(managedStorageI18nKeys.empty)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{plansQuery.data.availablePlans.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
isCurrent={plansQuery.data?.currentPlanId === plan.id}
hasCurrentPlan={Boolean(plansQuery.data?.currentPlanId)}
isProcessing={updateMutation.isPending}
onSelect={() => handleSelect(plan.id)}
formatCapacity={(bytes) => formatCapacity(bytes, numberFormatter)}
formatPrice={(value, currency) => formatPrice(value, currency, priceFormatter)}
/>
))}
</div>
)}
{plansQuery.data?.currentPlanId ? (
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
disabled={updateMutation.isPending}
onClick={() => handleSelect(null)}
>
{updateMutation.isPending
? t(managedStorageI18nKeys.actionsLoading)
: t(managedStorageI18nKeys.actionsCancel)}
</Button>
</div>
) : null}
</div>
</div>
)
}
function PlanCard({
plan,
isCurrent,
hasCurrentPlan,
isProcessing,
onSelect,
formatCapacity,
formatPrice,
}: {
plan: ManagedStoragePlanSummary
isCurrent: boolean
hasCurrentPlan: boolean
isProcessing: boolean
onSelect: () => void
formatCapacity: (bytes: number | null) => string
formatPrice: (value: number, currency: string | null | undefined) => string
}) {
const { t } = useTranslation()
const hasPrice =
plan.pricing &&
plan.pricing.monthlyPrice !== null &&
plan.pricing.monthlyPrice !== undefined &&
Number.isFinite(plan.pricing.monthlyPrice)
const priceLabel = hasPrice
? t(managedStorageI18nKeys.priceLabel, {
price: formatPrice(plan.pricing!.monthlyPrice as number, plan.pricing!.currency ?? null),
})
: t(managedStorageI18nKeys.priceFree)
const capacityLabel = formatCapacity(plan.capacityBytes)
const actionLabel = isCurrent
? t(managedStorageI18nKeys.actionsCurrent)
: hasCurrentPlan
? t(managedStorageI18nKeys.actionsSwitch)
: t(managedStorageI18nKeys.actionsSubscribe)
return (
<div
className={clsxm(
'border-border/40 bg-background-secondary/40 flex h-full flex-col rounded-2xl border p-5',
isCurrent && 'border-accent/50 shadow-[0_0_0_1px_rgba(var(--color-accent-rgb),0.3)]',
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-text text-base font-semibold">{plan.name}</h3>
{plan.description ? <p className="text-text-tertiary mt-1 text-sm leading-snug">{plan.description}</p> : null}
</div>
{isCurrent ? (
<span className="text-accent border-accent/40 bg-accent/10 rounded-full px-2 py-0.5 text-xs font-semibold">
{t(managedStorageI18nKeys.actionsCurrent)}
</span>
) : null}
</div>
<div className="mt-4 space-y-1 text-sm">
<p className="text-text font-medium">{capacityLabel}</p>
<p className="text-text-secondary">{priceLabel}</p>
</div>
<Button
type="button"
className="mt-6 w-full"
variant={isCurrent ? 'secondary' : 'primary'}
disabled={isCurrent || isProcessing}
onClick={onSelect}
>
{isProcessing ? t(managedStorageI18nKeys.actionsLoading) : actionLabel}
</Button>
</div>
)
}
function formatCapacity(bytes: number | null, formatter: Intl.NumberFormat) {
const { t } = getI18n()
if (bytes === null) {
return t(managedStorageI18nKeys.capacityUnlimited)
}
if (bytes === undefined || bytes <= 0 || Number.isNaN(bytes)) {
return t(managedStorageI18nKeys.capacityUnknown)
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / 1024 ** exponent
const formattedValue = formatter.format(value >= 10 ? Math.round(value) : value)
return t(managedStorageI18nKeys.capacityLabel, { value: `${formattedValue} ${units[exponent]}` })
}
function formatPrice(value: number, currency: string | null | undefined, formatter: Intl.NumberFormat) {
const formatted = formatter.format(value)
const normalizedCurrency = currency?.toUpperCase()?.trim()
return normalizedCurrency ? `${normalizedCurrency} ${formatted}` : formatted
}
function extractErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) return error.message
if (typeof error === 'object' && error && 'message' in error && typeof (error as any).message === 'string') {
return (error as any).message
}
return fallback
}

View File

@@ -141,7 +141,7 @@ export const ProviderCard: FC<ProviderCardProps> = ({ provider, isActive, onEdit
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" />
<DynamicIcon name="check" className="h-3.5 w-3.5 mr-1" />
<span>{t(storageProvidersI18nKeys.card.makeActive)}</span>
</Button>
)}

View File

@@ -88,7 +88,7 @@ export function ProviderEditModal({
const handleSave = () => {
if (!formData) return
formData.id = formData.id ?? nanoid()
formData.id = formData.id && formData.id.trim().length > 0 ? formData.id : nanoid()
onSave(formData)
dismiss()
}

View File

@@ -255,6 +255,8 @@ export function StorageProvidersManager() {
transition={Spring.presets.smooth}
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{/* <ManagedStorageEntryCard /> */}
{orderedProviders.map((provider, index) => (
<m.div
key={provider.id}

View File

@@ -0,0 +1,239 @@
import { Button, Label, Modal } from '@afilmory/ui'
import { nanoid } from 'nanoid'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
import type { StorageProvider } from '~/modules/storage-providers'
import { useStorageProviderSchemaQuery } from '~/modules/storage-providers'
import { ProviderEditModal } from '~/modules/storage-providers/components/ProviderEditModal'
import { storageProvidersI18nKeys } from '~/modules/storage-providers/constants'
import {
createEmptyProvider,
normalizeStorageProviderConfig,
reorderProvidersByActive,
} from '~/modules/storage-providers/utils'
import { useSuperAdminSettingsQuery, useUpdateSuperAdminSettingsMutation } from '../hooks'
import type { UpdateSuperAdminSettingsPayload } from '../types'
function coerceManagedProviders(input: unknown): StorageProvider[] {
if (!Array.isArray(input)) {
return []
}
return input
.map((entry) => {
const normalized = normalizeStorageProviderConfig(entry as StorageProvider)
const id = normalizeProviderId(normalized.id)
if (!id) {
return null
}
return { ...normalized, id }
})
.filter((provider): provider is StorageProvider => provider !== null)
}
const normalizeProviderId = (value: string | null | undefined): string | null => {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
const ensureLocalProviderId = (value: string | null | undefined): string => {
return normalizeProviderId(value) ?? nanoid()
}
export function ManagedStorageSettings() {
const { t } = useTranslation()
const schemaQuery = useStorageProviderSchemaQuery()
const settingsQuery = useSuperAdminSettingsQuery()
const updateSettings = useUpdateSuperAdminSettingsMutation()
const [providers, setProviders] = useState<StorageProvider[]>([])
const [baselineProviders, setBaselineProviders] = useState<StorageProvider[]>([])
const [managedId, setManagedId] = useState<string | null>(null)
const settingsSource = useMemo(() => {
const payload = settingsQuery.data
if (!payload) return null
return 'values' in payload ? payload.values : payload.settings
}, [settingsQuery.data])
const baselineManagedId = useMemo(() => {
const managed = settingsSource?.managedStorageProvider
return typeof managed === 'string' && managed.trim().length > 0 ? managed.trim() : null
}, [settingsSource])
useEffect(() => {
if (!settingsSource) {
return
}
const rawProviders = settingsSource.managedStorageProviders
const nextProviders = coerceManagedProviders(rawProviders)
setProviders(nextProviders)
setBaselineProviders(nextProviders)
const fallbackId = baselineManagedId ?? normalizeProviderId(nextProviders[0]?.id) ?? null
setManagedId(fallbackId)
}, [settingsSource, baselineManagedId])
useEffect(() => {
if (!managedId) {
return
}
const exists = providers.some((provider) => normalizeProviderId(provider.id) === managedId)
if (!exists) {
setManagedId(null)
}
}, [providers, managedId])
const orderedProviders = useMemo(() => reorderProvidersByActive(providers, managedId), [providers, managedId])
const providersChanged = useMemo(
() => JSON.stringify(baselineProviders) !== JSON.stringify(providers),
[baselineProviders, providers],
)
const normalizedManagedId = normalizeProviderId(managedId)
const managedChanged = normalizedManagedId !== baselineManagedId
const canSave = (providersChanged || managedChanged) && !updateSettings.isPending
const handleEdit = (provider: StorageProvider | null) => {
const providerForm = schemaQuery.data
if (!providerForm) return
const seed = provider ?? createEmptyProvider(providerForm.types[0]?.value ?? 's3')
Modal.present(
ProviderEditModal,
{
provider: seed,
activeProviderId: null,
providerSchema: providerForm,
onSave: (next) => {
const normalized = normalizeStorageProviderConfig(next)
const providerWithId: StorageProvider = { ...normalized, id: ensureLocalProviderId(normalized.id) }
setProviders((prev) => {
const exists = prev.some((item) => item.id === providerWithId.id)
if (exists) {
return prev.map((item) => (item.id === providerWithId.id ? providerWithId : item))
}
return [...prev, providerWithId]
})
setManagedId((prev) => prev ?? providerWithId.id)
},
onSetActive: () => {},
},
{ dismissOnOutsideClick: false },
)
}
const handleSave = () => {
if (!providersChanged && !managedChanged) {
return
}
const payload: UpdateSuperAdminSettingsPayload = {}
if (providersChanged) {
payload.managedStorageProviders = providers
}
if (managedChanged) {
payload.managedStorageProvider = normalizedManagedId
}
updateSettings.mutate(payload)
}
if (schemaQuery.isLoading || settingsQuery.isLoading) {
return (
<LinearBorderPanel className="space-y-3 p-6">
<div className="bg-fill/30 h-6 w-32 animate-pulse rounded" />
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-fill/20 h-16 animate-pulse rounded-lg" />
))}
</div>
</LinearBorderPanel>
)
}
if (schemaQuery.isError || settingsQuery.isError) {
return (
<LinearBorderPanel className="space-y-2 p-6">
<h2 className="text-text text-lg font-semibold">{t('superadmin.settings.managed-storage.title')}</h2>
<p className="text-red text-sm">
{t('superadmin.settings.managed-storage.error', {
reason:
(settingsQuery.error as Error | undefined)?.message ||
(schemaQuery.error as Error | undefined)?.message ||
t('common.unknown-error'),
})}
</p>
</LinearBorderPanel>
)
}
return (
<LinearBorderPanel className="space-y-4 p-6">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-text text-lg font-semibold">{t('superadmin.settings.managed-storage.title')}</h2>
<p className="text-text-secondary text-sm">{t('superadmin.settings.managed-storage.description')}</p>
</div>
<Button onClick={() => handleEdit(null)} disabled={!schemaQuery.data}>
{t(storageProvidersI18nKeys.actions.add)}
</Button>
</div>
{orderedProviders.length === 0 ? (
<p className="text-text-secondary text-sm">{t('superadmin.settings.managed-storage.empty')}</p>
) : (
<div className="space-y-3">
{orderedProviders.map((provider) => (
<div
key={provider.id}
className={[
'border-fill/60 bg-fill/5 flex flex-wrap items-center gap-3 rounded-lg border p-4',
managedId === provider.id ? 'border-accent' : '',
].join(' ')}
>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<Label className="text-text text-sm font-semibold">{provider.name || provider.id}</Label>
{managedId === provider.id ? (
<span className="text-accent border-accent/40 bg-accent/10 rounded-full px-2 py-0.5 text-[11px] font-semibold">
{t('superadmin.settings.managed-storage.current')}
</span>
) : null}
</div>
<p className="text-text-tertiary text-xs">
{t('superadmin.settings.managed-storage.type', { type: provider.type })}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={() => handleEdit(provider)}>
{t('superadmin.settings.managed-storage.actions.edit')}
</Button>
<Button
variant={managedId === provider.id ? 'secondary' : 'primary'}
onClick={() => setManagedId(normalizeProviderId(provider.id))}
>
{managedId === provider.id
? t('superadmin.settings.managed-storage.actions.selected')
: t('superadmin.settings.managed-storage.actions.select')}
</Button>
</div>
</div>
))}
</div>
)}
<div className="flex items-center justify-end gap-3 my-4">
<Button variant="secondary" disabled={!canSave} isLoading={updateSettings.isPending} onClick={handleSave}>
{updateSettings.isPending
? t(storageProvidersI18nKeys.actions.saving)
: t('superadmin.settings.managed-storage.actions.save')}
</Button>
</div>
</LinearBorderPanel>
)
}

View File

@@ -1,4 +1,5 @@
export * from './api'
export * from './components/ManagedStorageSettings'
export * from './components/SuperAdminSettingsForm'
export * from './components/SuperAdminTenantManager'
export * from './hooks'

View File

@@ -2,10 +2,18 @@ import type { PhotoManifestItem } from '@afilmory/builder'
import type { BillingUsageTotalsEntry, PhotoAssetListItem, PhotoSyncLogLevel } from '../photos/types'
import type { SchemaFormValue, UiSchema } from '../schema-form/types'
import type { StorageProvider } from '../storage-providers/types'
export type SuperAdminSettingField = string
export type SuperAdminSettings = Record<SuperAdminSettingField, SchemaFormValue | undefined>
export type SuperAdminSettingsWithStorage = SuperAdminSettings & {
storagePlanCatalog?: Record<string, unknown>
storagePlanPricing?: Record<string, unknown>
storagePlanProducts?: Record<string, unknown>
managedStorageProvider?: string | null
managedStorageProviders?: StorageProvider[]
}
export interface SuperAdminStats {
totalUsers: number
@@ -27,9 +35,10 @@ export type SuperAdminSettingsResponse =
values?: never
})
export type UpdateSuperAdminSettingsPayload = Partial<
Record<SuperAdminSettingField, SchemaFormValue | null | undefined>
>
export type UpdateSuperAdminSettingsPayload = Partial<{
managedStorageProvider: string | null
managedStorageProviders: StorageProvider[]
}>
export type BuilderDebugProgressEvent =
| {

View File

@@ -1,13 +1,30 @@
import { Button, Input, Switch, Textarea } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SuperAdminSettingsForm } from '~/modules/super-admin'
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
import { PageTabs } from '~/components/navigation/PageTabs'
import type { UpdateSuperAdminSettingsPayload } from '~/modules/super-admin'
import {
SuperAdminSettingsForm,
useSuperAdminSettingsQuery,
useUpdateSuperAdminSettingsMutation,
} from '~/modules/super-admin'
const PLAN_SECTION_IDS = ['billing-plan-settings'] as const
const APP_PLAN_SECTION_IDS = ['billing-plan-settings'] as const
const STORAGE_PLAN_SECTION_IDS = ['storage-plan-settings'] as const
const TABS = [
{ id: 'app', labelKey: 'superadmin.plans.tabs.app', sections: APP_PLAN_SECTION_IDS },
{ id: 'storage', labelKey: 'superadmin.plans.tabs.storage', sections: STORAGE_PLAN_SECTION_IDS },
] as const
export function Component() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<(typeof TABS)[number]['id']>('app')
const active = TABS.find((tab) => tab.id === activeTab) ?? TABS[0]
return (
<m.div
initial={{ opacity: 0, y: 8 }}
@@ -20,7 +37,363 @@ export function Component() {
<p className="text-text-secondary text-sm">{t('superadmin.plans.description')}</p>
</header>
<SuperAdminSettingsForm visibleSectionIds={PLAN_SECTION_IDS} />
<PageTabs
activeId={activeTab}
onSelect={(id) => setActiveTab(id as typeof activeTab)}
items={TABS.map((tab) => ({
id: tab.id,
labelKey: tab.labelKey,
}))}
/>
{active.id === 'app' ? <SuperAdminSettingsForm visibleSectionIds={active.sections} /> : <StoragePlanEditor />}
</m.div>
)
}
type StoragePlanRow = {
id: string
name: string
description: string
capacityGb: string
isActive: boolean
monthlyPrice: string
currency: string
creemProductId: string
}
function bytesToGb(bytes: unknown): string {
if (bytes === null || bytes === undefined) return ''
const num = Number(bytes)
if (!Number.isFinite(num)) return ''
return (num / 1024 / 1024 / 1024).toFixed(2)
}
function gbToBytes(value: string): number | null {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return null
return Math.max(0, Math.round(parsed * 1024 * 1024 * 1024))
}
function StoragePlanEditor() {
const { t } = useTranslation()
const query = useSuperAdminSettingsQuery()
const mutation = useUpdateSuperAdminSettingsMutation()
const rawValues = useMemo(() => {
const payload = query.data
if (!payload) return null
if ('values' in payload && payload.values) return payload.values
if ('settings' in payload && payload.settings) return payload.settings
return null
}, [query.data])
type StoragePlanCatalogEntry = {
name?: string
description?: string | null
capacityBytes?: number | null
isActive?: boolean
}
type StoragePlanPricingEntry = { monthlyPrice?: number | null; currency?: string | null }
type StoragePlanProductEntry = { creemProductId?: string | null }
const parsed = useMemo(() => {
const catalog = (rawValues?.storagePlanCatalog as Record<string, StoragePlanCatalogEntry> | undefined) ?? {}
const pricing = (rawValues?.storagePlanPricing as Record<string, StoragePlanPricingEntry> | undefined) ?? {}
const products = (rawValues?.storagePlanProducts as Record<string, StoragePlanProductEntry> | undefined) ?? {}
const provider = (rawValues?.managedStorageProvider as string | null | undefined) ?? ''
return {
rows: Object.entries(catalog).map(([id, entry]) => ({
id,
name: entry?.name ?? '',
description: entry?.description ?? '',
capacityGb: bytesToGb(entry?.capacityBytes),
isActive: entry?.isActive !== false,
monthlyPrice: pricing[id]?.monthlyPrice?.toString() ?? '',
currency: pricing[id]?.currency ?? '',
creemProductId: products[id]?.creemProductId ?? '',
})),
provider,
}
}, [rawValues])
const [rows, setRows] = useState<StoragePlanRow[]>(parsed.rows)
const [providerKey, setProviderKey] = useState<string>(parsed.provider ?? '')
useEffect(() => {
setRows(parsed.rows)
setProviderKey(parsed.provider ?? '')
}, [parsed])
const errors = useMemo(() => {
return rows.map((row) => {
const rowErrors: string[] = []
if (!row.id.trim()) rowErrors.push(t('superadmin.plans.storage.validation.id'))
if (!row.name.trim()) rowErrors.push(t('superadmin.plans.storage.validation.name'))
if (row.capacityGb && !Number.isFinite(Number(row.capacityGb))) {
rowErrors.push(t('superadmin.plans.storage.validation.capacity'))
}
if (row.monthlyPrice && !Number.isFinite(Number(row.monthlyPrice))) {
rowErrors.push(t('superadmin.plans.storage.validation.price'))
}
return rowErrors
})
}, [rows, t])
const hasErrors = errors.some((list) => list.length > 0)
const payload = useMemo((): UpdateSuperAdminSettingsPayload => {
const catalog: Record<string, StoragePlanCatalogEntry> = {}
const pricing: Record<string, StoragePlanPricingEntry> = {}
const products: Record<string, StoragePlanProductEntry> = {}
rows.forEach((row) => {
const id = row.id.trim()
if (!id) return
catalog[id] = {
name: row.name.trim(),
description: row.description.trim() || null,
capacityBytes: gbToBytes(row.capacityGb),
isActive: row.isActive,
}
if (row.monthlyPrice || row.currency) {
const parsedPrice = Number(row.monthlyPrice)
pricing[id] = {
monthlyPrice: Number.isFinite(parsedPrice) ? parsedPrice : null,
currency: row.currency.trim() || null,
}
}
if (row.creemProductId.trim()) {
products[id] = { creemProductId: row.creemProductId.trim() }
}
})
return {
managedStorageProvider: providerKey.trim() || null,
}
}, [providerKey, rows])
const baselinePayload = useMemo(() => {
const catalog = (rawValues?.storagePlanCatalog as Record<string, StoragePlanCatalogEntry> | undefined) ?? {}
const pricing = (rawValues?.storagePlanPricing as Record<string, StoragePlanPricingEntry> | undefined) ?? {}
const products = (rawValues?.storagePlanProducts as Record<string, StoragePlanProductEntry> | undefined) ?? {}
const provider = (rawValues?.managedStorageProvider as string | null | undefined) ?? null
return {
storagePlanCatalog: catalog,
storagePlanPricing: pricing,
storagePlanProducts: products,
managedStorageProvider: provider,
}
}, [rawValues])
const hasChanges = useMemo(
() => JSON.stringify(payload) !== JSON.stringify(baselinePayload),
[payload, baselinePayload],
)
const handleRowChange = (id: string, patch: Partial<StoragePlanRow>) => {
setRows((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row)))
}
const handleAdd = () => {
setRows((prev) => [
...prev,
{
id: `managed-${prev.length + 1}`,
name: '',
description: '',
capacityGb: '',
isActive: true,
monthlyPrice: '',
currency: 'USD',
creemProductId: '',
},
])
}
const handleRemove = (id: string) => {
setRows((prev) => prev.filter((row) => row.id !== id))
}
const handleSave = () => {
if (hasErrors || rows.length === 0) {
return
}
mutation.mutate(payload)
}
if (query.isLoading) {
return (
<LinearBorderPanel className="space-y-3 p-6">
<div className="bg-fill/40 h-6 w-32 animate-pulse rounded-full" />
<div className="space-y-3">
{[1, 2].map((key) => (
<div key={key} className="bg-fill/30 h-24 animate-pulse rounded-xl" />
))}
</div>
</LinearBorderPanel>
)
}
if (query.isError || !rawValues) {
return (
<LinearBorderPanel className="space-y-2 p-6">
<h2 className="text-text text-lg font-semibold">{t('superadmin.plans.storage.title')}</h2>
<p className="text-red text-sm">
{t('superadmin.plans.storage.error', {
reason: query.error instanceof Error ? query.error.message : t('common.unknown-error'),
})}
</p>
</LinearBorderPanel>
)
}
return (
<LinearBorderPanel className="space-y-4 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-text text-lg font-semibold">{t('superadmin.plans.storage.title')}</h2>
<p className="text-text-secondary text-sm">{t('superadmin.plans.storage.description')}</p>
</div>
<div className="flex items-center gap-2 mb-4 ml-auto">
<Button variant="ghost" onClick={handleAdd}>
{t('superadmin.plans.storage.actions.add')}
</Button>
<Button disabled={mutation.isPending || hasErrors || !hasChanges} onClick={handleSave}>
{t('superadmin.plans.storage.actions.save')}
</Button>
</div>
</div>
{hasErrors ? <div className="text-red text-sm">{t('superadmin.plans.storage.validation.block')}</div> : null}
<div className="space-y-4">
{rows.length === 0 ? (
<p className="text-text-secondary text-sm">{t('superadmin.plans.storage.empty')}</p>
) : null}
{rows.map((row, idx) => (
<div key={row.id} className="border-fill/60 bg-fill/5 rounded-xl border p-4">
<div className="flex items-start gap-3">
<div className="flex-1 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-text text-xs font-medium">{t('superadmin.plans.storage.fields.id')}</label>
<Input
value={row.id}
onInput={(e) => handleRowChange(row.id, { id: (e.target as HTMLInputElement).value })}
placeholder="managed-5gb"
/>
</div>
<div className="space-y-1">
<label className="text-text text-xs font-medium">{t('superadmin.plans.storage.fields.name')}</label>
<Input
value={row.name}
onInput={(e) => handleRowChange(row.id, { name: (e.target as HTMLInputElement).value })}
placeholder={t('superadmin.plans.storage.fields.placeholder.name')}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-text text-xs font-medium">
{t('superadmin.plans.storage.fields.description')}
</label>
<Textarea
rows={2}
value={row.description}
onInput={(e) => handleRowChange(row.id, { description: (e.target as HTMLTextAreaElement).value })}
placeholder={t('superadmin.plans.storage.fields.placeholder.description')}
/>
</div>
<div className="grid gap-3 sm:grid-cols-4">
<div className="space-y-1">
<label className="text-text text-xs font-medium">
{t('superadmin.plans.storage.fields.capacity')}
</label>
<Input
inputMode="decimal"
type="text"
value={row.capacityGb}
onInput={(e) => handleRowChange(row.id, { capacityGb: (e.target as HTMLInputElement).value })}
placeholder="5.00"
/>
</div>
<div className="space-y-1">
<label className="text-text text-xs font-medium">
{t('superadmin.plans.storage.fields.price')}
</label>
<Input
inputMode="decimal"
type="text"
value={row.monthlyPrice}
onInput={(e) => handleRowChange(row.id, { monthlyPrice: (e.target as HTMLInputElement).value })}
placeholder="0.99"
/>
</div>
<div className="space-y-1">
<label className="text-text text-xs font-medium">
{t('superadmin.plans.storage.fields.currency')}
</label>
<Input
value={row.currency}
onInput={(e) => handleRowChange(row.id, { currency: (e.target as HTMLInputElement).value })}
placeholder="USD"
/>
</div>
<div className="space-y-1">
<label className="text-text text-xs font-medium">
{t('superadmin.plans.storage.fields.creem')}
</label>
<Input
value={row.creemProductId}
onInput={(e) => handleRowChange(row.id, { creemProductId: (e.target as HTMLInputElement).value })}
placeholder="prod_xxx"
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Switch
checked={row.isActive}
onCheckedChange={(next) => handleRowChange(row.id, { isActive: next })}
/>
<span className="text-text-secondary text-sm">{t('superadmin.plans.storage.fields.active')}</span>
</div>
<Button variant="ghost" onClick={() => handleRemove(row.id)} disabled={rows.length === 1}>
{t('superadmin.plans.storage.actions.remove')}
</Button>
</div>
{errors[idx] && errors[idx]!.length > 0 ? (
<ul className="text-red text-xs">
{errors[idx]!.map((err) => (
<li key={err}>{err}</li>
))}
</ul>
) : null}
</div>
</div>
</div>
))}
</div>
{mutation.isError ? (
<p className="text-red text-sm">
{t('superadmin.plans.storage.error', {
reason: mutation.error instanceof Error ? mutation.error.message : t('common.unknown-error'),
})}
</p>
) : null}
{mutation.isSuccess && !mutation.isPending ? (
<p className="text-text-secondary text-sm">{t('superadmin.plans.storage.saved')}</p>
) : null}
</LinearBorderPanel>
)
}

View File

@@ -1,11 +1,15 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SuperAdminSettingsForm } from '~/modules/super-admin'
import { PageTabs } from '~/components/navigation/PageTabs'
import { ManagedStorageSettings, SuperAdminSettingsForm } from '~/modules/super-admin'
export function Component() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<'general' | 'managed-storage'>('general')
return (
<m.div
initial={{ opacity: 0, y: 8 }}
@@ -18,7 +22,20 @@ export function Component() {
<p className="text-text-secondary text-sm">{t('superadmin.settings.description')}</p>
</header>
<SuperAdminSettingsForm visibleSectionIds={['registration-control', 'oauth-providers']} />
<PageTabs
activeId={activeTab}
onSelect={(id) => setActiveTab(id as typeof activeTab)}
items={[
{ id: 'general', labelKey: 'superadmin.settings.tabs.general' },
{ id: 'managed-storage', labelKey: 'superadmin.settings.tabs.managed-storage' },
]}
/>
{activeTab === 'general' ? (
<SuperAdminSettingsForm visibleSectionIds={['registration-control', 'oauth-providers']} />
) : (
<ManagedStorageSettings />
)}
</m.div>
)
}

View File

@@ -0,0 +1 @@
ALTER TABLE "tenant" ADD COLUMN "storage_plan_id" text;

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1763626275917,
"tag": "0005_flawless_wild_pack",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1763656041992,
"tag": "0006_quick_titania",
"breakpoints": true
}
]
}

View File

@@ -62,6 +62,7 @@ export const tenants = pgTable(
slug: text('slug').notNull(),
name: text('name').notNull(),
planId: text('plan_id').notNull().default('free'),
storagePlanId: text('storage_plan_id'),
banned: boolean('banned').notNull().default(false),
status: tenantStatusEnum('status').notNull().default('inactive'),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),

View File

@@ -275,6 +275,25 @@
"photos.library.tags.toast.success-description": "Storage paths have been updated for the new tag structure.",
"photos.page.description": "Sync and manage photo assets on the server.",
"photos.page.title": "Photo Library",
"photos.storage.managed.actions.cancel": "Cancel managed storage",
"photos.storage.managed.actions.current": "Current plan",
"photos.storage.managed.actions.loading": "Updating…",
"photos.storage.managed.actions.retry": "Try again",
"photos.storage.managed.actions.subscribe": "Subscribe",
"photos.storage.managed.actions.switch": "Switch to this plan",
"photos.storage.managed.capacity.label": "Storage capacity: {{value}}",
"photos.storage.managed.capacity.unknown": "Capacity information is not available.",
"photos.storage.managed.capacity.unlimited": "Unlimited storage capacity",
"photos.storage.managed.description": "Subscribe to managed storage plans powered by the platforms shared provider—no self-hosted bucket required.",
"photos.storage.managed.empty": "No managed storage plans are currently available.",
"photos.storage.managed.error.load": "Failed to load managed storage plans.",
"photos.storage.managed.price.free": "Included in your current plan",
"photos.storage.managed.price.label": "{{price}} / month",
"photos.storage.managed.provider": "Backed by provider {{provider}}",
"photos.storage.managed.title": "Managed storage",
"photos.storage.managed.toast.error": "Failed to update managed storage: {{reason}}",
"photos.storage.managed.toast.success": "Managed storage plan updated.",
"photos.storage.managed.unavailable": "Managed storage hasnt been enabled for this workspace yet.",
"photos.sync.actions.button.apply": "Sync photos",
"photos.sync.actions.button.preview": "Preview sync",
"photos.sync.actions.toast.apply-success": "Photo sync completed",
@@ -716,11 +735,46 @@
"superadmin.nav.settings": "System Settings",
"superadmin.nav.tenants": "Tenants",
"superadmin.plans.description": "Manage plan quotas, pricing, and Creem product mappings. Only super admins can edit these settings.",
"superadmin.plans.storage.actions.add": "Add plan",
"superadmin.plans.storage.actions.remove": "Remove",
"superadmin.plans.storage.actions.save": "Save storage plans",
"superadmin.plans.storage.description": "Create managed storage plans without editing JSON. One plan per tenant; capacity stacks with app plan bundled storage.",
"superadmin.plans.storage.empty": "No storage plans yet. Add one to get started.",
"superadmin.plans.storage.error": "Failed to load storage plans: {{reason}}",
"superadmin.plans.storage.fields.active": "Expose this plan",
"superadmin.plans.storage.fields.capacity": "Capacity (GB)",
"superadmin.plans.storage.fields.creem": "Creem product ID",
"superadmin.plans.storage.fields.currency": "Currency",
"superadmin.plans.storage.fields.description": "Description",
"superadmin.plans.storage.fields.id": "Plan ID",
"superadmin.plans.storage.fields.name": "Display name",
"superadmin.plans.storage.fields.placeholder.description": "Who is this plan for? What's included?",
"superadmin.plans.storage.fields.placeholder.name": "Managed B2 • 5GB",
"superadmin.plans.storage.fields.price": "Monthly price",
"superadmin.plans.storage.saved": "Storage plans updated",
"superadmin.plans.storage.title": "Storage plans",
"superadmin.plans.storage.validation.block": "Fix validation errors before saving.",
"superadmin.plans.storage.validation.capacity": "Capacity must be a number in GB.",
"superadmin.plans.storage.validation.id": "Plan ID is required.",
"superadmin.plans.storage.validation.name": "Display name is required.",
"superadmin.plans.storage.validation.price": "Price must be a number.",
"superadmin.plans.tabs.app": "App plans",
"superadmin.plans.tabs.storage": "Storage plans",
"superadmin.plans.title": "Plan Configuration",
"superadmin.settings.button.loading": "Saving…",
"superadmin.settings.button.save": "Save changes",
"superadmin.settings.description": "Control platform-wide registration policies and local sign-in channels. Managed centrally by super admins.",
"superadmin.settings.error.loading": "Unable to load super admin settings: {{reason}}",
"superadmin.settings.managed-storage.actions.edit": "Edit",
"superadmin.settings.managed-storage.actions.save": "Save managed provider",
"superadmin.settings.managed-storage.actions.select": "Set as managed",
"superadmin.settings.managed-storage.actions.selected": "Selected",
"superadmin.settings.managed-storage.current": "Managed",
"superadmin.settings.managed-storage.description": "Configure the storage provider used for built-in managed storage (e.g., B2).",
"superadmin.settings.managed-storage.empty": "No storage providers yet. Add one to use as managed storage.",
"superadmin.settings.managed-storage.error": "Failed to load storage providers: {{reason}}",
"superadmin.settings.managed-storage.title": "Managed storage provider",
"superadmin.settings.managed-storage.type": "Type: {{type}}",
"superadmin.settings.message.dirty": "You have unsaved changes",
"superadmin.settings.message.error": "Failed to save settings: {{reason}}",
"superadmin.settings.message.idle": "All settings are up to date",
@@ -730,6 +784,8 @@
"superadmin.settings.stats.remaining": "Remaining registration slots",
"superadmin.settings.stats.total-users": "Total users",
"superadmin.settings.stats.unlimited": "Unlimited",
"superadmin.settings.tabs.general": "General",
"superadmin.settings.tabs.managed-storage": "Managed storage",
"superadmin.settings.title": "System Settings",
"superadmin.tenants.button.ban": "Ban",
"superadmin.tenants.button.processing": "Working…",

View File

@@ -275,6 +275,25 @@
"photos.library.tags.toast.success-description": "已根据新标签更新存储路径。",
"photos.page.description": "在此同步和管理服务器中的照片资产。",
"photos.page.title": "照片库",
"photos.storage.managed.actions.cancel": "取消托管存储",
"photos.storage.managed.actions.current": "当前方案",
"photos.storage.managed.actions.loading": "处理中…",
"photos.storage.managed.actions.retry": "重试",
"photos.storage.managed.actions.subscribe": "立即订阅",
"photos.storage.managed.actions.switch": "切换到此方案",
"photos.storage.managed.capacity.label": "可用容量:{{value}}",
"photos.storage.managed.capacity.unknown": "暂无法获取该方案的容量信息。",
"photos.storage.managed.capacity.unlimited": "容量无限制",
"photos.storage.managed.description": "订阅由平台托管的存储方案,无需自建对象存储即可扩容。",
"photos.storage.managed.empty": "当前没有可订阅的托管存储方案。",
"photos.storage.managed.error.load": "无法加载托管存储方案。",
"photos.storage.managed.price.free": "包含在当前订阅内",
"photos.storage.managed.price.label": "{{price}} / 月",
"photos.storage.managed.provider": "托管服务提供方:{{provider}}",
"photos.storage.managed.title": "托管存储",
"photos.storage.managed.toast.error": "更新托管存储方案失败:{{reason}}",
"photos.storage.managed.toast.success": "托管存储方案已更新。",
"photos.storage.managed.unavailable": "托管存储尚未启用,请联系管理员。",
"photos.sync.actions.button.apply": "同步照片",
"photos.sync.actions.button.preview": "预览同步",
"photos.sync.actions.toast.apply-success": "照片同步完成",
@@ -715,11 +734,46 @@
"superadmin.nav.settings": "系统设置",
"superadmin.nav.tenants": "租户管理",
"superadmin.plans.description": "管理各个订阅计划的资源配额、定价信息与 Creem Product 连接,仅超级管理员可编辑。",
"superadmin.plans.storage.actions.add": "新增计划",
"superadmin.plans.storage.actions.remove": "删除",
"superadmin.plans.storage.actions.save": "保存存储计划",
"superadmin.plans.storage.description": "直接编辑托管存储方案,无需填 JSON。每个租户仅一份存储计划会与应用计划的赠送存储叠加。",
"superadmin.plans.storage.empty": "还没有存储计划,请先新增一条。",
"superadmin.plans.storage.error": "加载存储计划失败:{{reason}}",
"superadmin.plans.storage.fields.active": "在前台展示该计划",
"superadmin.plans.storage.fields.capacity": "容量 (GB)",
"superadmin.plans.storage.fields.creem": "Creem 商品 ID",
"superadmin.plans.storage.fields.currency": "币种",
"superadmin.plans.storage.fields.description": "描述",
"superadmin.plans.storage.fields.id": "计划 ID",
"superadmin.plans.storage.fields.name": "展示名称",
"superadmin.plans.storage.fields.placeholder.description": "适用人群与包含的内容",
"superadmin.plans.storage.fields.placeholder.name": "托管 B2 · 5GB",
"superadmin.plans.storage.fields.price": "月费",
"superadmin.plans.storage.saved": "存储计划已更新",
"superadmin.plans.storage.title": "存储计划",
"superadmin.plans.storage.validation.block": "请先修复校验错误后再保存。",
"superadmin.plans.storage.validation.capacity": "容量必须是数字GB。",
"superadmin.plans.storage.validation.id": "计划 ID 必填。",
"superadmin.plans.storage.validation.name": "展示名称必填。",
"superadmin.plans.storage.validation.price": "价格必须是数字。",
"superadmin.plans.tabs.app": "应用计划",
"superadmin.plans.tabs.storage": "存储计划",
"superadmin.plans.title": "订阅计划配置",
"superadmin.settings.button.loading": "保存中...",
"superadmin.settings.button.save": "保存修改",
"superadmin.settings.description": "管理整个平台的注册策略与本地登录渠道,由超级管理员统一维护。",
"superadmin.settings.error.loading": "无法加载超级管理员设置:{{reason}}",
"superadmin.settings.managed-storage.actions.edit": "编辑",
"superadmin.settings.managed-storage.actions.save": "保存托管 Provider",
"superadmin.settings.managed-storage.actions.select": "设为托管",
"superadmin.settings.managed-storage.actions.selected": "已选择",
"superadmin.settings.managed-storage.current": "托管使用",
"superadmin.settings.managed-storage.description": "配置用于内置托管存储的 Provider例如 B2。",
"superadmin.settings.managed-storage.empty": "还没有存储提供商,请先新增。",
"superadmin.settings.managed-storage.error": "加载存储提供商失败:{{reason}}",
"superadmin.settings.managed-storage.title": "托管存储 Provider",
"superadmin.settings.managed-storage.type": "类型:{{type}}",
"superadmin.settings.message.dirty": "您有尚未保存的变更",
"superadmin.settings.message.error": "保存失败:{{reason}}",
"superadmin.settings.message.idle": "所有设置已同步",
@@ -729,6 +783,8 @@
"superadmin.settings.stats.remaining": "剩余可注册名额",
"superadmin.settings.stats.total-users": "当前用户总数",
"superadmin.settings.stats.unlimited": "不限",
"superadmin.settings.tabs.general": "通用",
"superadmin.settings.tabs.managed-storage": "托管存储",
"superadmin.settings.title": "系统设置",
"superadmin.tenants.button.ban": "封禁",
"superadmin.tenants.button.processing": "处理中…",