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:
parent
b9b96d2aa7
commit
9d1349ee76
28 changed files with 388 additions and 240 deletions
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />;
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
11
packages/console/src/components/Region/index.module.scss
Normal file
11
packages/console/src/components/Region/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
34
packages/console/src/components/Region/index.tsx
Normal file
34
packages/console/src/components/Region/index.tsx
Normal 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;
|
|
@ -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
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
.app {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
|
@ -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;
|
36
packages/console/src/onboarding/hooks/use-tenant-api.ts
Normal file
36
packages/console/src/onboarding/hooks/use-tenant-api.ts
Normal 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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -3,4 +3,13 @@
|
|||
.app {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
107
packages/console/src/onboarding/pages/CreateTenant/index.tsx
Normal file
107
packages/console/src/onboarding/pages/CreateTenant/index.tsx
Normal 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;
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -8,10 +8,6 @@
|
|||
|
||||
.region {
|
||||
font: var(--font-label-1);
|
||||
|
||||
.icon {
|
||||
margin-right: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
.regionTip {
|
||||
|
|
|
@ -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={{
|
||||
|
|
Loading…
Add table
Reference in a new issue