0
Fork 0
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:
Gao Sun 2023-06-25 14:57:16 +08:00
parent aaa9b47781
commit 8aab136b41
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
11 changed files with 169 additions and 98 deletions

View file

@ -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 />

View file

@ -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>

View file

@ -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 />;
}

View file

@ -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,

View file

@ -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 />;
}

View 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 />;
}

View file

@ -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 />;
}

View file

@ -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>;

View file

@ -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;

View file

@ -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>

View file

@ -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>