From e676f0c6eb578f607fa6621e5d4d3ff216f88693 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 13 Nov 2023 19:23:22 +0800 Subject: [PATCH] feat(console): add tenant env migration modal (#4859) --- packages/console/package.json | 1 + .../assets/images/tenant-modal-fireworks.svg | 18 +++ .../EnvTagOptionContent/index.tsx | 2 +- .../components/CreateTenantModal/index.tsx | 3 +- .../src/components/TenantEnvTag/index.tsx | 2 +- .../DevelopmentTenantFeatures/index.tsx | 36 +++++ .../index.module.scss | 18 +++ .../DevelopmentTenantMigrationHint/index.tsx | 46 ++++++ .../TenantEnvMigrationModal/index.module.scss | 38 +++++ .../TenantEnvMigrationModal/index.tsx | 136 ++++++++++++++++++ .../TenantNotificationContainer/index.tsx | 18 ++- .../console/src/contexts/TenantsProvider.tsx | 3 +- .../ProfileForm/TenantEnvironment/index.tsx | 2 +- .../TenantBasicSettings/ProfileForm/index.tsx | 2 +- .../TenantBasicSettings/index.tsx | 2 +- packages/integration-tests/src/api/tenant.ts | 3 +- packages/schemas/src/models/tenants.ts | 6 +- packages/schemas/src/types/index.ts | 1 + packages/schemas/src/types/logto-config.ts | 9 ++ packages/schemas/src/types/tenant.ts | 5 + pnpm-lock.yaml | 17 +++ 21 files changed, 347 insertions(+), 21 deletions(-) create mode 100644 packages/console/src/assets/images/tenant-modal-fireworks.svg create mode 100644 packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantFeatures/index.tsx create mode 100644 packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantMigrationHint/index.module.scss create mode 100644 packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantMigrationHint/index.tsx create mode 100644 packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/index.module.scss create mode 100644 packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/index.tsx create mode 100644 packages/schemas/src/types/tenant.ts diff --git a/packages/console/package.json b/packages/console/package.json index 0efeba72f..75c8837e5 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -97,6 +97,7 @@ "react": "^18.0.0", "react-animate-height": "^3.0.4", "react-color": "^2.19.3", + "react-confetti": "^6.1.0", "react-dnd": "^16.0.0", "react-dnd-html5-backend": "^16.0.0", "react-dom": "^18.0.0", diff --git a/packages/console/src/assets/images/tenant-modal-fireworks.svg b/packages/console/src/assets/images/tenant-modal-fireworks.svg new file mode 100644 index 000000000..a781592e1 --- /dev/null +++ b/packages/console/src/assets/images/tenant-modal-fireworks.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/console/src/components/CreateTenantModal/EnvTagOptionContent/index.tsx b/packages/console/src/components/CreateTenantModal/EnvTagOptionContent/index.tsx index eb4120ed2..0c04a613c 100644 --- a/packages/console/src/components/CreateTenantModal/EnvTagOptionContent/index.tsx +++ b/packages/console/src/components/CreateTenantModal/EnvTagOptionContent/index.tsx @@ -1,5 +1,5 @@ import { type AdminConsoleKey } from '@logto/phrases'; -import { TenantTag } from '@logto/schemas/lib/models/tenants.js'; +import { TenantTag } from '@logto/schemas'; import TenantEnvTag from '@/components/TenantEnvTag'; import Divider from '@/ds-components/Divider'; diff --git a/packages/console/src/components/CreateTenantModal/index.tsx b/packages/console/src/components/CreateTenantModal/index.tsx index 46ddf76aa..469b13b67 100644 --- a/packages/console/src/components/CreateTenantModal/index.tsx +++ b/packages/console/src/components/CreateTenantModal/index.tsx @@ -1,6 +1,5 @@ import type { AdminConsoleKey } from '@logto/phrases'; -import { Theme } from '@logto/schemas'; -import { TenantTag } from '@logto/schemas/models'; +import { Theme, TenantTag } from '@logto/schemas'; import { useState } from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; diff --git a/packages/console/src/components/TenantEnvTag/index.tsx b/packages/console/src/components/TenantEnvTag/index.tsx index 194046065..00ddd669a 100644 --- a/packages/console/src/components/TenantEnvTag/index.tsx +++ b/packages/console/src/components/TenantEnvTag/index.tsx @@ -1,5 +1,5 @@ import type { AdminConsoleKey } from '@logto/phrases'; -import { TenantTag } from '@logto/schemas/models'; +import { TenantTag } from '@logto/schemas'; import classNames from 'classnames'; import DynamicT from '@/ds-components/DynamicT'; diff --git a/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantFeatures/index.tsx b/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantFeatures/index.tsx new file mode 100644 index 000000000..0749983c8 --- /dev/null +++ b/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantFeatures/index.tsx @@ -0,0 +1,36 @@ +import PlanQuotaList from '@/components/PlanQuotaList'; +import { comingSoonQuotaKeys } from '@/consts/plan-quotas'; +import { ReservedPlanId } from '@/consts/subscriptions'; +import useSubscriptionPlans from '@/hooks/use-subscription-plans'; +import { type SubscriptionPlanQuota } from '@/types/subscriptions'; + +const featuredQuotaKeys: Array = [ + 'mauLimit', + 'machineToMachineLimit', + 'mfaEnabled', + 'omniSignInEnabled', + 'organizationEnabled', + 'rolesLimit', + 'scopesPerRoleLimit', + 'auditLogsRetentionDays', +]; + +function DevelopmentTenantFeatures() { + const { data: plans } = useSubscriptionPlans(); + const proPlan = plans?.find(({ id }) => id === ReservedPlanId.pro); + + if (!proPlan) { + return null; + } + + return ( + + ); +} + +export default DevelopmentTenantFeatures; diff --git a/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantMigrationHint/index.module.scss b/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantMigrationHint/index.module.scss new file mode 100644 index 000000000..2f32bb6cf --- /dev/null +++ b/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantMigrationHint/index.module.scss @@ -0,0 +1,18 @@ +@use '@/scss/underscore' as _; + +.title { + font: var(--font-title-3); +} + +.hint { + font: var(--font-body-2); + margin-top: _.unit(3); + + .strong { + font: var(--font-label-2); + } + + ol { + padding-inline-start: _.unit(4); + } +} diff --git a/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantMigrationHint/index.tsx b/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantMigrationHint/index.tsx new file mode 100644 index 000000000..4662a67dd --- /dev/null +++ b/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/DevelopmentTenantMigrationHint/index.tsx @@ -0,0 +1,46 @@ +import { useTranslation, Trans } from 'react-i18next'; + +import DynamicT from '@/ds-components/DynamicT'; + +import * as styles from './index.module.scss'; + +type Props = { + type: 'freeStagingTenant' | 'paidTenant'; +}; + +function DevelopmentTenantMigrationHint({ type }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + return ( +
+
+ +
+
+ {type === 'freeStagingTenant' && ( + }}> + {t('tenants.notification.staging_env_hint')} + + )} + {type === 'paidTenant' && ( + <> + }}> + {t('tenants.notification.paid_tenant_hint_1')} + +
    +
  1. + +
  2. +
  3. + +
  4. +
