0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): add tenant env migration modal (#4859)

This commit is contained in:
Xiao Yijun 2023-11-13 19:23:22 +08:00 committed by GitHub
parent 29efb5bf66
commit e676f0c6eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 347 additions and 21 deletions

View file

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

View file

@ -0,0 +1,18 @@
<svg width="824" height="417" viewBox="0 0 824 417" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M783.928 55.3338C783.928 55.1494 784.078 55 784.262 55H790.27C790.454 55 790.604 55.1494 790.604 55.3338V65.9073L799.76 60.6207C799.92 60.5285 800.124 60.5832 800.216 60.7428L803.22 65.9459C803.312 66.1055 803.258 66.3097 803.098 66.4018L793.941 71.6886L803.099 76.9757C803.258 77.0679 803.313 77.272 803.221 77.4317L800.217 82.6347C800.125 82.7944 799.92 82.8491 799.761 82.7569L790.604 77.47V88.0439C790.604 88.2282 790.454 88.3777 790.27 88.3777H784.262C784.078 88.3777 783.928 88.2282 783.928 88.0439V77.4696L774.77 82.7569C774.611 82.8491 774.407 82.7944 774.314 82.6347L771.31 77.4317C771.218 77.272 771.273 77.0679 771.433 76.9757L780.59 71.6886L771.433 66.4018C771.273 66.3097 771.219 66.1055 771.311 65.9459L774.315 60.7428C774.407 60.5832 774.611 60.5285 774.771 60.6207L783.928 65.9077V55.3338Z" fill="#AF9EFF"/>
<path d="M75.957 199.757L77.4702 194.307C78.306 191.296 76.5431 188.178 73.5328 187.343V187.343C70.5224 186.507 68.7595 183.389 69.5953 180.379V180.379C70.431 177.368 68.6681 174.251 65.6578 173.415V173.415C62.6474 172.579 60.8846 169.461 61.7203 166.451L63.2335 161" stroke="#FFD5FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M652.766 23C659.117 23 664.266 17.8513 664.266 11.5C664.266 5.14873 659.117 0 652.766 0C646.414 0 641.266 5.14873 641.266 11.5C641.266 17.8513 646.414 23 652.766 23ZM652.766 17.2499C655.941 17.2499 658.516 14.6755 658.516 11.4999C658.516 8.32426 655.941 5.7499 652.766 5.7499C649.59 5.7499 647.016 8.32426 647.016 11.4999C647.016 14.6755 649.59 17.2499 652.766 17.2499Z" fill="#FFB95A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M815.266 190C819.684 190 823.266 186.418 823.266 182C823.266 177.582 819.684 174 815.266 174C810.847 174 807.266 177.582 807.266 182C807.266 186.418 810.847 190 815.266 190ZM815.266 186C817.475 186 819.266 184.209 819.266 182C819.266 179.791 817.475 178 815.266 178C813.056 178 811.266 179.791 811.266 182C811.266 184.209 813.056 186 815.266 186Z" fill="#FFB95A"/>
<path d="M20.0117 272.242L18.885 266.698C18.2628 263.637 15.2764 261.659 12.2148 262.281V262.281C9.15323 262.904 6.16688 260.926 5.54463 257.865L4.41795 252.321" stroke="#C4C7C7" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M159.324 49.0327L159.169 43.378C159.084 40.255 156.482 37.7927 153.359 37.8783V37.8783C150.236 37.9639 147.635 35.5016 147.55 32.3786V32.3786C147.464 29.2556 144.863 26.7933 141.74 26.8789V26.8789C138.617 26.9645 136.016 24.5022 135.93 21.3792L135.775 15.7245" stroke="#CABEFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M492.121 56.6885L490.756 51.1987C490.003 48.1668 491.85 45.0979 494.882 44.3442V44.3442C497.913 43.5905 499.76 40.5217 499.007 37.4898L497.642 32" stroke="#E6DEFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M715.297 414.09L719.282 410.075C721.483 407.858 725.064 407.844 727.282 410.045V410.045C729.499 412.246 733.081 412.233 735.282 410.015L739.267 406" stroke="#E6DEFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M629.269 83.5C629.269 89.5 629.269 102 629.269 104C629.269 106.5 630.769 107.5 629.269 112.5C627.769 117.5 631.269 118.5 630.769 120.5C630.269 122.5 627.268 124.5 628.768 130.5" stroke="#D1CADE" stroke-linecap="round"/>
<path d="M628.466 84.5C628.851 83.8333 629.813 83.8333 630.198 84.5L633.832 90.7942C634.526 91.9968 633.658 93.5 632.27 93.5L630.832 93.1407C630.469 93.0499 630.086 93.092 629.751 93.2594C629.439 93.4153 629.084 93.4629 628.742 93.3945L627.602 93.1665C627.051 93.0563 626.477 93.0418 625.921 93.1213C624.856 93.2734 624.058 92.1351 624.595 91.2034L628.466 84.5Z" fill="#DEA0E1"/>
<path d="M649.27 61.7692C649.27 73.2398 640.315 87.9231 629.27 87.9231C618.224 87.9231 609.27 73.2398 609.27 61.7692C609.27 50.2987 618.224 41 629.27 41C640.315 41 649.27 50.2987 649.27 61.7692Z" fill="#F4B4FF"/>
<path d="M635.85 47.8057C636.341 48.2623 636.891 48.8377 637.464 49.5163M642.683 68.2827C644.19 62.6291 642.427 57.239 640.092 53.2449C639.843 52.8187 639.587 52.4083 639.328 52.0152C639.179 51.7882 639.028 51.5669 638.877 51.3516" stroke="#FEF2FF" stroke-linecap="round"/>
<path d="M592.768 143.5C596.272 138 597.27 136.5 594.769 133.5C593.45 131.916 591.769 130.5 593.269 125.5C594.769 120.5 591.77 121.5 593.269 117C594.27 114.5 593.269 109.9 593.269 109.5" stroke="#D1CADE" stroke-linecap="round"/>
<path d="M592.698 105.99C592.952 105.55 593.587 105.55 593.841 105.99L596.239 110.143C596.697 110.936 596.124 111.928 595.208 111.928L594.26 111.691C594.02 111.631 593.767 111.659 593.546 111.769C593.34 111.872 593.106 111.904 592.88 111.859L592.128 111.708C591.765 111.635 591.386 111.626 591.019 111.678C590.316 111.779 589.789 111.028 590.144 110.413L592.698 105.99Z" fill="#71C171"/>
<path d="M607.27 90.5385C607.27 98.5678 601.002 108.846 593.27 108.846C585.538 108.846 579.27 98.5678 579.27 90.5385C579.27 82.5091 585.538 76 593.27 76C601.002 76 607.27 82.5091 607.27 90.5385Z" fill="#83DA85"/>
<path d="M598.582 82C598.888 82.3065 599.234 82.7166 599.582 83.2167M601.082 94C602.167 90.9609 601.829 88.177 601.026 86C600.89 85.6304 600.74 85.2784 600.582 84.9455" stroke="#F7F4FF" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

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

View file

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

View file

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

View file

@ -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<keyof SubscriptionPlanQuota> = [
'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 (
<PlanQuotaList
hasIcon
quota={proPlan.quota}
featuredQuotaKeys={featuredQuotaKeys}
comingSoonQuotaKeys={comingSoonQuotaKeys}
/>
);
}
export default DevelopmentTenantFeatures;

