0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor(console): extract the onboarding app from the admin console (#3340)

This commit is contained in:
Xiao Yijun 2023-03-10 09:57:30 +08:00 committed by GitHub
parent 19f5efc107
commit fb6a65bd46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 359 additions and 235 deletions

View file

@ -18,10 +18,10 @@ import initI18n from '@/i18n/init';
import { adminTenantEndpoint } from './consts';
import { isCloud } from './consts/cloud';
import ErrorBoundary from './containers/ErrorBoundary';
import TenantAppContainer from './containers/TenantAppContainer';
import AppConfirmModalProvider from './contexts/AppConfirmModalProvider';
import AppEndpointsProvider from './contexts/AppEndpointsProvider';
import TenantsProvider, { TenantsContext } from './contexts/TenantsProvider';
import Main from './pages/Main';
void initI18n();
@ -39,6 +39,7 @@ const Content = () => {
meApi.indicator
)
);
const scopes = [
UserScope.Email,
UserScope.Identities,
@ -63,7 +64,7 @@ const Content = () => {
{!isCloud || isSettle ? (
<AppEndpointsProvider>
<AppConfirmModalProvider>
<Main />
<TenantAppContainer />
</AppConfirmModalProvider>
</AppEndpointsProvider>
) : (
@ -81,4 +82,5 @@ const App = () => {
</TenantsProvider>
);
};
export default App;

View file

@ -1,6 +0,0 @@
.onBoarding {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}

View file

@ -1,49 +0,0 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
import { OnboardingPage } from '@/cloud/types';
import { getOnboardPagePathname } from '@/cloud/utils';
import NotFound from '@/pages/NotFound';
import About from '../About';
import Congrats from '../Congrats';
import SignInExperience from '../SignInExperience';
import Welcome from '../Welcome';
import * as styles from './index.module.scss';
const welcomePathname = getOnboardPagePathname(OnboardingPage.Welcome);
const Onboarding = () => {
const {
data: { questionnaire },
isLoaded,
} = useUserOnboardingData();
if (!isLoaded) {
return null;
}
return (
<div className={styles.onBoarding}>
<Routes>
<Route index element={<Navigate replace to={welcomePathname} />} />
<Route path={OnboardingPage.Welcome} element={<Welcome />} />
<Route
path={OnboardingPage.AboutUser}
element={questionnaire ? <About /> : <Navigate replace to={welcomePathname} />}
/>
<Route
path={OnboardingPage.SignInExperience}
element={questionnaire ? <SignInExperience /> : <Navigate replace to={welcomePathname} />}
/>
<Route
path={OnboardingPage.Congrats}
element={questionnaire ? <Congrats /> : <Navigate replace to={welcomePathname} />}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</div>
);
};
export default Onboarding;

View file

@ -1 +0,0 @@
$questionnaire-content-width: 858px;

View file

@ -1,79 +1,3 @@
import type { SignInIdentifier } from '@logto/schemas';
import { z } from 'zod';
export enum CloudRoute {
Callback = 'callback',
Onboarding = 'onboarding',
}
export enum OnboardingPage {
Welcome = 'welcome',
AboutUser = 'about-user',
SignInExperience = 'sign-in-experience',
Congrats = 'congrats',
}
export enum Project {
Personal = 'personal',
Company = 'company',
}
export enum DeploymentType {
OpenSource = 'open-source',
Cloud = 'cloud',
}
export enum Title {
Developer = 'developer',
TeamLead = 'team-lead',
Ceo = 'ceo',
Cto = 'cto',
Product = 'product',
Others = 'others',
}
export enum CompanySize {
Scale1 = '1',
Scale2 = '1-49',
Scale3 = '50-199',
Scale4 = '200-999',
Scale5 = '1000+',
}
export enum Reason {
Adoption = 'adoption',
Replacement = 'replacement',
Evaluation = 'evaluation',
Experimentation = 'experimentation',
Aesthetics = 'aesthetics',
Others = 'others',
}
export const questionnaireGuard = z.object({
project: z.nativeEnum(Project),
deploymentType: z.nativeEnum(DeploymentType),
titles: z.array(z.nativeEnum(Title)).optional(),
companyName: z.string().optional(),
companySize: z.nativeEnum(CompanySize).optional(),
reasons: z.array(z.nativeEnum(Reason)).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>;
export enum Authentication {
Password = 'password',
VerificationCode = 'verificationCode',
}
export type OnboardingSieConfig = {
color: string;
identifier: SignInIdentifier;
authentications: Authentication[];
};

View file

@ -1,4 +0,0 @@
import type { OnboardingPage } from './types';
import { CloudRoute } from './types';
export const getOnboardPagePathname = (page: OnboardingPage) => `/${CloudRoute.Onboarding}/${page}`;

View file

@ -2,19 +2,20 @@ import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import Logo from '@/assets/images/logo.svg';
import EarlyBirdGift from '@/cloud/components/EarlyBirdGift';
import Spacer from '@/components/Spacer';
import { isCloud } from '@/consts/cloud';
import EarlyBirdGift from '@/onboarding/components/EarlyBirdGift';
import GetStartedProgress from '@/pages/GetStarted/components/GetStartedProgress';
import UserInfo from '../UserInfo';
import * as styles from './index.module.scss';
type Props = {
isLogoOnly?: boolean;
className?: string;
};
const Topbar = ({ className }: Props) => {
const Topbar = ({ isLogoOnly = false, className }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
@ -23,9 +24,13 @@ const Topbar = ({ className }: Props) => {
<div className={styles.line} />
<div className={styles.text}>{t('title')}</div>
<Spacer />
<GetStartedProgress />
{isCloud && <EarlyBirdGift />}
<UserInfo />
{!isLogoOnly && (
<>
<GetStartedProgress />
{isCloud && <EarlyBirdGift />}
<UserInfo />
</>
)}
</div>
);
};

View file

@ -4,10 +4,6 @@ import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet, useHref, useLocation, useNavigate } from 'react-router-dom';
import Broadcast from '@/cloud/components/Broadcast';
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
import { OnboardingPage } from '@/cloud/types';
import { getOnboardPagePathname } from '@/cloud/utils';
import AppError from '@/components/AppError';
import AppLoading from '@/components/AppLoading';
import SessionExpired from '@/components/SessionExpired';
@ -15,6 +11,7 @@ import { isCloud } from '@/consts/cloud';
import useConfigs from '@/hooks/use-configs';
import useScroll from '@/hooks/use-scroll';
import useUserPreferences from '@/hooks/use-user-preferences';
import Broadcast from '@/onboarding/components/Broadcast';
import { getPath } from '../ConsoleContent/Sidebar';
import { useSidebarMenuItems } from '../ConsoleContent/Sidebar/hook';
@ -27,16 +24,8 @@ const AppContent = () => {
const href = useHref('/callback');
const { isLoading: isPreferencesLoading } = useUserPreferences();
const { isLoading: isConfigsLoading } = useConfigs();
const {
data: { isOnboardingDone },
isLoading: isOnboardingDataLoading,
isLoaded: isOnboardingDataLoaded,
} = useUserOnboardingData();
const isLoading =
isPreferencesLoading || isConfigsLoading || (isCloud && isOnboardingDataLoading);
const isOnboarding = isCloud && isOnboardingDataLoaded && !isOnboardingDone;
const isLoading = isPreferencesLoading || isConfigsLoading;
const location = useLocation();
const navigate = useNavigate();
@ -54,13 +43,9 @@ const AppContent = () => {
useEffect(() => {
// Navigate to the first menu item after configs are loaded.
if (!isLoading && location.pathname === '/') {
navigate(
isOnboarding
? getOnboardPagePathname(OnboardingPage.Welcome)
: getPath(firstItem?.title ?? '')
);
navigate(getPath(firstItem?.title ?? ''), { replace: true });
}
}, [firstItem?.title, isOnboardingDone, isLoading, isOnboarding, location.pathname, navigate]);
}, [firstItem?.title, isLoading, location.pathname, navigate]);
if (error) {
if (error instanceof LogtoClientError) {

View file

@ -0,0 +1,30 @@
import { useContext } from 'react';
import AppLoading from '@/components/AppLoading';
import { isCloud } from '@/consts/cloud';
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
import OnboardingApp from '@/onboarding/App';
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
import ConsoleApp from '@/pages/Main';
const TenantAppContainer = () => {
const { userEndpoint } = useContext(AppEndpointsContext);
const {
data: { isOnboardingDone },
isLoaded,
} = useUserOnboardingData();
if (!userEndpoint || (isCloud && !isLoaded)) {
return <AppLoading />;
}
const isOnboarding = isCloud && !isOnboardingDone;
if (isOnboarding) {
return <OnboardingApp />;
}
return <ConsoleApp />;
};
export default TenantAppContainer;

View file

@ -3,6 +3,7 @@ import { defaultManagementApi } from '@logto/schemas';
import { conditional, noop } from '@silverhand/essentials';
import type { ReactNode } from 'react';
import { useCallback, useMemo, createContext, useState } from 'react';
import type { NavigateOptions } from 'react-router-dom';
import { isCloud } from '@/consts/cloud';
import { getUserTenantId } from '@/consts/tenants';
@ -17,7 +18,7 @@ export type Tenants = {
setTenants: (tenants: TenantInfo[]) => void;
setIsSettle: (isSettle: boolean) => void;
currentTenantId: string;
navigate: (tenantId: string) => void;
navigate: (tenantId: string, options?: NavigateOptions) => void;
};
const { tenantId, indicator } = defaultManagementApi.resource;
@ -37,8 +38,13 @@ const TenantsProvider = ({ children }: Props) => {
const [isSettle, setIsSettle] = useState(false);
const [currentTenantId, setCurrentTenantId] = useState(getUserTenantId());
const navigate = useCallback((tenantId: string) => {
window.history.pushState({}, '', '/' + tenantId);
const navigate = useCallback((tenantId: string, options?: NavigateOptions) => {
if (options?.replace) {
window.history.replaceState(options.state ?? {}, '', '/' + tenantId);
return;
}
window.history.pushState(options?.state ?? {}, '', '/' + tenantId);
setCurrentTenantId(tenantId);
}, []);

View file

@ -0,0 +1,6 @@
@use '@/scss/underscore' as _;
.app {
position: absolute;
inset: 0;
}

View file

@ -0,0 +1,70 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { SWRConfig } from 'swr';
import AppLoading from '@/components/AppLoading';
import Toast from '@/components/Toast';
import { getBasename } from '@/consts';
import AppBoundary from '@/containers/AppBoundary';
import useSwrOptions from '@/hooks/use-swr-options';
import NotFound from '@/pages/NotFound';
import * as styles from './App.module.scss';
import AppContent from './containers/AppContent';
import useUserOnboardingData from './hooks/use-user-onboarding-data';
import About from './pages/About';
import Congrats from './pages/Congrats';
import SignInExperience from './pages/SignInExperience';
import Welcome from './pages/Welcome';
import { OnboardingPage, OnboardingRoute } from './types';
import { getOnboardingPage } from './utils';
const welcomePathname = getOnboardingPage(OnboardingPage.Welcome);
const App = () => {
const swrOptions = useSwrOptions();
const {
data: { questionnaire },
isLoaded,
} = useUserOnboardingData();
if (!isLoaded) {
return <AppLoading />;
}
return (
<BrowserRouter basename={getBasename()}>
<div className={styles.app}>
<SWRConfig value={swrOptions}>
<AppBoundary>
<Toast />
<Routes>
<Route index element={<Navigate replace to={welcomePathname} />} />
<Route path={`/${OnboardingRoute.Onboarding}`} element={<AppContent />}>
<Route index element={<Navigate replace to={welcomePathname} />} />
<Route path={OnboardingPage.Welcome} element={<Welcome />} />
<Route
path={OnboardingPage.AboutUser}
element={questionnaire ? <About /> : <Navigate replace to={welcomePathname} />}
/>
<Route
path={OnboardingPage.SignInExperience}
element={
questionnaire ? <SignInExperience /> : <Navigate replace to={welcomePathname} />
}
/>
<Route
path={OnboardingPage.Congrats}
element={questionnaire ? <Congrats /> : <Navigate replace to={welcomePathname} />}
/>
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</AppBoundary>
</SWRConfig>
</div>
</BrowserRouter>
);
};
export default App;

View file

@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { logtoBlogLink } from '@/cloud/constants';
import TextLink from '@/components/TextLink';
import { logtoBlogLink } from '@/onboarding/constants';
import * as styles from './index.module.scss';

View file

@ -4,8 +4,8 @@ import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import Calendar from '@/assets/images/calendar.svg';
import { reservationLink } from '@/cloud/constants';
import Button from '@/components/Button';
import { reservationLink } from '@/onboarding/constants';
import { buildUrl } from '@/utils/url';
import * as styles from './index.module.scss';

View file

@ -0,0 +1,13 @@
.app {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.content {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}

View file

@ -0,0 +1,16 @@
import { Outlet } from 'react-router-dom';
import Topbar from '@/containers/AppContent/components/Topbar';
import * as styles from './index.module.scss';
const AppContent = () => (
<div className={styles.app}>
<Topbar isLogoOnly />
<div className={styles.content}>
<Outlet />
</div>
</div>
);
export default AppContent;

View file

@ -5,18 +5,18 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Case from '@/assets/images/case.svg';
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
import * as pageLayout from '@/cloud/scss/layout.module.scss';
import Button from '@/components/Button';
import FormField from '@/components/FormField';
import OverlayScrollbar from '@/components/OverlayScrollbar';
import TextInput from '@/components/TextInput';
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
import ActionBar from '../../components/ActionBar';
import { CardSelector, MultiCardSelector } from '../../components/CardSelector';
import type { Questionnaire } from '../../types';
import { OnboardingPage } from '../../types';
import { getOnboardPagePathname } from '../../utils';
import { getOnboardingPage } from '../../utils';
import * as styles from './index.module.scss';
import { titleOptions, companySizeOptions, reasonOptions } from './options';
@ -43,11 +43,11 @@ const About = () => {
const onNext = async () => {
await onSubmit();
navigate(getOnboardPagePathname(OnboardingPage.SignInExperience));
navigate(getOnboardingPage(OnboardingPage.SignInExperience), { replace: true });
};
const onBack = async () => {
navigate(getOnboardPagePathname(OnboardingPage.Welcome));
navigate(getOnboardingPage(OnboardingPage.Welcome), { replace: true });
};
return (

View file

@ -1,4 +1,4 @@
import type { Option as SelectorOption } from '@/cloud/components/CardSelector';
import type { Option as SelectorOption } from '@/onboarding/components/CardSelector';
import { CompanySize, Reason, Title } from '../../types';

View file

@ -1,18 +1,19 @@
import { AppearanceMode } from '@logto/schemas';
import classNames from 'classnames';
import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import CalendarOutline from '@/assets/images/calendar-outline.svg';
import CongratsImageDark from '@/assets/images/congrats-dark.svg';
import CongratsImageLight from '@/assets/images/congrats.svg';
import Reservation from '@/cloud/components/Reservation';
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
import * as pageLayout from '@/cloud/scss/layout.module.scss';
import Button from '@/components/Button';
import Divider from '@/components/Divider';
import OverlayScrollbar from '@/components/OverlayScrollbar';
import { TenantsContext } from '@/contexts/TenantsProvider';
import { useTheme } from '@/hooks/use-theme';
import Reservation from '@/onboarding/components/Reservation';
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
import * as styles from './index.module.scss';
@ -21,12 +22,12 @@ const Congrats = () => {
const theme = useTheme();
const CongratsImage = theme === AppearanceMode.LightMode ? CongratsImageLight : CongratsImageDark;
const { update } = useUserOnboardingData();
const { navigate, currentTenantId } = useContext(TenantsContext);
const navigate = useNavigate();
const enterAdminConsole = async () => {
await update({ isOnboardingDone: true });
navigate('/');
const enterAdminConsole = () => {
void update({ isOnboardingDone: true });
// Note: navigate to the admin console page directly instead of using the router
navigate(currentTenantId, { replace: true });
};
return (

View file

@ -8,19 +8,19 @@ import useSWR from 'swr';
import Bulb from '@/assets/images/bulb.svg';
import Tools from '@/assets/images/tools.svg';
import ActionBar from '@/cloud/components/ActionBar';
import { CardSelector, MultiCardSelector } from '@/cloud/components/CardSelector';
import { defaultOnboardingSieConfig } from '@/cloud/constants';
import * as pageLayout from '@/cloud/scss/layout.module.scss';
import type { OnboardingSieConfig } from '@/cloud/types';
import { OnboardingPage } from '@/cloud/types';
import { getOnboardPagePathname } from '@/cloud/utils';
import Button from '@/components/Button';
import ColorPicker from '@/components/ColorPicker';
import FormField from '@/components/FormField';
import OverlayScrollbar from '@/components/OverlayScrollbar';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import ActionBar from '@/onboarding/components/ActionBar';
import { CardSelector, MultiCardSelector } from '@/onboarding/components/CardSelector';
import { defaultOnboardingSieConfig } from '@/onboarding/constants';
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
import { OnboardingPage } from '@/onboarding/types';
import type { OnboardingSieConfig } from '@/onboarding/types';
import { getOnboardingPage } from '@/onboarding/utils';
import Preview from './components/Preview';
import * as styles from './index.module.scss';
@ -78,7 +78,7 @@ const SignInExperience = () => {
});
const handleBack = () => {
navigate(getOnboardPagePathname(OnboardingPage.AboutUser));
navigate(getOnboardingPage(OnboardingPage.AboutUser), { replace: true });
};
const handleSave = async () => {
@ -88,7 +88,7 @@ const SignInExperience = () => {
const handleNext = async () => {
await onSubmit();
navigate(getOnboardPagePathname(OnboardingPage.Congrats));
navigate(getOnboardingPage(OnboardingPage.Congrats), { replace: true });
};
return (

View file

@ -5,8 +5,8 @@ import Keyboard from '@/assets/images/keyboard.svg';
import Label from '@/assets/images/label.svg';
import Lock from '@/assets/images/lock.svg';
import Mobile from '@/assets/images/mobile.svg';
import type { Option as SelectorOption } from '@/cloud/components/CardSelector';
import { Authentication } from '@/cloud/types';
import type { Option as SelectorOption } from '@/onboarding/components/CardSelector';
import { Authentication } from '@/onboarding/types';
export const identifierOptions: SelectorOption[] = [
{

View file

@ -1,8 +1,8 @@
import type { SignInExperience } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import type { OnboardingSieConfig } from '@/cloud/types';
import { Authentication } from '@/cloud/types';
import type { OnboardingSieConfig } from '@/onboarding/types';
import { Authentication } from '@/onboarding/types';
const signInExperienceToOnboardSieConfig = (
signInExperience: SignInExperience

View file

@ -5,17 +5,17 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Congrats from '@/assets/images/congrats.svg';
import ActionBar from '@/cloud/components/ActionBar';
import { CardSelector } from '@/cloud/components/CardSelector';
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
import * as pageLayout from '@/cloud/scss/layout.module.scss';
import Button from '@/components/Button';
import FormField from '@/components/FormField';
import OverlayScrollbar from '@/components/OverlayScrollbar';
import ActionBar from '@/onboarding/components/ActionBar';
import { CardSelector } from '@/onboarding/components/CardSelector';
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
import type { Questionnaire } from '../../types';
import { OnboardingPage } from '../../types';
import { getOnboardPagePathname } from '../../utils';
import { getOnboardingPage } from '../../utils';
import * as styles from './index.module.scss';
import { deploymentTypeOptions, projectOptions } from './options';
@ -45,7 +45,7 @@ const Welcome = () => {
const onNext = async () => {
await onSubmit();
navigate(getOnboardPagePathname(OnboardingPage.AboutUser));
navigate(getOnboardingPage(OnboardingPage.AboutUser), { replace: true });
};
return (

View file

@ -2,7 +2,7 @@ import Building from '@/assets/images/building.svg';
import Cloud from '@/assets/images/cloud.svg';
import Database from '@/assets/images/database.svg';
import Pizza from '@/assets/images/pizza.svg';
import type { Option as SelectorOption } from '@/cloud/components/CardSelector';
import type { Option as SelectorOption } from '@/onboarding/components/CardSelector';
import { DeploymentType, Project } from '../../types';

View file

@ -1,5 +1,4 @@
@use '@/scss/underscore' as _;
@use './cloud-page-size.scss' as size;
.page {
height: 100%;
@ -16,7 +15,7 @@
.content {
margin: 0 auto;
max-width: size.$questionnaire-content-width;
max-width: 858px;
border-radius: 16px;
padding: _.unit(12);
background-color: var(--color-layer-1);

View file

@ -0,0 +1,78 @@
import type { SignInIdentifier } from '@logto/schemas';
import { z } from 'zod';
export enum OnboardingRoute {
Onboarding = 'onboarding',
}
export enum OnboardingPage {
Welcome = 'welcome',
AboutUser = 'about-user',
SignInExperience = 'sign-in-experience',
Congrats = 'congrats',
}
export enum Project {
Personal = 'personal',
Company = 'company',
}
export enum DeploymentType {
OpenSource = 'open-source',
Cloud = 'cloud',
}
export enum Title {
Developer = 'developer',
TeamLead = 'team-lead',
Ceo = 'ceo',
Cto = 'cto',
Product = 'product',
Others = 'others',
}
export enum CompanySize {
Scale1 = '1',
Scale2 = '1-49',
Scale3 = '50-199',
Scale4 = '200-999',
Scale5 = '1000+',
}
export enum Reason {
Adoption = 'adoption',
Replacement = 'replacement',
Evaluation = 'evaluation',
Experimentation = 'experimentation',
Aesthetics = 'aesthetics',
Others = 'others',
}
export const questionnaireGuard = z.object({
project: z.nativeEnum(Project),
deploymentType: z.nativeEnum(DeploymentType),
titles: z.array(z.nativeEnum(Title)).optional(),
companyName: z.string().optional(),
companySize: z.nativeEnum(CompanySize).optional(),
reasons: z.array(z.nativeEnum(Reason)).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>;
export enum Authentication {
Password = 'password',
VerificationCode = 'verificationCode',
}
export type OnboardingSieConfig = {
color: string;
identifier: SignInIdentifier;
authentications: Authentication[];
};

View file

@ -0,0 +1,7 @@
import { joinPath } from '@silverhand/essentials';
import type { OnboardingPage } from './types';
import { OnboardingRoute } from './types';
export const getOnboardingPage = (page: OnboardingPage) =>
joinPath(OnboardingRoute.Onboarding, page);

View file

@ -1,18 +1,11 @@
import { useContext } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { SWRConfig } from 'swr';
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
import Onboarding from '@/cloud/pages/Onboarding';
import { CloudRoute } from '@/cloud/types';
import AppLoading from '@/components/AppLoading';
import Toast from '@/components/Toast';
import { getBasename } from '@/consts';
import { isCloud } from '@/consts/cloud';
import AppBoundary from '@/containers/AppBoundary';
import AppContent from '@/containers/AppContent';
import ConsoleContent from '@/containers/ConsoleContent';
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
import useSwrOptions from '@/hooks/use-swr-options';
import Callback from '@/pages/Callback';
import Welcome from '@/pages/Welcome';
@ -21,17 +14,6 @@ import HandleSocialCallback from '../Profile/containers/HandleSocialCallback';
const Main = () => {
const swrOptions = useSwrOptions();
const { userEndpoint } = useContext(AppEndpointsContext);
const {
data: { isOnboardingDone },
isLoaded,
} = useUserOnboardingData();
if (!userEndpoint || (isCloud && !isLoaded)) {
return <AppLoading />;
}
const isOnboarding = isCloud && !isOnboardingDone;
return (
<BrowserRouter basename={getBasename()}>
@ -43,14 +25,7 @@ const Main = () => {
<Route path="welcome" element={<Welcome />} />
<Route path="handle-social" element={<HandleSocialCallback />} />
<Route element={<AppContent />}>
{isOnboarding ? (
<Route>
<Route index element={<Navigate replace to={`/${CloudRoute.Onboarding}`} />} />
<Route path={`/${CloudRoute.Onboarding}/*`} element={<Onboarding />} />
</Route>
) : (
<Route path="/*" element={<ConsoleContent />} />
)}
<Route path="/*" element={<ConsoleContent />} />
</Route>
</Routes>
</AppBoundary>

View file

@ -1,6 +1,11 @@
@use '@/scss/underscore' as _;
.container {
height: 100%;
padding: _.unit(6);
}
.content {
height: 100%;
color: var(--color-text);
text-align: center;

View file

@ -13,10 +13,12 @@ const NotFound = () => {
const theme = useTheme();
return (
<Card className={styles.container}>
{theme === AppearanceMode.LightMode ? <NotFoundImage /> : <NotFoundDarkImage />}
<div className={styles.message}>{t('errors.page_not_found')}</div>
</Card>
<div className={styles.container}>
<Card className={styles.content}>
{theme === AppearanceMode.LightMode ? <NotFoundImage /> : <NotFoundDarkImage />}
<div className={styles.message}>{t('errors.page_not_found')}</div>
</Card>
</div>
);
};

View file

@ -68,6 +68,66 @@ describe('smoke testing for cloud', () => {
expect(page.url()).toBe(new URL(`/${tenantId ?? ''}/onboarding/welcome`, logtoCloudUrl).href);
});
it('can complete the onboarding welcome process and enter the user survey page', async () => {
// Select the project type option
await expect(page).toClick('div[role=radio]:has(input[name=project][value=personal])');
// Select the deployment type option
await expect(page).toClick(
'div[role=radio]:has(input[name=deploymentType][value=open-source])'
);
// Click the next button
await expect(page).toClick('div[class$=actions] button:first-child');
// Wait for the next page to load
await expect(page).toMatchElement('div[class$=content] div[class$=title]', {
text: 'A little bit about you',
});
expect(new URL(page.url()).pathname.endsWith('/onboarding/about-user')).toBeTruthy();
});
it('can complete the onboarding user survey process and enter the sie page', async () => {
// Select the reason option
await expect(page).toClick('div[class$=option]', { text: 'Others' });
// Click the next button
await expect(page).toClick('div[class$=actions] button:first-child');
// Wait for the next page to load
await expect(page).toMatchElement('div[class$=config] div[class$=title]', {
text: 'Lets first customize your experience with ease',
});
expect(new URL(page.url()).pathname.endsWith('/onboarding/sign-in-experience')).toBeTruthy();
});
it('can complete the sie configuration process and enter the congrats page', async () => {
// Click the finish button
await expect(page).toClick('div[class$=continueActions] button:last-child');
// Wait for the next page to load
await expect(page).toMatchElement('div[class$=content] div[class$=title]', {
text: 'Great news! You are qualified to earn Logto Cloud early credit!',
});
expect(new URL(page.url()).pathname.endsWith('/onboarding/congrats')).toBeTruthy();
});
it('can complete the onboarding process and enter the admin console', async () => {
// Click the enter ac button
await expect(page).toClick('div[class$=content] >button');
// Wait for the admin console to load
const mainContent = await page.waitForSelector('div[class$=main]:has(div[class$=title])');
await expect(mainContent).toMatchElement('div[class$=title]', {
text: 'How do you want to get started with Logto?',
});
expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy();
});
it('can sign out of admin console', async () => {
await expect(page).toClick('div[class$=topbar] > div[class$=container]');