+ + + )} +
+
+ ); +} + +export default DevelopmentTenantMigrationHint; diff --git a/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/index.module.scss b/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/index.module.scss new file mode 100644 index 000000000..001258acd --- /dev/null +++ b/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/index.module.scss @@ -0,0 +1,38 @@ +@use '@/scss/underscore' as _; + +.modalLayoutWrapper { + position: relative; +} + +.headerIcon { + width: 48px; + height: 48px; +} + +.highlight { + color: var(--color-text-link); +} + +.linkButton { + text-decoration: none; +} + +.content { + padding: _.unit(6); + background-color: var(--color-layer-light); + border-radius: 12px; +} + +.fireworks { + position: absolute; + top: 0; + pointer-events: none; + + .fireworksImage { + transform: translateX(-10.5%) translateY(-17%); + } + + .stagingFireworksImage { + transform: translateX(-10.5%) translateY(-38%); + } +} diff --git a/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/index.tsx b/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/index.tsx new file mode 100644 index 000000000..4cb8a2e89 --- /dev/null +++ b/packages/console/src/containers/AppContent/TenantNotificationContainer/TenantEnvMigrationModal/index.tsx @@ -0,0 +1,136 @@ +import { Theme, TenantTag } from '@logto/schemas'; +import { useCallback } from 'react'; +import Confetti from 'react-confetti'; +import { Trans, useTranslation } from 'react-i18next'; +import ReactModal from 'react-modal'; + +import CongratsDark from '@/assets/images/congrats-dark.svg'; +import Congrats from '@/assets/images/congrats.svg'; +import Fireworks from '@/assets/images/tenant-modal-fireworks.svg'; +import { contactEmailLink } from '@/consts'; +import Button from '@/ds-components/Button'; +import DangerousRaw from '@/ds-components/DangerousRaw'; +import ModalLayout from '@/ds-components/ModalLayout'; +import useConfigs from '@/hooks/use-configs'; +import useTheme from '@/hooks/use-theme'; +import * as modalStyles from '@/scss/modal.module.scss'; + +import DevelopmentTenantFeatures from './DevelopmentTenantFeatures'; +import DevelopmentTenantMigrationHint from './DevelopmentTenantMigrationHint'; +import * as styles from './index.module.scss'; + +const isFreeTenantWithDevelopmentOrProductionEnvTag = ( + originalTenantTag: TenantTag, + isPaidTenant: boolean +) => !isPaidTenant && [TenantTag.Development, TenantTag.Production].includes(originalTenantTag); + +/** + * This modal is used to notify the user that the tenant env has been migrated. + * + * In our new development tenant feature, the old tenant env `staging` is deprecated, + * we migrated the existing paid tenant's env to 'production', and the existing free 'staging' tenant's env to 'production'. + * + * For the original free tenant: + * - If the tenant env is 'dev' or 'production', we will show the development tenant's available feature list. + * - If the tenant env is 'staging', we will show the migration hint to notify the user that the tenant env has been migrated to 'production'. + * + * For the original paid tenant, we will show the migration hint to notify the user that the tenant env has been migrated to 'production'. + */ +function TenantEnvMigrationModal() { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const theme = useTheme(); + const HeaderIcon = theme === Theme.Light ? Congrats : CongratsDark; + const { configs, updateConfigs } = useConfigs(); + const { developmentTenantMigrationNotification: migrationData } = configs ?? {}; + + const onClose = useCallback(async () => { + if (!migrationData) { + return; + } + await updateConfigs({ + developmentTenantMigrationNotification: { + ...migrationData, + readAt: Date.now(), + }, + }); + }, [migrationData, updateConfigs]); + + if (!migrationData || migrationData.readAt) { + return null; + } + + const { tag: originalTenantTag, isPaidTenant } = migrationData; + + const shouldDisplayDevelopmentTenantFeatureList = isFreeTenantWithDevelopmentOrProductionEnvTag( + originalTenantTag, + isPaidTenant + ); + + return ( + +
+ } + title={ + + }}> + {t('tenants.notification.allow_pro_features_title')} + + + } + subtitle="tenants.notification.allow_pro_features_description" + footer={ + <> + {!shouldDisplayDevelopmentTenantFeatureList && ( + +
+ +
+ ); +} + +export default TenantEnvMigrationModal; diff --git a/packages/console/src/containers/AppContent/TenantNotificationContainer/index.tsx b/packages/console/src/containers/AppContent/TenantNotificationContainer/index.tsx index be8af4a83..9457a6546 100644 --- a/packages/console/src/containers/AppContent/TenantNotificationContainer/index.tsx +++ b/packages/console/src/containers/AppContent/TenantNotificationContainer/index.tsx @@ -2,23 +2,29 @@ import { useContext } from 'react'; import MauExceededModal from '@/components/MauExceededModal'; import PaymentOverdueModal from '@/components/PaymentOverdueModal'; -import { isCloud } from '@/consts/env'; +import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import { TenantsContext } from '@/contexts/TenantsProvider'; +import TenantEnvMigrationModal from './TenantEnvMigrationModal'; + function TenantNotificationContainer() { const { currentTenant, isDevTenant } = useContext(TenantsContext); const isTenantSuspended = currentTenant?.isSuspended; - // Todo @xiaoyijun remove isDevFeaturesEnabled when the dev tenant feature is ready - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (!isCloud || isTenantSuspended || isDevTenant) { + if (!isCloud || isTenantSuspended) { return null; } return ( <> - - + {/* Note: we won't check the MAU limit and payment for dev tenants */} + {!isDevTenant && ( + <> + + + + )} + {isDevFeaturesEnabled && } ); } diff --git a/packages/console/src/contexts/TenantsProvider.tsx b/packages/console/src/contexts/TenantsProvider.tsx index 289a941ff..041185df4 100644 --- a/packages/console/src/contexts/TenantsProvider.tsx +++ b/packages/console/src/contexts/TenantsProvider.tsx @@ -1,5 +1,4 @@ -import { defaultManagementApi, defaultTenantId } from '@logto/schemas'; -import { TenantTag } from '@logto/schemas/models'; +import { defaultManagementApi, defaultTenantId, TenantTag } from '@logto/schemas'; import { conditionalArray, noop } from '@silverhand/essentials'; import dayjs from 'dayjs'; import type { ReactNode } from 'react'; diff --git a/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/TenantEnvironment/index.tsx b/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/TenantEnvironment/index.tsx index 36774ddc2..88328a30b 100644 --- a/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/TenantEnvironment/index.tsx +++ b/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/TenantEnvironment/index.tsx @@ -1,4 +1,4 @@ -import { TenantTag } from '@logto/schemas/lib/models/tenants.js'; +import { TenantTag } from '@logto/schemas'; import { Trans, useTranslation } from 'react-i18next'; import TenantEnvTag from '@/components/TenantEnvTag'; diff --git a/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/index.tsx b/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/index.tsx index eec7f1a24..bbbe522cf 100644 --- a/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/index.tsx +++ b/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/index.tsx @@ -1,5 +1,5 @@ import type { AdminConsoleKey } from '@logto/phrases'; -import { TenantTag } from '@logto/schemas/models'; +import { TenantTag } from '@logto/schemas'; import { Controller, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; diff --git a/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx b/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx index 8705ad82c..84ae665e7 100644 --- a/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx +++ b/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx @@ -1,4 +1,4 @@ -import { TenantTag } from '@logto/schemas/models'; +import { TenantTag } from '@logto/schemas'; import classNames from 'classnames'; import { useContext, useEffect, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; diff --git a/packages/integration-tests/src/api/tenant.ts b/packages/integration-tests/src/api/tenant.ts index 832ea345a..973864024 100644 --- a/packages/integration-tests/src/api/tenant.ts +++ b/packages/integration-tests/src/api/tenant.ts @@ -1,4 +1,5 @@ -import type { TenantInfo, TenantTag } from '@logto/schemas/models'; +import { type TenantTag } from '@logto/schemas'; +import type { TenantInfo } from '@logto/schemas/models'; import { cloudApi } from './api.js'; diff --git a/packages/schemas/src/models/tenants.ts b/packages/schemas/src/models/tenants.ts index 7b7dbe5d8..ee1c37133 100644 --- a/packages/schemas/src/models/tenants.ts +++ b/packages/schemas/src/models/tenants.ts @@ -2,11 +2,7 @@ import { createModel } from '@withtyped/server/model'; import type { InferModelType } from '@withtyped/server/model'; import { z } from 'zod'; -export enum TenantTag { - Development = 'development', - Staging = 'staging', - Production = 'production', -} +import { TenantTag } from '../types/tenant.js'; export const Tenants = createModel( /* Sql */ ` diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index 874c59748..b2b5fe9f9 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -22,3 +22,4 @@ export * from './sentinel.js'; export * from './mfa.js'; export * from './organization.js'; export * from './sso-connector.js'; +export * from './tenant.js'; diff --git a/packages/schemas/src/types/logto-config.ts b/packages/schemas/src/types/logto-config.ts index 3b79c1387..8779eca78 100644 --- a/packages/schemas/src/types/logto-config.ts +++ b/packages/schemas/src/types/logto-config.ts @@ -1,6 +1,8 @@ import type { ZodType } from 'zod'; import { z } from 'zod'; +import { TenantTag } from './tenant.js'; + /** * Logto OIDC signing key types, used mainly in REST API routes. */ @@ -49,6 +51,13 @@ export const logtoOidcConfigGuard: Readonly<{ export const adminConsoleDataGuard = z.object({ signInExperienceCustomized: z.boolean(), organizationCreated: z.boolean(), + developmentTenantMigrationNotification: z + .object({ + isPaidTenant: z.boolean(), + tag: z.nativeEnum(TenantTag), + readAt: z.number().optional(), + }) + .optional(), }); export type AdminConsoleData = z.infer; diff --git a/packages/schemas/src/types/tenant.ts b/packages/schemas/src/types/tenant.ts new file mode 100644 index 000000000..4f8dc2fc9 --- /dev/null +++ b/packages/schemas/src/types/tenant.ts @@ -0,0 +1,5 @@ +export enum TenantTag { + Development = 'development', + Staging = 'staging', + Production = 'production', +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f4fce8d3..31586446d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3052,6 +3052,9 @@ importers: react-color: specifier: ^2.19.3 version: 2.19.3(react@18.2.0) + react-confetti: + specifier: ^6.1.0 + version: 6.1.0(react@18.2.0) react-dnd: specifier: ^16.0.0 version: 16.0.0(@types/node@18.11.18)(@types/react@18.0.31)(react@18.2.0) @@ -18105,6 +18108,16 @@ packages: tinycolor2: 1.6.0 dev: true + /react-confetti@6.1.0(react@18.2.0): + resolution: {integrity: sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==} + engines: {node: '>=10.18'} + peerDependencies: + react: ^16.3.0 || ^17.0.1 || ^18.0.0 + dependencies: + react: 18.2.0 + tween-functions: 1.2.0 + dev: true + /react-device-detect@2.2.2(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-zSN1gIAztUekp5qUT/ybHwQ9fmOqVT1psxpSlTn1pe0CO+fnJHKRLOWWac5nKxOxvOpD/w84hk1I+EydrJp7SA==} peerDependencies: @@ -20339,6 +20352,10 @@ packages: engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} dev: false + /tween-functions@1.2.0: + resolution: {integrity: sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==} + dev: true + /type-check@0.3.2: resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} engines: {node: '>= 0.8.0'}