View file

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

View file

@ -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 (
<div>
<div className={styles.title}>
<DynamicT forKey="tenants.notification.impact_title" />
</div>
<div className={styles.hint}>
{type === 'freeStagingTenant' && (
<Trans components={{ strong: <span className={styles.strong} /> }}>
{t('tenants.notification.staging_env_hint')}
</Trans>
)}
{type === 'paidTenant' && (
<>
<Trans components={{ strong: <span className={styles.strong} /> }}>
{t('tenants.notification.paid_tenant_hint_1')}
</Trans>
<ol>
<li>
<DynamicT forKey="tenants.notification.paid_tenant_hint_2" />
</li>
<li>
<DynamicT forKey="tenants.notification.paid_tenant_hint_3" />
</li>
</ol>
<DynamicT forKey="tenants.notification.paid_tenant_hint_4" />
</>
)}
</div>
</div>
);
}
export default DevelopmentTenantMigrationHint;

View file

@ -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%);
}
}

View file

@ -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 (
<ReactModal
isOpen
shouldCloseOnEsc
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={onClose}
>
<div className={styles.modalLayoutWrapper}>
<ModalLayout
isWordWrapEnabled
headerIcon={<HeaderIcon className={styles.headerIcon} />}
title={
<DangerousRaw>
<Trans components={{ span: <span className={styles.highlight} /> }}>
{t('tenants.notification.allow_pro_features_title')}
</Trans>
</DangerousRaw>
}
subtitle="tenants.notification.allow_pro_features_description"
footer={
<>
{!shouldDisplayDevelopmentTenantFeatureList && (
<a href={contactEmailLink} className={styles.linkButton} rel="noopener">
<Button title="general.contact_us_action" size="large" />
</a>
)}
<Button
title={
shouldDisplayDevelopmentTenantFeatureList
? 'tenants.notification.explore_all_features'
: 'general.got_it'
}
size="large"
type="primary"
onClick={onClose}
/>
</>
}
onClose={onClose}
>
<div className={styles.content}>
{isPaidTenant && <DevelopmentTenantMigrationHint type="paidTenant" />}
{!isPaidTenant &&
[TenantTag.Development, TenantTag.Production].includes(originalTenantTag) && (
<DevelopmentTenantFeatures />
)}
{!isPaidTenant && originalTenantTag === TenantTag.Staging && (
<DevelopmentTenantMigrationHint type="freeStagingTenant" />
)}
</div>
</ModalLayout>
<div className={styles.fireworks}>
<Fireworks
className={
!isPaidTenant && originalTenantTag === TenantTag.Staging
? styles.stagingFireworksImage
: styles.fireworksImage
}
/>
</div>
</div>
<Confetti recycle={false} />
</ReactModal>
);
}
export default TenantEnvMigrationModal;

View file

@ -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 (
<>
<MauExceededModal />
<PaymentOverdueModal />
{/* Note: we won't check the MAU limit and payment for dev tenants */}
{!isDevTenant && (
<>
<MauExceededModal />
<PaymentOverdueModal />
</>
)}
{isDevFeaturesEnabled && <TenantEnvMigrationModal />}
</>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 */ `

View file

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

View file

@ -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<typeof adminConsoleDataGuard>;

View file

@ -0,0 +1,5 @@
export enum TenantTag {
Development = 'development',
Staging = 'staging',
Production = 'production',
}

View file

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