mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
fix(console): correct onboarding experience
This commit is contained in:
parent
aaa9b47781
commit
8aab136b41
11 changed files with 169 additions and 98 deletions
|
@ -29,7 +29,7 @@ import initI18n from './i18n/init';
|
|||
void initI18n();
|
||||
|
||||
function Content() {
|
||||
const { tenants, currentTenantId, currentTenantValidated } = useContext(TenantsContext);
|
||||
const { tenants, currentTenantId } = useContext(TenantsContext);
|
||||
|
||||
const resources = useMemo(
|
||||
() =>
|
||||
|
@ -80,7 +80,7 @@ function Content() {
|
|||
* If it's not Cloud (OSS), render the tenant app container directly since only default tenant is available;
|
||||
* if it's Cloud, render the tenant app container only when a tenant ID is available (in a tenant context).
|
||||
*/}
|
||||
{!isCloud || (currentTenantId && currentTenantValidated) ? (
|
||||
{!isCloud || currentTenantId ? (
|
||||
<AppEndpointsProvider>
|
||||
<AppConfirmModalProvider>
|
||||
<TenantAppContainer />
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import ProtectedContent from '@/containers/ProtectedRoutes';
|
||||
import ProtectedRoutes from '@/containers/ProtectedRoutes';
|
||||
import Callback from '@cloud/pages/Callback';
|
||||
|
||||
import * as styles from './App.module.scss';
|
||||
|
@ -15,7 +15,7 @@ function App() {
|
|||
<Route path="/callback" element={<Callback />} />
|
||||
<Route path="/social-demo-callback" element={<SocialDemoCallback />} />
|
||||
<Route path="/:tenantId/callback" element={<Callback />} />
|
||||
<Route path="/*" element={<ProtectedContent />}>
|
||||
<Route element={<ProtectedRoutes />}>
|
||||
<Route path="*" element={<Main />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
|
|
@ -6,17 +6,18 @@ import { TenantsContext } from '@/contexts/TenantsProvider';
|
|||
|
||||
export default function AutoCreateTenant() {
|
||||
const api = useCloudApi();
|
||||
const { appendTenant, navigateTenant } = useContext(TenantsContext);
|
||||
const { appendTenant, tenants } = useContext(TenantsContext);
|
||||
|
||||
useEffect(() => {
|
||||
const createTenant = async () => {
|
||||
const newTenant = await api.post('/api/tenants', { body: {} });
|
||||
appendTenant(newTenant);
|
||||
navigateTenant(newTenant.id);
|
||||
};
|
||||
|
||||
void createTenant();
|
||||
}, [api, appendTenant, navigateTenant]);
|
||||
if (tenants.length === 0) {
|
||||
void createTenant();
|
||||
}
|
||||
}, [api, appendTenant, tenants.length]);
|
||||
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useContext } from 'react';
|
||||
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import useValidateTenantAccess from '@/hooks/use-validate-tenant-access';
|
||||
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
||||
|
||||
import AutoCreateTenant from './AutoCreateTenant';
|
||||
|
@ -10,9 +10,11 @@ import TenantLandingPage from './TenantLandingPage';
|
|||
|
||||
export default function Main() {
|
||||
const { tenants } = useContext(TenantsContext);
|
||||
const { isOnboarding } = useUserOnboardingData();
|
||||
const { isOnboarding, isLoaded } = useUserOnboardingData();
|
||||
|
||||
useValidateTenantAccess();
|
||||
if (!isLoaded) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
/**
|
||||
* If current tenant ID is not set, but there is at least one tenant available,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { yes, conditional } from '@silverhand/essentials';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { Outlet, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
|
@ -8,12 +8,30 @@ import AppLoading from '@/components/AppLoading';
|
|||
import { searchKeys, getCallbackUrl } from '@/consts';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
||||
/**
|
||||
* The container for all protected routes. It renders `<AppLoading />` when the user is not
|
||||
* authenticated or the user is authenticated but the tenant is not initialized.
|
||||
*
|
||||
* That is, when it renders `<Outlet />`, you can expect:
|
||||
*
|
||||
* - `isAuthenticated` from `useLogto()` to be `true`.
|
||||
* - `isInitComplete` from `TenantsContext` to be `true`.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* ```tsx
|
||||
* <Route element={<ProtectedRoutes />}>
|
||||
* <Route path="some-path" element={<SomeContent />} />
|
||||
* </Route>
|
||||
* ```
|
||||
*
|
||||
* Note that the `ProtectedRoutes` component should be put in a {@link https://reactrouter.com/en/main/start/concepts#pathless-routes | pathless route}.
|
||||
*/
|
||||
export default function ProtectedRoutes() {
|
||||
const api = useCloudApi();
|
||||
const [searchParameters] = useSearchParams();
|
||||
const { isAuthenticated, isLoading, signIn } = useLogto();
|
||||
const { currentTenantId, isInitComplete, resetTenants } = useContext(TenantsContext);
|
||||
const [loadingTenants, setLoadingTenants] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
|
@ -23,18 +41,17 @@ export default function ProtectedRoutes() {
|
|||
}, [currentTenantId, isAuthenticated, isLoading, searchParameters, signIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !loadingTenants && !isInitComplete) {
|
||||
if (isAuthenticated && !isInitComplete) {
|
||||
const loadTenants = async () => {
|
||||
const data = await api.get('/api/tenants');
|
||||
resetTenants(data);
|
||||
};
|
||||
|
||||
setLoadingTenants(true);
|
||||
void loadTenants();
|
||||
}
|
||||
}, [api, isAuthenticated, isInitComplete, loadingTenants, resetTenants]);
|
||||
}, [api, isAuthenticated, isInitComplete, resetTenants]);
|
||||
|
||||
if (isLoading || !isInitComplete) {
|
||||
if (!isInitComplete || !isAuthenticated) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
|
|
83
packages/console/src/containers/TenantAccess/index.tsx
Normal file
83
packages/console/src/containers/TenantAccess/index.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { type TenantInfo } from '@logto/schemas/lib/models/tenants.js';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import { getCallbackUrl } from '@/consts';
|
||||
// Used in the docs
|
||||
// eslint-disable-next-line unused-imports/no-unused-imports
|
||||
import type ProtectedRoutes from '@/containers/ProtectedRoutes';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
||||
/**
|
||||
* The container that ensures the user has access to the current tenant. When the user is
|
||||
* authenticated, it will run the validation by fetching an Access Token for the current
|
||||
* tenant.
|
||||
*
|
||||
* Before the validation is complete, it renders `<AppLoading />`; after the validation is
|
||||
* complete:
|
||||
*
|
||||
* - If the user has access to the current tenant, it renders `<Outlet />`.
|
||||
* - If the tenant is unavailable to the user, it redirects to the home page.
|
||||
* - If the tenant is available to the user but getAccessToken() fails, it redirects to the
|
||||
* sign-in page to fetch a full-scoped token.
|
||||
*
|
||||
* Usually this component is used as a child of `<ProtectedRoutes />`, since it requires the
|
||||
* user to be authenticated and the tenant to be initialized.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* ```tsx
|
||||
* <Route element={<ProtectedRoutes />}>
|
||||
* <Route element={<TenantAccess />}>
|
||||
* <Route path="some-path" element={<SomeContent />} />
|
||||
* </Route>
|
||||
* </Route>
|
||||
* ```
|
||||
*
|
||||
* Note that the `TenantAccess` component should be put in a {@link https://reactrouter.com/en/main/start/concepts#pathless-routes | pathless route}.
|
||||
*
|
||||
* @see ProtectedRoutes
|
||||
*/
|
||||
export default function TenantAccess() {
|
||||
const { getAccessToken, signIn, isAuthenticated } = useLogto();
|
||||
const { currentTenant, currentTenantId, currentTenantStatus, setCurrentTenantStatus } =
|
||||
useContext(TenantsContext);
|
||||
|
||||
useEffect(() => {
|
||||
const validate = async ({ indicator, id }: TenantInfo) => {
|
||||
// Test fetching an access token for the current Tenant ID.
|
||||
// If failed, it means the user finishes the first auth, ands still needs to auth again to
|
||||
// fetch the full-scoped (with all available tenants) token.
|
||||
if (await trySafe(getAccessToken(indicator))) {
|
||||
setCurrentTenantStatus('validated');
|
||||
} else {
|
||||
void signIn(getCallbackUrl(id).href);
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthenticated && currentTenantId && currentTenantStatus === 'pending') {
|
||||
setCurrentTenantStatus('validating');
|
||||
if (currentTenant) {
|
||||
void validate(currentTenant);
|
||||
} else {
|
||||
// The current tenant is unavailable to the user, maybe a deleted tenant or a tenant that
|
||||
// the user has no access to. Fall back to the home page.
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentTenant,
|
||||
currentTenantId,
|
||||
currentTenantStatus,
|
||||
getAccessToken,
|
||||
isAuthenticated,
|
||||
setCurrentTenantStatus,
|
||||
signIn,
|
||||
]);
|
||||
|
||||
return currentTenantStatus === 'validated' ? <Outlet /> : <AppLoading />;
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
|
||||
|
@ -10,14 +11,24 @@ import { OnboardingRoutes } from '@/onboarding';
|
|||
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
||||
import { ConsoleRoutes } from '@/pages/ConsoleRoutes';
|
||||
|
||||
/** @deprecated Remove this layer. */
|
||||
function TenantAppContainer() {
|
||||
const { userEndpoint } = useContext(AppEndpointsContext);
|
||||
const { isOnboarding, isLoaded } = useUserOnboardingData();
|
||||
const { isAuthenticated } = useLogto();
|
||||
|
||||
const router = useMemo(
|
||||
() =>
|
||||
createBrowserRouter(
|
||||
[{ path: '*', Component: isOnboarding ? OnboardingRoutes : ConsoleRoutes }],
|
||||
[
|
||||
{
|
||||
path: '*',
|
||||
// Only authenticated user can access onboarding routes.
|
||||
// This looks weird and it will be refactored soon by merging the onboarding
|
||||
// routes with the console routes.
|
||||
Component: isAuthenticated && isOnboarding ? OnboardingRoutes : ConsoleRoutes,
|
||||
},
|
||||
],
|
||||
// Currently we use `window.open()` to navigate between tenants so the `useMemo` hook
|
||||
// can have no dependency and the router will be created anyway. Consider integrating the
|
||||
// tenant ID into the router and remove basename here if we want to use `history.pushState()`
|
||||
|
@ -28,12 +39,15 @@ function TenantAppContainer() {
|
|||
// to handle `popstate` event to update the tenant ID when the user navigates back.
|
||||
{ basename: getBasename() }
|
||||
),
|
||||
[isOnboarding]
|
||||
[isAuthenticated, isOnboarding]
|
||||
);
|
||||
|
||||
useTrackUserId();
|
||||
|
||||
if (!userEndpoint || (isCloud && !isLoaded)) {
|
||||
// Authenticated user should loading onboarding data before rendering the app.
|
||||
// This looks weird and it will be refactored soon by merging the onboarding
|
||||
// routes with the console routes.
|
||||
if (!userEndpoint || (isCloud && isAuthenticated && !isLoaded)) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
|
|
|
@ -8,9 +8,7 @@ import type { NavigateOptions } from 'react-router-dom';
|
|||
import { isCloud } from '@/consts/env';
|
||||
import { getUserTenantId } from '@/consts/tenants';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
type CurrentTenantStatus = 'pending' | 'validating' | 'validated';
|
||||
|
||||
/** @see {@link TenantsProvider} for why `useSWR()` is not applicable for this context. */
|
||||
type Tenants = {
|
||||
|
@ -33,8 +31,8 @@ type Tenants = {
|
|||
currentTenantId: string;
|
||||
currentTenant?: TenantInfo;
|
||||
/** Indicates if the Access Token has been validated for use. Will be reset to false when the current tenant changes. */
|
||||
currentTenantValidated: boolean;
|
||||
setCurrentTenantValidated: () => void;
|
||||
currentTenantStatus: CurrentTenantStatus;
|
||||
setCurrentTenantStatus: (status: CurrentTenantStatus) => void;
|
||||
navigateTenant: (tenantId: string, options?: NavigateOptions) => void;
|
||||
};
|
||||
|
||||
|
@ -58,11 +56,15 @@ export const TenantsContext = createContext<Tenants>({
|
|||
removeTenant: noop,
|
||||
updateTenant: noop,
|
||||
currentTenantId: '',
|
||||
currentTenantValidated: false,
|
||||
setCurrentTenantValidated: noop,
|
||||
currentTenantStatus: 'pending',
|
||||
setCurrentTenantStatus: noop,
|
||||
navigateTenant: noop,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The global tenants context provider for all available tenants of the current users.
|
||||
* It is used to manage the tenants information, including create, update, and delete;
|
||||
|
@ -78,14 +80,14 @@ function TenantsProvider({ children }: Props) {
|
|||
/** @see {@link initialTenants} */
|
||||
const [isInitComplete, setIsInitComplete] = useState(!isCloud);
|
||||
const [currentTenantId, setCurrentTenantId] = useState(getUserTenantId());
|
||||
const [currentTenantValidated, setCurrentTenantValidated] = useState(false);
|
||||
const [currentTenantStatus, setCurrentTenantStatus] = useState<CurrentTenantStatus>('pending');
|
||||
|
||||
const navigateTenant = useCallback((tenantId: string) => {
|
||||
// Use `window.open()` to force page reload since we use `basename` for the router
|
||||
// which will not re-create the router instance when the URL changes.
|
||||
window.open(`/${tenantId}`, '_self');
|
||||
setCurrentTenantId(tenantId);
|
||||
setCurrentTenantValidated(false);
|
||||
setCurrentTenantStatus('pending');
|
||||
}, []);
|
||||
|
||||
const currentTenant = useMemo(
|
||||
|
@ -98,7 +100,7 @@ function TenantsProvider({ children }: Props) {
|
|||
tenants,
|
||||
resetTenants: (tenants: TenantInfo[]) => {
|
||||
setTenants(tenants);
|
||||
setCurrentTenantValidated(false);
|
||||
setCurrentTenantStatus('pending');
|
||||
setIsInitComplete(true);
|
||||
},
|
||||
appendTenant: (tenant: TenantInfo) => {
|
||||
|
@ -115,20 +117,11 @@ function TenantsProvider({ children }: Props) {
|
|||
isInitComplete,
|
||||
currentTenantId,
|
||||
currentTenant,
|
||||
currentTenantValidated,
|
||||
setCurrentTenantValidated: () => {
|
||||
setCurrentTenantValidated(true);
|
||||
},
|
||||
currentTenantStatus,
|
||||
setCurrentTenantStatus,
|
||||
navigateTenant,
|
||||
}),
|
||||
[
|
||||
currentTenant,
|
||||
currentTenantId,
|
||||
currentTenantValidated,
|
||||
isInitComplete,
|
||||
navigateTenant,
|
||||
tenants,
|
||||
]
|
||||
[currentTenant, currentTenantId, currentTenantStatus, isInitComplete, navigateTenant, tenants]
|
||||
);
|
||||
|
||||
return <TenantsContext.Provider value={memorizedContext}>{children}</TenantsContext.Provider>;
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { type TenantInfo } from '@logto/schemas/lib/models/tenants.js';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
||||
import { getCallbackUrl } from '@/consts';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
||||
const useValidateTenantAccess = () => {
|
||||
const { getAccessToken, signIn } = useLogto();
|
||||
const { currentTenant, currentTenantId, currentTenantValidated, setCurrentTenantValidated } =
|
||||
useContext(TenantsContext);
|
||||
|
||||
useEffect(() => {
|
||||
const validate = async ({ indicator, id }: TenantInfo) => {
|
||||
// Test fetching an access token for the current Tenant ID.
|
||||
// If failed, it means the user finishes the first auth, ands still needs to auth again to
|
||||
// fetch the full-scoped (with all available tenants) token.
|
||||
if (!(await trySafe(getAccessToken(indicator)))) {
|
||||
void signIn(getCallbackUrl(id).href);
|
||||
}
|
||||
};
|
||||
|
||||
if (currentTenantId && !currentTenantValidated) {
|
||||
setCurrentTenantValidated();
|
||||
if (currentTenant) {
|
||||
void validate(currentTenant);
|
||||
} else {
|
||||
// The current tenant is unavailable to the user, maybe a deleted tenant or a tenant that
|
||||
// the user has no access to. Fall back to the home page.
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentTenant,
|
||||
currentTenantId,
|
||||
currentTenantValidated,
|
||||
getAccessToken,
|
||||
setCurrentTenantValidated,
|
||||
signIn,
|
||||
]);
|
||||
};
|
||||
|
||||
export default useValidateTenantAccess;
|
|
@ -8,6 +8,7 @@ import { SWRConfig } from 'swr';
|
|||
|
||||
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';
|
||||
|
@ -102,16 +103,18 @@ export function OnboardingRoutes() {
|
|||
return (
|
||||
<Routes>
|
||||
<Route element={<ProtectedRoutes />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Navigate replace to={welcomePathname} />} />
|
||||
<Route path={`/${OnboardingRoute.Onboarding}`} element={<AppContent />}>
|
||||
<Route path="" element={<Navigate replace to={welcomePathname} />} />
|
||||
<Route path={OnboardingPage.Welcome} element={<Welcome />} />
|
||||
<Route path={OnboardingPage.AboutUser} element={<About />} />
|
||||
<Route path={OnboardingPage.SignInExperience} element={<SignInExperience />} />
|
||||
<Route path={OnboardingPage.Congrats} element={<Congrats />} />
|
||||
<Route element={<TenantAccess />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" 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={<About />} />
|
||||
<Route path={OnboardingPage.SignInExperience} element={<SignInExperience />} />
|
||||
<Route path={OnboardingPage.Congrats} element={<Congrats />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
|
|
@ -7,6 +7,7 @@ import AppBoundary from '@/containers/AppBoundary';
|
|||
import AppContent from '@/containers/AppContent';
|
||||
import ConsoleContent from '@/containers/ConsoleContent';
|
||||
import ProtectedRoutes from '@/containers/ProtectedRoutes';
|
||||
import TenantAccess from '@/containers/TenantAccess';
|
||||
import Toast from '@/ds-components/Toast';
|
||||
import useSwrOptions from '@/hooks/use-swr-options';
|
||||
import Callback from '@/pages/Callback';
|
||||
|
@ -36,8 +37,10 @@ export function ConsoleRoutes() {
|
|||
<Route path="welcome" element={<Welcome />} />
|
||||
<Route element={<ProtectedRoutes />}>
|
||||
<Route path="handle-social" element={<HandleSocialCallback />} />
|
||||
<Route element={<AppContent />}>
|
||||
<Route path="*" element={<ConsoleContent />} />
|
||||
<Route element={<TenantAccess />}>
|
||||
<Route element={<AppContent />}>
|
||||
<Route path="*" element={<ConsoleContent />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
|
Loading…
Add table
Reference in a new issue