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

feat(console): support multi-region

This commit is contained in:
Gao Sun 2024-05-28 16:09:08 +08:00
parent b9b96d2aa7
commit 9d1349ee76
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
28 changed files with 388 additions and 240 deletions

View file

@ -22,8 +22,6 @@ import AppLoading from '@/components/AppLoading';
import { isCloud } from '@/consts/env';
import { cloudApi, getManagementApi, meApi } from '@/consts/resources';
import { ConsoleRoutes } from '@/containers/ConsoleRoutes';
import { OnboardingRoutes } from '@/onboarding';
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
import { GlobalScripts } from './components/Conversion';
import { adminTenantEndpoint, mainTitle } from './consts';
@ -139,7 +137,6 @@ function Providers() {
function AppRoutes() {
const { tenantEndpoint } = useContext(AppDataContext);
const { isLoaded } = useCurrentUser();
const { isOnboarding } = useUserOnboardingData();
const { isAuthenticated } = useLogto();
// Authenticated user should load onboarding data before rendering the app.
@ -152,7 +149,7 @@ function AppRoutes() {
return (
<>
<GlobalScripts />
{isAuthenticated && isOnboarding ? <OnboardingRoutes /> : <ConsoleRoutes />}
<ConsoleRoutes />
</>
);
}

View file

@ -2,6 +2,7 @@ import { Route, Routes } from 'react-router-dom';
import ProtectedRoutes from '@/containers/ProtectedRoutes';
import { GlobalAnonymousRoute, GlobalRoute } from '@/contexts/TenantsProvider';
import { OnboardingApp } from '@/onboarding';
import AcceptInvitation from '@/pages/AcceptInvitation';
import Callback from '@/pages/Callback';
import CheckoutSuccessCallback from '@/pages/CheckoutSuccessCallback';
@ -21,12 +22,13 @@ function AppRoutes() {
<Route path={GlobalAnonymousRoute.SocialDemoCallback} element={<SocialDemoCallback />} />
<Route element={<ProtectedRoutes />}>
<Route
path={`${GlobalAnonymousRoute.AcceptInvitation}/:invitationId`}
path={`${GlobalRoute.AcceptInvitation}/:invitationId`}
element={<AcceptInvitation />}
/>
<Route path={GlobalAnonymousRoute.Profile + '/*'} element={<Profile />} />
<Route path={GlobalAnonymousRoute.HandleSocial} element={<HandleSocialCallback />} />
<Route path={GlobalRoute.Profile + '/*'} element={<Profile />} />
<Route path={GlobalRoute.HandleSocial} element={<HandleSocialCallback />} />
<Route path={GlobalRoute.CheckoutSuccessCallback} element={<CheckoutSuccessCallback />} />
<Route path={GlobalRoute.Onboarding + '/*'} element={<OnboardingApp />} />
<Route index element={<Main />} />
</Route>
</Routes>

View file

@ -1,23 +0,0 @@
import { useContext, useEffect } from 'react';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import AppLoading from '@/components/AppLoading';
import { TenantsContext } from '@/contexts/TenantsProvider';
export default function AutoCreateTenant() {
const api = useCloudApi();
const { prependTenant, tenants } = useContext(TenantsContext);
useEffect(() => {
const createTenant = async () => {
const newTenant = await api.post('/api/tenants', { body: {} });
prependTenant(newTenant);
};
if (tenants.length === 0) {
void createTenant();
}
}, [api, prependTenant, tenants.length]);
return <AppLoading />;
}

View file

@ -1,13 +1,14 @@
import { OrganizationInvitationStatus } from '@logto/schemas';
import { Navigate } from 'react-router-dom';
import AppLoading from '@/components/AppLoading';
import { isCloud } from '@/consts/env';
import { GlobalRoute } from '@/contexts/TenantsProvider';
import useCurrentUser from '@/hooks/use-current-user';
import useUserDefaultTenantId from '@/hooks/use-user-default-tenant-id';
import useUserInvitations from '@/hooks/use-user-invitations';
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
import AutoCreateTenant from './AutoCreateTenant';
import InvitationList from './InvitationList';
import Redirect from './Redirect';
import TenantLandingPage from './TenantLandingPage';
@ -27,9 +28,9 @@ export default function Main() {
return <Redirect toTenantId={defaultTenantId} />;
}
// A new user has just signed up and has no tenant, needs to create a new tenant.
// A new user has just signed up, redirect them to the onboarding flow.
if (isOnboarding) {
return <AutoCreateTenant />;
return <Navigate to={GlobalRoute.Onboarding} />;
}
// If user has pending invitations (onboarding will be skipped), show the invitation list and allow them to quick join.

View file

@ -6,16 +6,6 @@
margin-top: _.unit(0.5);
}
.regionOptions {
font: var(--font-label-2);
.comingSoon {
margin-left: _.unit(1);
font: var(--font-body-2);
color: var(--color-text-secondary);
}
}
.envTagRadioGroup {
display: grid;
grid-template-columns: repeat(2, 1fr);

View file

@ -1,13 +1,14 @@
import { Theme, TenantTag } from '@logto/schemas';
import { useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import CreateTenantHeaderIconDark from '@/assets/icons/create-tenant-header-dark.svg';
import CreateTenantHeaderIcon from '@/assets/icons/create-tenant-header.svg';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type TenantResponse } from '@/cloud/types/router';
import Region, { RegionName } from '@/components/Region';
import { isDevFeaturesEnabled } from '@/consts/env';
import Button from '@/ds-components/Button';
import DangerousRaw from '@/ds-components/DangerousRaw';
import FormField from '@/ds-components/FormField';
@ -30,11 +31,10 @@ type Props = {
const availableTags = [TenantTag.Development, TenantTag.Production];
function CreateTenantModal({ isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [tenantData, setTenantData] = useState<CreateTenantData>();
const theme = useTheme();
const defaultValues = { tag: TenantTag.Development };
const defaultValues = { tag: TenantTag.Development, regionName: RegionName.EU };
const methods = useForm<CreateTenantData>({
defaultValues,
});
@ -49,9 +49,9 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
const cloudApi = useCloudApi();
const createTenant = async (data: CreateTenantData) => {
const { name, tag } = data;
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag } });
const createTenant = async ({ name, tag, regionName }: CreateTenantData) => {
// @ts-expect-error will be removed once we bump the `@logto/cloud` version
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag, regionName } });
onClose(newTenant);
};
@ -108,28 +108,31 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
/>
</FormField>
<FormField title="tenants.settings.tenant_region">
<RadioGroup type="small" value="eu" name="region">
<Radio
title={
<DangerousRaw>
<span className={styles.regionOptions}>🇪🇺 EU</span>
</DangerousRaw>
}
value="eu"
/>
<Radio
isDisabled
title={
<DangerousRaw>
<span className={styles.regionOptions}>
🇺🇸 US
<span className={styles.comingSoon}>{`(${t('general.coming_soon')})`}</span>
</span>
</DangerousRaw>
}
value="us"
/>
</RadioGroup>
<Controller
control={control}
name="regionName"
rules={{ required: true }}
render={({ field: { onChange, value, name } }) => (
<RadioGroup type="small" name={name} value={value} onChange={onChange}>
{/* Manually maintaining the list of regions to avoid unexpected changes. We may consider using an API in the future. */}
{[RegionName.EU, RegionName.US].map((region) => (
<Radio
key={region}
title={
<DangerousRaw>
<Region
regionName={region}
isComingSoon={!isDevFeaturesEnabled && region !== RegionName.EU}
/>
</DangerousRaw>
}
value={region}
isDisabled={!isDevFeaturesEnabled && region !== RegionName.EU}
/>
))}
</RadioGroup>
)}
/>
</FormField>
<FormField title="tenants.create_modal.tenant_usage_purpose">
<Controller

View file

@ -1,3 +1,7 @@
import { type TenantModel } from '@logto/schemas';
export type CreateTenantData = Pick<TenantModel, 'name' | 'tag'>;
import { type RegionName } from '@/components/Region';
export type CreateTenantData = Pick<TenantModel, 'name' | 'tag'> & {
regionName: RegionName;
};

View file

@ -0,0 +1,11 @@
@use '@/scss/underscore' as _;
.regionText {
font: var(--font-label-2);
.comingSoon {
margin-left: _.unit(1);
font: var(--font-body-2);
color: var(--color-text-secondary);
}
}

View file

@ -0,0 +1,34 @@
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import * as styles from './index.module.scss';
// TODO: This is a copy from `@logto/cloud-models`, make a SSoT for this later
export enum RegionName {
EU = 'EU',
US = 'US',
}
const regionFlagMap = Object.freeze({
[RegionName.EU]: '🇪🇺',
[RegionName.US]: '🇺🇸',
} satisfies Record<RegionName, string>);
type Props = {
readonly regionName: RegionName;
readonly isComingSoon?: boolean;
readonly className?: string;
};
function Region({ isComingSoon = false, regionName, className }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<span className={classNames(styles.regionText, className)}>
{regionFlagMap[regionName]} {regionName}
{isComingSoon && <span className={styles.comingSoon}>{`(${t('general.coming_soon')})`}</span>}
</span>
);
}
export default Region;

