From f0678038c2c515b0c00499e37a11f0cd2aef3678 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 21 Nov 2025 16:23:13 +0800 Subject: [PATCH] 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 --- be/apps/core/src/locales/ui-schema/en.ts | 31 + be/apps/core/src/locales/ui-schema/zh-CN.ts | 30 + .../system-setting.constants.ts | 37 + .../system-setting/system-setting.service.ts | 163 +- .../system-setting/system-setting.types.ts | 11 + .../system-setting.ui-schema.ts | 358 ++-- .../billing/billing-plan.constants.ts | 3 + .../platform/billing/billing-plan.service.ts | 15 + .../platform/billing/billing-plan.types.ts | 1 + .../platform/billing/billing.controller.ts | 20 +- .../platform/billing/billing.module.ts | 3 +- .../billing/storage-plan.constants.ts | 20 + .../platform/billing/storage-plan.service.ts | 210 +++ .../platform/billing/storage-plan.types.ts | 34 + .../platform/super-admin/super-admin.dto.ts | 13 + .../platform/tenant/tenant.repository.ts | 8 +- .../modules/platform/tenant/tenant.service.ts | 7 +- .../src/modules/storage-plans/api.ts | 21 + .../src/modules/storage-plans/hooks.ts | 24 + .../src/modules/storage-plans/index.ts | 2 + .../src/modules/storage-plans/types.ts | 25 + .../components/ManagedStorageEntryCard.tsx | 76 + .../components/ManagedStoragePlansModal.tsx | 221 +++ .../components/ProviderCard.tsx | 2 +- .../components/ProviderEditModal.tsx | 2 +- .../components/StorageProvidersManager.tsx | 2 + .../components/ManagedStorageSettings.tsx | 239 +++ .../src/modules/super-admin/index.ts | 1 + .../src/modules/super-admin/types.ts | 15 +- .../dashboard/src/pages/superadmin/plans.tsx | 379 +++- .../src/pages/superadmin/settings.tsx | 21 +- .../db/migrations/0006_quick_titania.sql | 1 + .../db/migrations/meta/0006_snapshot.json | 1593 +++++++++++++++++ be/packages/db/migrations/meta/_journal.json | 7 + be/packages/db/src/schema.ts | 1 + locales/dashboard/en.json | 56 + locales/dashboard/zh-CN.json | 56 + 37 files changed, 3537 insertions(+), 171 deletions(-) create mode 100644 be/apps/core/src/modules/platform/billing/storage-plan.constants.ts create mode 100644 be/apps/core/src/modules/platform/billing/storage-plan.service.ts create mode 100644 be/apps/core/src/modules/platform/billing/storage-plan.types.ts create mode 100644 be/apps/dashboard/src/modules/storage-plans/api.ts create mode 100644 be/apps/dashboard/src/modules/storage-plans/hooks.ts create mode 100644 be/apps/dashboard/src/modules/storage-plans/index.ts create mode 100644 be/apps/dashboard/src/modules/storage-plans/types.ts create mode 100644 be/apps/dashboard/src/modules/storage-providers/components/ManagedStorageEntryCard.tsx create mode 100644 be/apps/dashboard/src/modules/storage-providers/components/ManagedStoragePlansModal.tsx create mode 100644 be/apps/dashboard/src/modules/super-admin/components/ManagedStorageSettings.tsx create mode 100644 be/packages/db/migrations/0006_quick_titania.sql create mode 100644 be/packages/db/migrations/meta/0006_snapshot.json diff --git a/be/apps/core/src/locales/ui-schema/en.ts b/be/apps/core/src/locales/ui-schema/en.ts index 6e437883..05e412f5 100644 --- a/be/apps/core/src/locales/ui-schema/en.ts +++ b/be/apps/core/src/locales/ui-schema/en.ts @@ -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.', diff --git a/be/apps/core/src/locales/ui-schema/zh-CN.ts b/be/apps/core/src/locales/ui-schema/zh-CN.ts index 06cd3f3b..691933d5 100644 --- a/be/apps/core/src/locales/ui-schema/zh-CN.ts +++ b/be/apps/core/src/locales/ui-schema/zh-CN.ts @@ -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: '统一配置所有租户可用的第三方登录渠道。', diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts index 10e65bcb..11f642dc 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts @@ -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 = [ diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts index 66ab3b1d..d0b900b4 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts @@ -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 { + const settings = await this.getSettings() + return settings.storagePlanCatalog ?? {} + } + + async getStoragePlanProducts(): Promise { + const settings = await this.getSettings() + return settings.storagePlanProducts ?? {} + } + + async getStoragePlanPricing(): Promise { + const settings = await this.getSettings() + return settings.storagePlanPricing ?? {} + } + + async getManagedStorageProviderKey(): Promise { + const settings = await this.getSettings() + return settings.managedStorageProvider ?? null + } + async getOverview(): Promise { 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 = (field: K, value: unknown) => { + const enqueueUpdate = ( + field: K, + value: unknown, + currentValue?: SystemSettings[K], + ) => { updates.push({ field, value }) - ;(current as unknown as Record)[field] = value + if (currentValue !== undefined) { + ;(current as unknown as Record)[field] = currentValue + } else { + ;(current as unknown as Record)[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)[field] = maskStorageProviderSecrets(settings.managedStorageProviders ?? []) + return + } ;(map as Record)[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>> type PlanPricingUpdateMap = Partial>> type PlanProductUpdateMap = Partial> diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts index c02895ea..6431ecf8 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts @@ -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 = { diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts index eca3b1a2..25fb85bf 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts @@ -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> { +function buildBillingPlanGroups(t: UiSchemaTFunction): ReadonlyArray> { 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 { +export function createSystemSettingUiSchema(t: UiSchemaTFunction): UiSchema { 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) diff --git a/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts b/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts index 64015f6a..0650eba2 100644 --- a/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts +++ b/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts @@ -7,6 +7,7 @@ export const BILLING_PLAN_DEFINITIONS: Record { + 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] diff --git a/be/apps/core/src/modules/platform/billing/billing-plan.types.ts b/be/apps/core/src/modules/platform/billing/billing-plan.types.ts index eef74a6b..7035887c 100644 --- a/be/apps/core/src/modules/platform/billing/billing-plan.types.ts +++ b/be/apps/core/src/modules/platform/billing/billing-plan.types.ts @@ -11,6 +11,7 @@ export interface BillingPlanDefinition { id: BillingPlanId name: string description: string + includedStorageBytes?: number | null quotas: BillingPlanQuota } diff --git a/be/apps/core/src/modules/platform/billing/billing.controller.ts b/be/apps/core/src/modules/platform/billing/billing.controller.ts index 2e3508c1..08117fea 100644 --- a/be/apps/core/src/modules/platform/billing/billing.controller.ts +++ b/be/apps/core/src/modules/platform/billing/billing.controller.ts @@ -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) + } } diff --git a/be/apps/core/src/modules/platform/billing/billing.module.ts b/be/apps/core/src/modules/platform/billing/billing.module.ts index 534effa1..4b2f62b1 100644 --- a/be/apps/core/src/modules/platform/billing/billing.module.ts +++ b/be/apps/core/src/modules/platform/billing/billing.module.ts @@ -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 {} diff --git a/be/apps/core/src/modules/platform/billing/storage-plan.constants.ts b/be/apps/core/src/modules/platform/billing/storage-plan.constants.ts new file mode 100644 index 00000000..61e9994b --- /dev/null +++ b/be/apps/core/src/modules/platform/billing/storage-plan.constants.ts @@ -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, + }, +} diff --git a/be/apps/core/src/modules/platform/billing/storage-plan.service.ts b/be/apps/core/src/modules/platform/billing/storage-plan.service.ts new file mode 100644 index 00000000..ef5f62f0 --- /dev/null +++ b/be/apps/core/src/modules/platform/billing/storage-plan.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const tenant = requireTenantContext() + await this.assignPlanToTenant(tenant.tenant.id, planId) + return await this.getOverviewForCurrentTenant() + } + + private async resolveStoragePlanIdForTenant(tenantId: string): Promise { + 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> { + const config = await this.systemSettingService.getStoragePlanCatalog() + const merged: StoragePlanCatalog = { ...DEFAULT_STORAGE_PLAN_CATALOG, ...config } + return Object.entries(merged).reduce>((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 { + 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 { + 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 + } +} diff --git a/be/apps/core/src/modules/platform/billing/storage-plan.types.ts b/be/apps/core/src/modules/platform/billing/storage-plan.types.ts new file mode 100644 index 00000000..ba766082 --- /dev/null +++ b/be/apps/core/src/modules/platform/billing/storage-plan.types.ts @@ -0,0 +1,34 @@ +export interface StoragePlanDefinition { + id: string + name: string + description?: string | null + capacityBytes: number | null + isActive?: boolean +} + +export type StoragePlanCatalog = Record> + +export interface StoragePlanPricing { + monthlyPrice: number | null + currency: string | null +} + +export interface StoragePlanPaymentInfo { + creemProductId?: string | null +} + +export type StoragePlanPricingConfigs = Record +export type StoragePlanProductConfigs = Record + +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[] +} diff --git a/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts b/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts index 88e3b9d1..df05f1c9 100644 --- a/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts +++ b/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts @@ -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, diff --git a/be/apps/core/src/modules/platform/tenant/tenant.repository.ts b/be/apps/core/src/modules/platform/tenant/tenant.repository.ts index 3a081594..30d69463 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.repository.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.repository.ts @@ -31,7 +31,12 @@ export class TenantRepository { return { tenant } } - async createTenant(payload: { name: string; slug: string; planId?: BillingPlanId }): Promise { + async createTenant(payload: { + name: string + slug: string + planId?: BillingPlanId + storagePlanId?: string | null + }): Promise { 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', } diff --git a/be/apps/core/src/modules/platform/tenant/tenant.service.ts b/be/apps/core/src/modules/platform/tenant/tenant.service.ts index 4d9ac586..204cc41e 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.service.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.service.ts @@ -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 { + async createTenant(payload: { + name: string + slug: string + planId?: BillingPlanId + storagePlanId?: string | null + }): Promise { const normalizedSlug = this.normalizeSlug(payload.slug) if (!normalizedSlug) { diff --git a/be/apps/dashboard/src/modules/storage-plans/api.ts b/be/apps/dashboard/src/modules/storage-plans/api.ts new file mode 100644 index 00000000..48f93a52 --- /dev/null +++ b/be/apps/dashboard/src/modules/storage-plans/api.ts @@ -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 { + return camelCaseKeys( + await coreApi(STORAGE_BILLING_ENDPOINT, { + method: 'GET', + }), + ) +} + +export async function updateManagedStoragePlan(planId: string | null): Promise { + return await coreApi(STORAGE_BILLING_ENDPOINT, { + method: 'PATCH', + body: { planId }, + }) +} diff --git a/be/apps/dashboard/src/modules/storage-plans/hooks.ts b/be/apps/dashboard/src/modules/storage-plans/hooks.ts new file mode 100644 index 00000000..e95065e2 --- /dev/null +++ b/be/apps/dashboard/src/modules/storage-plans/hooks.ts @@ -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(MANAGED_STORAGE_PLAN_QUERY_KEY, data) + }, + }) +} diff --git a/be/apps/dashboard/src/modules/storage-plans/index.ts b/be/apps/dashboard/src/modules/storage-plans/index.ts new file mode 100644 index 00000000..a1c77e91 --- /dev/null +++ b/be/apps/dashboard/src/modules/storage-plans/index.ts @@ -0,0 +1,2 @@ +export * from './hooks' +export * from './types' diff --git a/be/apps/dashboard/src/modules/storage-plans/types.ts b/be/apps/dashboard/src/modules/storage-plans/types.ts new file mode 100644 index 00000000..fe2e8d97 --- /dev/null +++ b/be/apps/dashboard/src/modules/storage-plans/types.ts @@ -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[] +} diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ManagedStorageEntryCard.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ManagedStorageEntryCard.tsx new file mode 100644 index 00000000..c70591a9 --- /dev/null +++ b/be/apps/dashboard/src/modules/storage-providers/components/ManagedStorageEntryCard.tsx @@ -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 ( + +
+
+
+
+
+ +
+
+ +
+
+ +
+

{t(managedStorageI18nKeys.title)}

+

{t(managedStorageI18nKeys.description)}

+
+ {/* +
+ {plansQuery.isLoading + ? t(managedStorageI18nKeys.loading) + : plansQuery.isError + ? t(managedStorageI18nKeys.unavailable) + : plansQuery.data?.managedStorageEnabled + ? t(managedStorageI18nKeys.seePlans) + : t(managedStorageI18nKeys.unavailable)} +
*/} + +
+ +
+
+ + ) +} diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ManagedStoragePlansModal.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ManagedStoragePlansModal.tsx new file mode 100644 index 00000000..c1ad07dd --- /dev/null +++ b/be/apps/dashboard/src/modules/storage-providers/components/ManagedStoragePlansModal.tsx @@ -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 ( +
+ + + {t(managedStorageI18nKeys.title)} + + + {t(managedStorageI18nKeys.description)} + + + +
+ {plansQuery.isLoading ? ( +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ ))} +
+ ) : plansQuery.isError ? ( +
+ {t(managedStorageI18nKeys.errorLoad)} +
+ ) : !plansQuery.data?.managedStorageEnabled ? ( +
+ {t(managedStorageI18nKeys.unavailable)} +
+ ) : plansQuery.data.availablePlans.length === 0 ? ( +
+ {t(managedStorageI18nKeys.empty)} +
+ ) : ( +
+ {plansQuery.data.availablePlans.map((plan) => ( + handleSelect(plan.id)} + formatCapacity={(bytes) => formatCapacity(bytes, numberFormatter)} + formatPrice={(value, currency) => formatPrice(value, currency, priceFormatter)} + /> + ))} +
+ )} + + {plansQuery.data?.currentPlanId ? ( +
+ +
+ ) : null} +
+
+ ) +} + +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 ( +
+
+
+

{plan.name}

+ {plan.description ?

{plan.description}

: null} +
+ {isCurrent ? ( + + {t(managedStorageI18nKeys.actionsCurrent)} + + ) : null} +
+ +
+

{capacityLabel}

+

{priceLabel}

+
+ + +
+ ) +} + +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 +} diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx index 35090a94..e23d9901 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx @@ -141,7 +141,7 @@ export const ProviderCard: FC = ({ provider, isActive, onEdit className="border-accent/30 bg-accent/10 text-accent hover:bg-accent/20 border" onClick={onToggleActive} > - + {t(storageProvidersI18nKeys.card.makeActive)} )} diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx index 8290e103..9092a7d5 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx @@ -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() } diff --git a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx index 2f0b424d..2d9edd8e 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx @@ -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" > + {/* */} + {orderedProviders.map((provider, index) => ( { + 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([]) + const [baselineProviders, setBaselineProviders] = useState([]) + const [managedId, setManagedId] = useState(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 ( + +
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ + ) + } + + if (schemaQuery.isError || settingsQuery.isError) { + return ( + +

{t('superadmin.settings.managed-storage.title')}

+

+ {t('superadmin.settings.managed-storage.error', { + reason: + (settingsQuery.error as Error | undefined)?.message || + (schemaQuery.error as Error | undefined)?.message || + t('common.unknown-error'), + })} +

+
+ ) + } + + return ( + +
+
+

{t('superadmin.settings.managed-storage.title')}

+

{t('superadmin.settings.managed-storage.description')}

+
+ +
+ + {orderedProviders.length === 0 ? ( +

{t('superadmin.settings.managed-storage.empty')}

+ ) : ( +
+ {orderedProviders.map((provider) => ( +
+
+
+ + {managedId === provider.id ? ( + + {t('superadmin.settings.managed-storage.current')} + + ) : null} +
+

+ {t('superadmin.settings.managed-storage.type', { type: provider.type })} +

+
+ +
+ + +
+
+ ))} +
+ )} + +
+ +
+
+ ) +} diff --git a/be/apps/dashboard/src/modules/super-admin/index.ts b/be/apps/dashboard/src/modules/super-admin/index.ts index 61e91f76..84e3f47d 100644 --- a/be/apps/dashboard/src/modules/super-admin/index.ts +++ b/be/apps/dashboard/src/modules/super-admin/index.ts @@ -1,4 +1,5 @@ export * from './api' +export * from './components/ManagedStorageSettings' export * from './components/SuperAdminSettingsForm' export * from './components/SuperAdminTenantManager' export * from './hooks' diff --git a/be/apps/dashboard/src/modules/super-admin/types.ts b/be/apps/dashboard/src/modules/super-admin/types.ts index af95d9d3..72cdc2ec 100644 --- a/be/apps/dashboard/src/modules/super-admin/types.ts +++ b/be/apps/dashboard/src/modules/super-admin/types.ts @@ -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 +export type SuperAdminSettingsWithStorage = SuperAdminSettings & { + storagePlanCatalog?: Record + storagePlanPricing?: Record + storagePlanProducts?: Record + managedStorageProvider?: string | null + managedStorageProviders?: StorageProvider[] +} export interface SuperAdminStats { totalUsers: number @@ -27,9 +35,10 @@ export type SuperAdminSettingsResponse = values?: never }) -export type UpdateSuperAdminSettingsPayload = Partial< - Record -> +export type UpdateSuperAdminSettingsPayload = Partial<{ + managedStorageProvider: string | null + managedStorageProviders: StorageProvider[] +}> export type BuilderDebugProgressEvent = | { diff --git a/be/apps/dashboard/src/pages/superadmin/plans.tsx b/be/apps/dashboard/src/pages/superadmin/plans.tsx index 9203162e..4f4ed197 100644 --- a/be/apps/dashboard/src/pages/superadmin/plans.tsx +++ b/be/apps/dashboard/src/pages/superadmin/plans.tsx @@ -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 ( {t('superadmin.plans.description')}

- + setActiveTab(id as typeof activeTab)} + items={TABS.map((tab) => ({ + id: tab.id, + labelKey: tab.labelKey, + }))} + /> + + {active.id === 'app' ? : }
) } + +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 | undefined) ?? {} + const pricing = (rawValues?.storagePlanPricing as Record | undefined) ?? {} + const products = (rawValues?.storagePlanProducts as Record | 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(parsed.rows) + const [providerKey, setProviderKey] = useState(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 = {} + const pricing: Record = {} + const products: Record = {} + + 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 | undefined) ?? {} + const pricing = (rawValues?.storagePlanPricing as Record | undefined) ?? {} + const products = (rawValues?.storagePlanProducts as Record | 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) => { + 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 ( + +
+
+ {[1, 2].map((key) => ( +
+ ))} +
+ + ) + } + + if (query.isError || !rawValues) { + return ( + +

{t('superadmin.plans.storage.title')}

+

+ {t('superadmin.plans.storage.error', { + reason: query.error instanceof Error ? query.error.message : t('common.unknown-error'), + })} +

+
+ ) + } + + return ( + +
+
+

{t('superadmin.plans.storage.title')}

+

{t('superadmin.plans.storage.description')}

+
+
+ + +
+
+ + {hasErrors ?
{t('superadmin.plans.storage.validation.block')}
: null} + +
+ {rows.length === 0 ? ( +

{t('superadmin.plans.storage.empty')}

+ ) : null} + {rows.map((row, idx) => ( +
+
+
+
+
+ + handleRowChange(row.id, { id: (e.target as HTMLInputElement).value })} + placeholder="managed-5gb" + /> +
+
+ + handleRowChange(row.id, { name: (e.target as HTMLInputElement).value })} + placeholder={t('superadmin.plans.storage.fields.placeholder.name')} + /> +
+
+ +
+ +