mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(schemas,core,console): skip onboarding if user has pending invitations (#5547)
This commit is contained in:
parent
618c38f134
commit
a387bf2868
7 changed files with 132 additions and 100 deletions
|
@ -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 { useCallback, useContext, useMemo } from 'react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
@ -6,12 +12,15 @@ import { isCloud } from '@/consts/env';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import useCurrentUser from '@/hooks/use-current-user';
|
import useCurrentUser from '@/hooks/use-current-user';
|
||||||
|
|
||||||
import type { UserOnboardingData } from '../types';
|
const useUserOnboardingData = (): {
|
||||||
import { Project, userOnboardingDataGuard } from '../types';
|
data: UserOnboardingData;
|
||||||
|
error: unknown;
|
||||||
const userOnboardingDataKey = 'onboarding';
|
isLoading: boolean;
|
||||||
|
isLoaded: boolean;
|
||||||
const useUserOnboardingData = () => {
|
isOnboarding: boolean;
|
||||||
|
isBusinessPlan: boolean;
|
||||||
|
update: (data: Partial<UserOnboardingData>) => Promise<void>;
|
||||||
|
} => {
|
||||||
const { customData, error, isLoading, isLoaded, updateCustomData } = useCurrentUser();
|
const { customData, error, isLoading, isLoaded, updateCustomData } = useCurrentUser();
|
||||||
const { currentTenantId } = useContext(TenantsContext);
|
const { currentTenantId } = useContext(TenantsContext);
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { withAppInsights } from '@logto/app-insights/react';
|
import { withAppInsights } from '@logto/app-insights/react';
|
||||||
|
import { type Questionnaire, Project } from '@logto/schemas';
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
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 { trySubmitSafe } from '@/utils/form';
|
||||||
|
|
||||||
import { CardSelector, MultiCardSelector } from '../../components/CardSelector';
|
import { CardSelector, MultiCardSelector } from '../../components/CardSelector';
|
||||||
import type { Questionnaire } from '../../types';
|
import { OnboardingPage } from '../../types';
|
||||||
import { OnboardingPage, Project } from '../../types';
|
|
||||||
import { getOnboardingPage } from '../../utils';
|
import { getOnboardingPage } from '../../utils';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { AdditionalFeatures, Project, Stage } from '@logto/schemas';
|
||||||
|
|
||||||
import Building from '@/assets/icons/building.svg';
|
import Building from '@/assets/icons/building.svg';
|
||||||
import Pizza from '@/assets/icons/pizza.svg';
|
import Pizza from '@/assets/icons/pizza.svg';
|
||||||
import type {
|
import type {
|
||||||
|
@ -5,8 +7,6 @@ import type {
|
||||||
MultiCardSelectorOption,
|
MultiCardSelectorOption,
|
||||||
} from '@/onboarding/components/CardSelector';
|
} from '@/onboarding/components/CardSelector';
|
||||||
|
|
||||||
import { Project, Stage, AdditionalFeatures } from '../../types';
|
|
||||||
|
|
||||||
export const projectOptions: CardSelectorOption[] = [
|
export const projectOptions: CardSelectorOption[] = [
|
||||||
{
|
{
|
||||||
icon: <Pizza />,
|
icon: <Pizza />,
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export enum OnboardingRoute {
|
export enum OnboardingRoute {
|
||||||
Onboarding = 'onboarding',
|
Onboarding = 'onboarding',
|
||||||
}
|
}
|
||||||
|
@ -12,85 +10,3 @@ export enum OnboardingPage {
|
||||||
/** @deprecated Remove this to shorten onboarding process. */
|
/** @deprecated Remove this to shorten onboarding process. */
|
||||||
Congrats = 'congrats',
|
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<typeof questionnaireGuard>;
|
|
||||||
|
|
||||||
export const userOnboardingDataGuard = z.object({
|
|
||||||
questionnaire: questionnaireGuard.optional(),
|
|
||||||
isOnboardingDone: z.boolean().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type UserOnboardingData = z.infer<typeof userOnboardingDataGuard>;
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Component, CoreEvent, getEventName } from '@logto/app-insights/custom-event';
|
import { Component, CoreEvent, getEventName } from '@logto/app-insights/custom-event';
|
||||||
import { appInsights } from '@logto/app-insights/node';
|
import { appInsights } from '@logto/app-insights/node';
|
||||||
import type { User } from '@logto/schemas';
|
import type { User, UserOnboardingData } from '@logto/schemas';
|
||||||
import {
|
import {
|
||||||
AdminTenantRole,
|
AdminTenantRole,
|
||||||
SignInMode,
|
SignInMode,
|
||||||
|
@ -13,6 +13,8 @@ import {
|
||||||
getTenantRole,
|
getTenantRole,
|
||||||
TenantRole,
|
TenantRole,
|
||||||
defaultManagementApiAdminName,
|
defaultManagementApiAdminName,
|
||||||
|
OrganizationInvitationStatus,
|
||||||
|
userOnboardingDataKey,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { generateStandardId } from '@logto/shared';
|
import { generateStandardId } from '@logto/shared';
|
||||||
import { conditional, conditionalArray, trySafe } from '@silverhand/essentials';
|
import { conditional, conditionalArray, trySafe } from '@silverhand/essentials';
|
||||||
|
@ -90,8 +92,11 @@ async function handleSubmitRegister(
|
||||||
log?: LogEntry
|
log?: LogEntry
|
||||||
) {
|
) {
|
||||||
const { provider, libraries, queries, cloudConnection, id: tenantId } = tenantContext;
|
const { provider, libraries, queries, cloudConnection, id: tenantId } = tenantContext;
|
||||||
const { hasActiveUsers } = queries.users;
|
const {
|
||||||
const { updateDefaultSignInExperience } = queries.signInExperiences;
|
users: { hasActiveUsers },
|
||||||
|
signInExperiences: { updateDefaultSignInExperience },
|
||||||
|
organizations,
|
||||||
|
} = queries;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
users: { generateUserId, insertUser },
|
users: { generateUserId, insertUser },
|
||||||
|
@ -110,6 +115,15 @@ async function handleSubmitRegister(
|
||||||
const isCreatingFirstAdminUser =
|
const isCreatingFirstAdminUser =
|
||||||
isInAdminTenant && String(client_id) === adminConsoleApplicationId && !(await hasActiveUsers());
|
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(
|
await insertUser(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
|
@ -119,6 +133,16 @@ async function handleSubmitRegister(
|
||||||
mfaVerifications,
|
mfaVerifications,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
...conditional(
|
||||||
|
// Skip onboarding flow if the new user has pending Cloud invitations
|
||||||
|
hasPendingInvitations && {
|
||||||
|
customData: {
|
||||||
|
[userOnboardingDataKey]: {
|
||||||
|
isOnboardingDone: true,
|
||||||
|
} satisfies UserOnboardingData,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
...conditional(
|
...conditional(
|
||||||
mfaSkipped && {
|
mfaSkipped && {
|
||||||
logtoConfig: {
|
logtoConfig: {
|
||||||
|
@ -142,8 +166,8 @@ async function handleSubmitRegister(
|
||||||
// Create tenant organization and assign the admin user to it.
|
// 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.
|
// This is only for Cloud integration tests and data alignment, OSS still uses the legacy Management API user role.
|
||||||
const organizationId = getTenantOrganizationId(defaultTenantId);
|
const organizationId = getTenantOrganizationId(defaultTenantId);
|
||||||
await queries.organizations.relations.users.insert([organizationId, id]);
|
await organizations.relations.users.insert([organizationId, id]);
|
||||||
await queries.organizations.relations.rolesUsers.insert([
|
await organizations.relations.rolesUsers.insert([
|
||||||
organizationId,
|
organizationId,
|
||||||
getTenantRole(TenantRole.Admin).id,
|
getTenantRole(TenantRole.Admin).id,
|
||||||
id,
|
id,
|
||||||
|
|
|
@ -27,3 +27,4 @@ export * from './tenant-organization.js';
|
||||||
export * from './mapi-proxy.js';
|
export * from './mapi-proxy.js';
|
||||||
export * from './consent.js';
|
export * from './consent.js';
|
||||||
export * from './jwt-customizer.js';
|
export * from './jwt-customizer.js';
|
||||||
|
export * from './onboarding.js';
|
||||||
|
|
82
packages/schemas/src/types/onboarding.ts
Normal file
82
packages/schemas/src/types/onboarding.ts
Normal file
|
@ -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<typeof questionnaireGuard>;
|
||||||
|
|
||||||
|
export const userOnboardingDataGuard = z.object({
|
||||||
|
questionnaire: questionnaireGuard.optional(),
|
||||||
|
isOnboardingDone: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UserOnboardingData = z.infer<typeof userOnboardingDataGuard>;
|
Loading…
Reference in a new issue