From a387bf28682ed50e63b9a82fb13a4bbae1a8c8ee Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Mon, 25 Mar 2024 17:45:06 +0800 Subject: [PATCH] refactor(schemas,core,console): skip onboarding if user has pending invitations (#5547) --- .../hooks/use-user-onboarding-data.ts | 23 +++-- .../src/onboarding/pages/Welcome/index.tsx | 4 +- .../src/onboarding/pages/Welcome/options.tsx | 4 +- packages/console/src/onboarding/types.ts | 84 ------------------- .../interaction/actions/submit-interaction.ts | 34 ++++++-- packages/schemas/src/types/index.ts | 1 + packages/schemas/src/types/onboarding.ts | 82 ++++++++++++++++++ 7 files changed, 132 insertions(+), 100 deletions(-) create mode 100644 packages/schemas/src/types/onboarding.ts diff --git a/packages/console/src/onboarding/hooks/use-user-onboarding-data.ts b/packages/console/src/onboarding/hooks/use-user-onboarding-data.ts index 0d4d19798..7e576a99e 100644 --- a/packages/console/src/onboarding/hooks/use-user-onboarding-data.ts +++ b/packages/console/src/onboarding/hooks/use-user-onboarding-data.ts @@ -1,4 +1,10 @@ -import { adminTenantId } from '@logto/schemas'; +import { + adminTenantId, + Project, + type UserOnboardingData, + userOnboardingDataGuard, + userOnboardingDataKey, +} from '@logto/schemas'; import { useCallback, useContext, useMemo } from 'react'; import { z } from 'zod'; @@ -6,12 +12,15 @@ import { isCloud } from '@/consts/env'; import { TenantsContext } from '@/contexts/TenantsProvider'; import useCurrentUser from '@/hooks/use-current-user'; -import type { UserOnboardingData } from '../types'; -import { Project, userOnboardingDataGuard } from '../types'; - -const userOnboardingDataKey = 'onboarding'; - -const useUserOnboardingData = () => { +const useUserOnboardingData = (): { + data: UserOnboardingData; + error: unknown; + isLoading: boolean; + isLoaded: boolean; + isOnboarding: boolean; + isBusinessPlan: boolean; + update: (data: Partial) => Promise; +} => { const { customData, error, isLoading, isLoaded, updateCustomData } = useCurrentUser(); const { currentTenantId } = useContext(TenantsContext); diff --git a/packages/console/src/onboarding/pages/Welcome/index.tsx b/packages/console/src/onboarding/pages/Welcome/index.tsx index 46771bbfa..205f743e5 100644 --- a/packages/console/src/onboarding/pages/Welcome/index.tsx +++ b/packages/console/src/onboarding/pages/Welcome/index.tsx @@ -1,4 +1,5 @@ import { withAppInsights } from '@logto/app-insights/react'; +import { type Questionnaire, Project } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import { useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -17,8 +18,7 @@ import * as pageLayout from '@/onboarding/scss/layout.module.scss'; import { trySubmitSafe } from '@/utils/form'; import { CardSelector, MultiCardSelector } from '../../components/CardSelector'; -import type { Questionnaire } from '../../types'; -import { OnboardingPage, Project } from '../../types'; +import { OnboardingPage } from '../../types'; import { getOnboardingPage } from '../../utils'; import * as styles from './index.module.scss'; diff --git a/packages/console/src/onboarding/pages/Welcome/options.tsx b/packages/console/src/onboarding/pages/Welcome/options.tsx index 3029548b9..f2b1a8ad9 100644 --- a/packages/console/src/onboarding/pages/Welcome/options.tsx +++ b/packages/console/src/onboarding/pages/Welcome/options.tsx @@ -1,3 +1,5 @@ +import { AdditionalFeatures, Project, Stage } from '@logto/schemas'; + import Building from '@/assets/icons/building.svg'; import Pizza from '@/assets/icons/pizza.svg'; import type { @@ -5,8 +7,6 @@ import type { MultiCardSelectorOption, } from '@/onboarding/components/CardSelector'; -import { Project, Stage, AdditionalFeatures } from '../../types'; - export const projectOptions: CardSelectorOption[] = [ { icon: , diff --git a/packages/console/src/onboarding/types.ts b/packages/console/src/onboarding/types.ts index 2efe3e83a..f1ff687da 100644 --- a/packages/console/src/onboarding/types.ts +++ b/packages/console/src/onboarding/types.ts @@ -1,5 +1,3 @@ -import { z } from 'zod'; - export enum OnboardingRoute { Onboarding = 'onboarding', } @@ -12,85 +10,3 @@ export enum OnboardingPage { /** @deprecated Remove this to shorten onboarding process. */ Congrats = 'congrats', } - -export enum Project { - Personal = 'personal', - Company = 'company', -} - -/** @deprecated Open-source options was for cloud preview use, no longer needed. Use default `Cloud` value for placeholder. */ -enum DeploymentType { - OpenSource = 'open-source', - Cloud = 'cloud', -} - -/** @deprecated */ -// eslint-disable-next-line import/no-unused-modules -export enum Title { - Developer = 'developer', - TeamLead = 'team-lead', - Ceo = 'ceo', - Cto = 'cto', - Product = 'product', - Others = 'others', -} - -/** @deprecated */ -// eslint-disable-next-line import/no-unused-modules -export enum CompanySize { - Scale1 = '1', - Scale2 = '2-49', - Scale3 = '50-199', - Scale4 = '200-999', - Scale5 = '1000+', -} - -/** @deprecated */ -// eslint-disable-next-line import/no-unused-modules -export enum Reason { - Passwordless = 'passwordless', - Efficiency = 'efficiency', - AccessControl = 'access-control', - MultiTenancy = 'multi-tenancy', - Enterprise = 'enterprise', - Others = 'others', -} - -export enum Stage { - NewProduct = 'new-product', - ExistingProduct = 'existing-product', - TargetEnterpriseReady = 'target-enterprise-ready', -} - -export enum AdditionalFeatures { - CustomizeUiAndFlow = 'customize-ui-and-flow', - Compliance = 'compliance', - ExportUserDataFromLogto = 'export-user-data-from-logto', - BudgetControl = 'budget-control', - BringOwnAuth = 'bring-own-auth', - Others = 'others', -} - -const questionnaireGuard = z.object({ - project: z.nativeEnum(Project).optional(), - /** @deprecated Open-source options was for cloud preview use, no longer needed. Use default `Cloud` value for placeholder. */ - deploymentType: z.nativeEnum(DeploymentType).optional().default(DeploymentType.Cloud), - /** @deprecated */ - titles: z.array(z.nativeEnum(Title)).optional(), - companyName: z.string().optional(), - /** @deprecated */ - companySize: z.nativeEnum(CompanySize).optional(), - /** @deprecated */ - reasons: z.array(z.nativeEnum(Reason)).optional(), - stage: z.nativeEnum(Stage).optional(), - additionalFeatures: z.array(z.nativeEnum(AdditionalFeatures)).optional(), -}); - -export type Questionnaire = z.infer; - -export const userOnboardingDataGuard = z.object({ - questionnaire: questionnaireGuard.optional(), - isOnboardingDone: z.boolean().optional(), -}); - -export type UserOnboardingData = z.infer; diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 5ca004ab3..7f4583c56 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -1,6 +1,6 @@ import { Component, CoreEvent, getEventName } from '@logto/app-insights/custom-event'; import { appInsights } from '@logto/app-insights/node'; -import type { User } from '@logto/schemas'; +import type { User, UserOnboardingData } from '@logto/schemas'; import { AdminTenantRole, SignInMode, @@ -13,6 +13,8 @@ import { getTenantRole, TenantRole, defaultManagementApiAdminName, + OrganizationInvitationStatus, + userOnboardingDataKey, } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { conditional, conditionalArray, trySafe } from '@silverhand/essentials'; @@ -90,8 +92,11 @@ async function handleSubmitRegister( log?: LogEntry ) { const { provider, libraries, queries, cloudConnection, id: tenantId } = tenantContext; - const { hasActiveUsers } = queries.users; - const { updateDefaultSignInExperience } = queries.signInExperiences; + const { + users: { hasActiveUsers }, + signInExperiences: { updateDefaultSignInExperience }, + organizations, + } = queries; const { users: { generateUserId, insertUser }, @@ -110,6 +115,15 @@ async function handleSubmitRegister( const isCreatingFirstAdminUser = isInAdminTenant && String(client_id) === adminConsoleApplicationId && !(await hasActiveUsers()); + // If it's Logto Cloud, Check if the new user has any pending invitations, if yes, skip onboarding flow. + const invitations = + isCloud && userProfile.primaryEmail + ? await organizations.invitations.findEntities({ invitee: userProfile.primaryEmail }) + : []; + const hasPendingInvitations = invitations.some( + (invitation) => invitation.status === OrganizationInvitationStatus.Pending + ); + await insertUser( { id, @@ -119,6 +133,16 @@ async function handleSubmitRegister( mfaVerifications, } ), + ...conditional( + // Skip onboarding flow if the new user has pending Cloud invitations + hasPendingInvitations && { + customData: { + [userOnboardingDataKey]: { + isOnboardingDone: true, + } satisfies UserOnboardingData, + }, + } + ), ...conditional( mfaSkipped && { logtoConfig: { @@ -142,8 +166,8 @@ async function handleSubmitRegister( // Create tenant organization and assign the admin user to it. // This is only for Cloud integration tests and data alignment, OSS still uses the legacy Management API user role. const organizationId = getTenantOrganizationId(defaultTenantId); - await queries.organizations.relations.users.insert([organizationId, id]); - await queries.organizations.relations.rolesUsers.insert([ + await organizations.relations.users.insert([organizationId, id]); + await organizations.relations.rolesUsers.insert([ organizationId, getTenantRole(TenantRole.Admin).id, id, diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index a128c78eb..54d51ad51 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -27,3 +27,4 @@ export * from './tenant-organization.js'; export * from './mapi-proxy.js'; export * from './consent.js'; export * from './jwt-customizer.js'; +export * from './onboarding.js'; diff --git a/packages/schemas/src/types/onboarding.ts b/packages/schemas/src/types/onboarding.ts new file mode 100644 index 000000000..d1b57ce08 --- /dev/null +++ b/packages/schemas/src/types/onboarding.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; + +export const userOnboardingDataKey = 'onboarding'; + +export enum Project { + Personal = 'personal', + Company = 'company', +} + +/** @deprecated Open-source options was for cloud preview use, no longer needed. Use default `Cloud` value for placeholder. */ +enum DeploymentType { + OpenSource = 'open-source', + Cloud = 'cloud', +} + +/** @deprecated */ +export enum Title { + Developer = 'developer', + TeamLead = 'team-lead', + Ceo = 'ceo', + Cto = 'cto', + Product = 'product', + Others = 'others', +} + +/** @deprecated */ +export enum CompanySize { + Scale1 = '1', + Scale2 = '2-49', + Scale3 = '50-199', + Scale4 = '200-999', + Scale5 = '1000+', +} + +/** @deprecated */ +export enum Reason { + Passwordless = 'passwordless', + Efficiency = 'efficiency', + AccessControl = 'access-control', + MultiTenancy = 'multi-tenancy', + Enterprise = 'enterprise', + Others = 'others', +} + +export enum Stage { + NewProduct = 'new-product', + ExistingProduct = 'existing-product', + TargetEnterpriseReady = 'target-enterprise-ready', +} + +export enum AdditionalFeatures { + CustomizeUiAndFlow = 'customize-ui-and-flow', + Compliance = 'compliance', + ExportUserDataFromLogto = 'export-user-data-from-logto', + BudgetControl = 'budget-control', + BringOwnAuth = 'bring-own-auth', + Others = 'others', +} + +const questionnaireGuard = z.object({ + project: z.nativeEnum(Project).optional(), + /** @deprecated Open-source options was for cloud preview use, no longer needed. Use default `Cloud` value for placeholder. */ + deploymentType: z.nativeEnum(DeploymentType).optional().default(DeploymentType.Cloud), + /** @deprecated */ + titles: z.array(z.nativeEnum(Title)).optional(), + companyName: z.string().optional(), + /** @deprecated */ + companySize: z.nativeEnum(CompanySize).optional(), + /** @deprecated */ + reasons: z.array(z.nativeEnum(Reason)).optional(), + stage: z.nativeEnum(Stage).optional(), + additionalFeatures: z.array(z.nativeEnum(AdditionalFeatures)).optional(), +}); + +export type Questionnaire = z.infer; + +export const userOnboardingDataGuard = z.object({ + questionnaire: questionnaireGuard.optional(), + isOnboardingDone: z.boolean().optional(), +}); + +export type UserOnboardingData = z.infer;