View file

@ -8,7 +8,7 @@ import AppContent, { RedirectToFirstItem } from '@/containers/AppContent';
import ConsoleContent from '@/containers/ConsoleContent';
import ProtectedRoutes from '@/containers/ProtectedRoutes';
import TenantAccess from '@/containers/TenantAccess';
import { GlobalAnonymousRoute, GlobalRoute } from '@/contexts/TenantsProvider';
import { GlobalRoute } from '@/contexts/TenantsProvider';
import Toast from '@/ds-components/Toast';
import useSwrOptions from '@/hooks/use-swr-options';
import Callback from '@/pages/Callback';
@ -43,10 +43,7 @@ export function ConsoleRoutes() {
<Route path="callback" element={<Callback />} />
<Route path="welcome" element={<Welcome />} />
<Route element={<ProtectedRoutes />}>
<Route
path={dropLeadingSlash(GlobalAnonymousRoute.Profile) + '/*'}
element={<Profile />}
/>
<Route path={dropLeadingSlash(GlobalRoute.Profile) + '/*'} element={<Profile />} />
<Route element={<TenantAccess />}>
{isCloud && (
<Route

View file

@ -9,28 +9,24 @@ import { defaultTenantResponse } from '@/consts';
import { isCloud } from '@/consts/env';
/**
* The routes don't start with a tenant ID.
*
* @remarks
* It's important to keep this single source of truth for all anonymous routes
* because we need to check if the current route is anonymous or not to decide
* if the current tenant ID is available.
*
* This should be more clear once we refactor the file structure and the routes.
* The reserved routes that don't require authentication.
*/
export enum GlobalAnonymousRoute {
/** The global callback route for OpenID Connect. */
Callback = '/callback',
SocialDemoCallback = '/social-demo-callback',
AcceptInvitation = '/accept',
Profile = '/profile',
HandleSocial = '/handle-social',
}
/**
* The reserved routes that need tenant access.
* The reserved routes that require authentication. Note they may not require the user to be in a
* tenant context.
*/
export enum GlobalRoute {
CheckoutSuccessCallback = '/checkout-success-callback',
Onboarding = '/onboarding',
AcceptInvitation = '/accept',
Profile = '/profile',
HandleSocial = '/handle-social',
}
const reservedRoutes: Readonly<string[]> = Object.freeze([

View file

@ -4,7 +4,7 @@ import useSWRImmutable from 'swr/immutable';
import { adminTenantEndpoint, meApi } from '@/consts';
import { isCloud } from '@/consts/env';
import { GlobalAnonymousRoute } from '@/contexts/TenantsProvider';
import { GlobalRoute } from '@/contexts/TenantsProvider';
import useApi, { useStaticApi, type RequestError } from './use-api';
import useSwrFetcher from './use-swr-fetcher';
@ -17,8 +17,7 @@ const useUserAssetsService = () => {
const api = useApi();
const { pathname } = useLocation();
const isProfilePage =
pathname === GlobalAnonymousRoute.Profile ||
pathname.startsWith(GlobalAnonymousRoute.Profile + '/');
pathname === GlobalRoute.Profile || pathname.startsWith(GlobalRoute.Profile + '/');
const shouldUseAdminApi = isCloud && isProfilePage;
const fetcher = useSwrFetcher<UserAssetsServiceStatus>(shouldUseAdminApi ? adminApi : api);

View file

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

View file

@ -1,49 +0,0 @@
import { useRoutes, type RouteObject, Navigate } from 'react-router-dom';
import { usePlausiblePageview } from '@/hooks/use-plausible-pageview';
import Topbar from '@/onboarding/components/Topbar';
import SignInExperience from '@/onboarding/pages/SignInExperience';
import Welcome from '@/onboarding/pages/Welcome';
import { OnboardingPage, OnboardingRoute } from '@/onboarding/types';
import NotFound from '@/pages/NotFound';
import * as styles from './index.module.scss';
const routeObjects: RouteObject[] = [
{
path: OnboardingRoute.Onboarding,
children: [
{
index: true,
element: <Navigate replace to={OnboardingPage.Welcome} />,
},
{
path: OnboardingPage.Welcome,
element: <Welcome />,
},
{
path: OnboardingPage.SignInExperience,
element: <SignInExperience />,
},
],
},
{
path: '*',
element: <NotFound />,
},
];
function AppContent() {
const routes = useRoutes(routeObjects);
usePlausiblePageview(routeObjects);
return (
<div className={styles.app}>
<Topbar />
<div className={styles.content}>{routes}</div>
</div>
);
}
export default AppContent;

View file

@ -0,0 +1,36 @@
import { buildOrganizationUrn } from '@logto/core-kit';
import { getTenantOrganizationId } from '@logto/schemas';
import { appendPath } from '@silverhand/essentials';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { type StaticApiProps, useStaticApi } from '@/hooks/use-api';
/**
* A hook to get a Ky instance with the current tenant's Management API prefix URL. Note this hook
* can only be used in a route with a `:tenantId` param.
*/
const useTenantApi = (props: Omit<StaticApiProps, 'prefixUrl' | 'resourceIndicator'> = {}) => {
const { tenantId: currentTenantId } = useParams();
if (!currentTenantId) {
throw new Error(
'No tenant ID param found in the current route. This hook should be used in a route with a tenant ID param.'
);
}
const config = useMemo(
() => ({
prefixUrl: appendPath(new URL(window.location.origin), 'm', currentTenantId),
resourceIndicator: buildOrganizationUrn(getTenantOrganizationId(currentTenantId)),
}),
[currentTenantId]
);
return useStaticApi({
...props,
...config,
});
};
export default useTenantApi;

View file

@ -0,0 +1,28 @@
import type React from 'react';
import { useMemo } from 'react';
import type { SWRConfig } from 'swr';
import useSwrFetcher from '@/hooks/use-swr-fetcher';
import { shouldRetryOnError } from '@/utils/request';
import useTenantApi from './use-tenant-api';
/**
* A hook to get the SWR options for the current tenant by reading the `:tenantId` param from the
* route.
*/
const useTenantSwrOptions = (): Partial<React.ComponentProps<typeof SWRConfig>['value']> => {
const api = useTenantApi();
const fetcher = useSwrFetcher(api);
const config = useMemo(
() => ({
fetcher,
shouldRetryOnError: shouldRetryOnError({ ignore: [401, 403] }),
}),
[fetcher]
);
return config;
};
export default useTenantSwrOptions;

View file

@ -0,0 +1,27 @@
import { type UserAssetsServiceStatus } from '@logto/schemas';
import useSWRImmutable from 'swr/immutable';
import { type RequestError } from '@/hooks/use-api';
import useSwrFetcher from '@/hooks/use-swr-fetcher';
import useTenantApi from './use-tenant-api';
/**
* A hook to check if the current tenant's user assets service is ready. The tenant ID is read from
* `:tenantId` param in the route.
*/
const useTenantUserAssetsService = () => {
const api = useTenantApi();
const fetcher = useSwrFetcher<UserAssetsServiceStatus>(api);
const { data, error } = useSWRImmutable<UserAssetsServiceStatus, RequestError>(
'api/user-assets/service-status',
fetcher
);
return {
isReady: data?.status === 'ready',
isLoading: !error && !data,
};
};
export default useTenantUserAssetsService;

View file

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

View file

@ -1,28 +1,49 @@
import { Theme } from '@logto/schemas';
import { useContext, useEffect } from 'react';
import { Route, Navigate, Outlet, Routes } from 'react-router-dom';
import { SWRConfig } from 'swr';
import { Navigate, type RouteObject, useMatch, useRoutes } from 'react-router-dom';
import AppLoading from '@/components/AppLoading';
import AppBoundary from '@/containers/AppBoundary';
import ProtectedRoutes from '@/containers/ProtectedRoutes';
import TenantAccess from '@/containers/TenantAccess';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import Toast from '@/ds-components/Toast';
import useSwrOptions from '@/hooks/use-swr-options';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import { usePlausiblePageview } from '@/hooks/use-plausible-pageview';
import AppContent from './containers/AppContent';
import Topbar from './components/Topbar';
import useUserOnboardingData from './hooks/use-user-onboarding-data';
import * as styles from './index.module.scss';
import { OnboardingPage, OnboardingRoute } from './types';
import CreateTenant from './pages/CreateTenant';
import SignInExperience from './pages/SignInExperience';
import Welcome from './pages/Welcome';
import { OnboardingPage } from './types';
import { getOnboardingPage } from './utils';
const welcomePathname = getOnboardingPage(OnboardingPage.Welcome);
function Layout() {
const swrOptions = useSwrOptions();
const routeObjects: RouteObject[] = [
{
index: true,
element: <Navigate replace to={OnboardingPage.Welcome} />,
},
{
path: OnboardingPage.Welcome,
element: <Welcome />,
},
{
path: OnboardingPage.CreateTenant,
element: <CreateTenant />,
},
{
path: `:tenantId/${OnboardingPage.SignInExperience}`,
element: <SignInExperience />,
},
];
export function OnboardingApp() {
const { setThemeOverride } = useContext(AppThemeContext);
const { match, getTo } = useTenantPathname();
const matched = useMatch(welcomePathname);
const routes = useRoutes(routeObjects);
usePlausiblePageview(routeObjects);
useEffect(() => {
setThemeOverride(Theme.Light);
@ -33,37 +54,30 @@ function Layout() {
}, [setThemeOverride]);
const {
data: { questionnaire },
isLoading,
data: { questionnaire, isOnboardingDone },
} = useUserOnboardingData();
if (isLoading) {
return <AppLoading />;
}
if (isOnboardingDone) {
return <Navigate replace to="/" />;
}
// Redirect to the welcome page if the user has not started the onboarding process.
if (!questionnaire && !match(welcomePathname)) {
return <Navigate replace to={getTo(welcomePathname)} />;
if (!questionnaire && !matched) {
return <Navigate replace to={welcomePathname} />;
}
return (
<div className={styles.app}>
<SWRConfig value={swrOptions}>
<AppBoundary>
<Toast />
<Outlet />
</AppBoundary>
</SWRConfig>
<AppBoundary>
<Toast />
<Topbar />
<div className={styles.content}>{routes}</div>
</AppBoundary>
</div>
);
}
export function OnboardingRoutes() {
return (
<Routes>
<Route path="/:tenantId" element={<ProtectedRoutes />}>
<Route element={<TenantAccess />}>
<Route element={<Layout />}>
<Route index element={<Navigate replace to={OnboardingRoute.Onboarding} />} />
<Route path="*" element={<AppContent />} />
</Route>
</Route>
</Route>
</Routes>
);
}

View file

@ -0,0 +1,107 @@
import { joinPath } from '@silverhand/essentials';
import { useContext } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import ActionBar from '@/components/ActionBar';
import { type CreateTenantData } from '@/components/CreateTenantModal/types';
import Region, { RegionName } from '@/components/Region';
import { isDevFeaturesEnabled } from '@/consts/env';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import DangerousRaw from '@/ds-components/DangerousRaw';
import FormField from '@/ds-components/FormField';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import RadioGroup, { Radio } from '@/ds-components/RadioGroup';
import TextInput from '@/ds-components/TextInput';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
import { OnboardingPage, OnboardingRoute } from '@/onboarding/types';
import { trySubmitSafe } from '@/utils/form';
type CreateTenantForm = Omit<CreateTenantData, 'tag'>;
function CreateTenant() {
const methods = useForm<CreateTenantForm>({ defaultValues: { regionName: RegionName.EU } });
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
register,
} = methods;
const { navigate } = useTenantPathname();
const { prependTenant } = useContext(TenantsContext);
const cloudApi = useCloudApi();
const onCreateClick = handleSubmit(
trySubmitSafe(async ({ name, regionName }: CreateTenantForm) => {
// @ts-expect-error will be removed once we bump the `@logto/cloud` version
const newTenant = await cloudApi.post('/api/tenants', { body: { name, regionName } });
prependTenant(newTenant);
navigate(joinPath(OnboardingRoute.Onboarding, newTenant.id, OnboardingPage.SignInExperience));
})
);
return (
<div className={pageLayout.page}>
<OverlayScrollbar className={pageLayout.contentContainer}>
<div className={pageLayout.content}>
<div className={pageLayout.title}>Create your first tenant</div>
<div className={pageLayout.description}>
A tenant is an isolated environment where you can manage user identities, applications,
and all other Logto resources.
</div>
<FormProvider {...methods}>
<FormField isRequired title="tenants.settings.tenant_name">
<TextInput
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
placeholder="My project"
{...register('name', { required: true })}
error={Boolean(errors.name)}
/>
</FormField>
<FormField title="tenants.settings.tenant_region">
<Controller
control={control}
name="regionName"
rules={{ required: true }}
render={({ field: { onChange, value, name } }) => (
<RadioGroup type="small" name={name} value={value} onChange={onChange}>
{/* Manually maintaining the list of regions to avoid unexpected changes. We may consider using an API in the future. */}
{[RegionName.EU, RegionName.US].map((region) => (
<Radio
key={region}
title={
<DangerousRaw>
<Region
regionName={region}
isComingSoon={!isDevFeaturesEnabled && region !== RegionName.EU}
/>
</DangerousRaw>
}
value={region}
isDisabled={!isDevFeaturesEnabled && region !== RegionName.EU}
/>
))}
</RadioGroup>
)}
/>
</FormField>
</FormProvider>
</div>
</OverlayScrollbar>
<ActionBar step={2} totalSteps={3}>
<Button
title="general.create"
type="primary"
disabled={isSubmitting}
onClick={onCreateClick}
/>
</ActionBar>
</div>
);
}
export default CreateTenant;

View file

@ -18,11 +18,6 @@
padding: _.unit(12);
margin-right: _.unit(6);
.title {
margin-top: _.unit(6);
font: var(--font-title-1);
}
.authnSelector {
grid-template-columns: repeat(2, 1fr);
}

View file

@ -1,7 +1,7 @@
import { ConnectorType, ServiceConnector } from '@logto/connector-kit';
import { SignInIdentifier } from '@logto/schemas';
import type { SignInExperience as SignInExperienceType, ConnectorResponse } from '@logto/schemas';
import { useCallback, useEffect, useMemo, useContext } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
@ -10,23 +10,21 @@ import Tools from '@/assets/icons/tools.svg';
import ActionBar from '@/components/ActionBar';
import { GtagConversionId, reportConversion } from '@/components/Conversion/utils';
import PageMeta from '@/components/PageMeta';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import ColorPicker from '@/ds-components/ColorPicker';
import FormField from '@/ds-components/FormField';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import TextInput from '@/ds-components/TextInput';
import ImageUploaderField from '@/ds-components/Uploader/ImageUploaderField';
import useApi from '@/hooks/use-api';
import type { RequestError } from '@/hooks/use-api';
import useCurrentUser from '@/hooks/use-current-user';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import useUserAssetsService from '@/hooks/use-user-assets-service';
import { CardSelector, MultiCardSelector } from '@/onboarding/components/CardSelector';
import useTenantApi from '@/onboarding/hooks/use-tenant-api';
import useTenantSwrOptions from '@/onboarding/hooks/use-tenant-swr-options';
import useTenantUserAssetsService from '@/onboarding/hooks/use-tenant-user-asset-service';
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
import { OnboardingPage } from '@/onboarding/types';
import { getOnboardingPage } from '@/onboarding/utils';
import { trySubmitSafe } from '@/utils/form';
import { buildUrl } from '@/utils/url';
import { uriValidator } from '@/utils/validator';
@ -42,25 +40,23 @@ import { defaultOnboardingSieFormData } from './sie-config-templates';
import { Authentication, type OnboardingSieFormData } from './types';
function SignInExperience() {
const swrOptions = useTenantSwrOptions();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { navigate } = useTenantPathname();
const {
data: signInExperience,
error,
mutate,
} = useSWR<SignInExperienceType, RequestError>('api/sign-in-exp');
} = useSWR<SignInExperienceType, RequestError>('api/sign-in-exp', swrOptions);
const isSignInExperienceDataLoading = !error && !signInExperience;
const { isLoading: isUserAssetsServiceLoading } = useUserAssetsService();
const { isLoading: isUserAssetsServiceLoading } = useTenantUserAssetsService();
const isLoading = isSignInExperienceDataLoading || isUserAssetsServiceLoading;
const api = useApi();
const api = useTenantApi();
const { isReady: isUserAssetsServiceReady } = useUserAssetsService();
const { update } = useUserOnboardingData();
const { user } = useCurrentUser();
const { navigateTenant, currentTenantId } = useContext(TenantsContext);
const enterAdminConsole = async () => {
await update({ isOnboardingDone: true });
navigateTenant(currentTenantId);
};
const {
@ -146,7 +142,7 @@ function SignInExperience() {
<div className={styles.content}>
<div className={styles.config}>
<Tools />
<div className={styles.title}>{t('cloud.sie.title')}</div>
<div className={pageLayout.title}>{t('cloud.sie.title')}</div>
<InspireMe
onInspired={(template) => {
for (const [key, value] of Object.entries(template)) {
@ -235,7 +231,7 @@ function SignInExperience() {
<Preview className={styles.preview} signInExperience={previewSieConfig} />
</div>
</OverlayScrollbar>
<ActionBar step={2} totalSteps={2}>
<ActionBar step={3} totalSteps={3}>
<div className={styles.continueActions}>
<Button
type="primary"
@ -246,13 +242,6 @@ function SignInExperience() {
}}
/>
</div>
<Button
title="general.back"
disabled={isSubmitting}
onClick={() => {
navigate(getOnboardingPage(OnboardingPage.Welcome));
}}
/>
</ActionBar>
</div>
);

View file

@ -1,15 +1,5 @@
@use '@/scss/underscore' as _;
.title {
font: var(--font-title-1);
margin-top: _.unit(6);
}
.description {
font: var(--font-body-2);
margin-top: _.unit(3);
}
.form {
width: 100%;
margin-top: _.unit(6);

View file

@ -48,7 +48,7 @@ function Welcome() {
const onNext = async () => {
await onSubmit();
navigate(getOnboardingPage(OnboardingPage.SignInExperience));
navigate(getOnboardingPage(OnboardingPage.CreateTenant));
};
return (
@ -57,8 +57,8 @@ function Welcome() {
<OverlayScrollbar className={pageLayout.contentContainer}>
<div className={pageLayout.content}>
<Case />
<div className={styles.title}>{t('cloud.welcome.title')}</div>
<div className={styles.description}>{t('cloud.welcome.description')}</div>
<div className={pageLayout.title}>{t('cloud.welcome.title')}</div>
<div className={pageLayout.description}>{t('cloud.welcome.description')}</div>
<form className={styles.form}>
<FormField title="cloud.welcome.project_field" headlineSpacing="large">
<Controller
@ -122,7 +122,7 @@ function Welcome() {
</form>
</div>
</OverlayScrollbar>
<ActionBar step={1} totalSteps={2}>
<ActionBar step={1} totalSteps={3}>
<Button title="general.next" type="primary" onClick={onNext} />
</ActionBar>
</div>

View file

@ -23,3 +23,14 @@
display: flex;
flex-direction: column;
}
.title {
font: var(--font-title-1);
margin-top: _.unit(6);
}
.description {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-top: _.unit(3);
}

View file

@ -4,9 +4,6 @@ export enum OnboardingRoute {
export enum OnboardingPage {
Welcome = 'welcome',
/** @deprecated Merged `about-user` to `welcome` page. */
AboutUser = 'about-user',
CreateTenant = 'create-tenant',
SignInExperience = 'sign-in-experience',
/** @deprecated Remove this to shorten onboarding process. */
Congrats = 'congrats',
}

View file

@ -8,10 +8,6 @@
.region {
font: var(--font-label-1);
.icon {
margin-right: _.unit(2);
}
}
.regionTip {

View file

@ -1,5 +1,6 @@
import { Trans, useTranslation } from 'react-i18next';
import Region, { RegionName } from '@/components/Region';
import { trustAndSecurityLink } from '@/consts';
import TextLink from '@/ds-components/TextLink';
@ -10,9 +11,8 @@ function TenantRegion() {
return (
<div className={styles.container}>
<div className={styles.region}>
<span className={styles.icon}>🇪🇺</span>EU
</div>
{/* TODO: Read the value from the tenant */}
<Region className={styles.region} regionName={RegionName.EU} />
<div className={styles.regionTip}>
<Trans
components={{