mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor(console): reorg hooks and data checking logics
This commit is contained in:
parent
a3e3363b10
commit
d7f96a6559
18 changed files with 250 additions and 229 deletions
|
@ -9,7 +9,7 @@
|
|||
"prepare": "if test \"$NODE_ENV\" != \"production\" && test \"$CI\" != \"true\" ; then husky install ; fi",
|
||||
"prepack": "pnpm -r prepack",
|
||||
"dev": "pnpm -r prepack && pnpm start:dev",
|
||||
"dev:cloud": "pnpm -r prepack && pnpm start:dev:cloud",
|
||||
"dev:cloud": "IS_CLOUD=1 pnpm -r prepack && pnpm start:dev:cloud",
|
||||
"start:dev": "pnpm -r --parallel --filter=!@logto/integration-tests --filter \"!./packages/connectors/connector-*\" --filter=!@logto/cloud dev",
|
||||
"start:dev:cloud": "CONSOLE_PUBLIC_URL=/ IS_CLOUD=1 pnpm -r --parallel --filter=!@logto/integration-tests --filter \"!./packages/connectors/connector-*\" dev",
|
||||
"start": "cd packages/core && NODE_ENV=production node .",
|
||||
|
|
|
@ -15,7 +15,6 @@ import '@fontsource/roboto-mono';
|
|||
|
||||
import CloudApp from '@/cloud/App';
|
||||
import { cloudApi, getManagementApi, meApi } from '@/consts/resources';
|
||||
import initI18n from '@/i18n/init';
|
||||
|
||||
import { adminTenantEndpoint, mainTitle } from './consts';
|
||||
import { isCloud } from './consts/env';
|
||||
|
@ -25,11 +24,12 @@ import AppConfirmModalProvider from './contexts/AppConfirmModalProvider';
|
|||
import AppEndpointsProvider from './contexts/AppEndpointsProvider';
|
||||
import { AppThemeProvider } from './contexts/AppThemeProvider';
|
||||
import TenantsProvider, { TenantsContext } from './contexts/TenantsProvider';
|
||||
import initI18n from './i18n/init';
|
||||
|
||||
void initI18n();
|
||||
|
||||
function Content() {
|
||||
const { tenants, isInitComplete, currentTenantId } = 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 init is complete and a tenant ID is available (in a tenant context).
|
||||
*/}
|
||||
{!isCloud || (isInitComplete && currentTenantId) ? (
|
||||
{!isCloud || currentTenantId ? (
|
||||
<AppEndpointsProvider>
|
||||
<AppConfirmModalProvider>
|
||||
<TenantAppContainer />
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import ProtectedContent from '@/containers/ProtectedRoutes';
|
||||
import Callback from '@cloud/pages/Callback';
|
||||
|
||||
import * as styles from './App.module.scss';
|
||||
import Main from './pages/Main';
|
||||
import SocialDemoCallback from './pages/SocialDemoCallback';
|
||||
import { CloudRoute } from './types';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className={styles.app}>
|
||||
<Routes>
|
||||
<Route path={`/${CloudRoute.Callback}`} element={<Callback />} />
|
||||
<Route path={`/${CloudRoute.SocialDemoCallback}`} element={<SocialDemoCallback />} />
|
||||
<Route path={`/:tenantId/${CloudRoute.Callback}`} element={<Callback />} />
|
||||
<Route path="/*" element={<Main />} />
|
||||
<Route path="/callback" element={<Callback />} />
|
||||
<Route path="/social-demo-callback" element={<SocialDemoCallback />} />
|
||||
<Route path="/:tenantId/callback" element={<Callback />} />
|
||||
<Route path="/*" element={<ProtectedContent />}>
|
||||
<Route path="*" element={<Main />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { conditional, yes } from '@silverhand/essentials';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { useHref, useSearchParams } from 'react-router-dom';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import { searchKeys } from '@/consts';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
||||
|
||||
|
@ -13,34 +7,15 @@ import AutoCreateTenant from './AutoCreateTenant';
|
|||
import Redirect from './Redirect';
|
||||
import TenantLandingPage from './TenantLandingPage';
|
||||
|
||||
function Content() {
|
||||
const api = useCloudApi();
|
||||
const { tenants, resetTenants, currentTenantId, isInitComplete } = useContext(TenantsContext);
|
||||
const { isOnboarding, isLoaded: isOnboardingDataLoaded } = useUserOnboardingData();
|
||||
|
||||
// Load tenants from the cloud API for the first render.
|
||||
useEffect(() => {
|
||||
const loadTenants = async () => {
|
||||
const data = await api.get('/api/tenants');
|
||||
resetTenants(data);
|
||||
};
|
||||
|
||||
if (!isInitComplete) {
|
||||
void loadTenants();
|
||||
}
|
||||
}, [api, resetTenants, tenants, isInitComplete]);
|
||||
|
||||
if (!isInitComplete || !isOnboardingDataLoaded) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
export default function Main() {
|
||||
const { tenants } = useContext(TenantsContext);
|
||||
const { isOnboarding } = useUserOnboardingData();
|
||||
|
||||
/**
|
||||
* Trigger a redirect when one of the following conditions is met:
|
||||
*
|
||||
* - If a current tenant ID has been set in the URL; or
|
||||
* - If current tenant ID is not set, but there is at least one tenant available.
|
||||
* If current tenant ID is not set, but there is at least one tenant available,
|
||||
* trigger a redirect to the first tenant.
|
||||
*/
|
||||
if (currentTenantId || tenants[0]) {
|
||||
if (tenants[0]) {
|
||||
return <Redirect />;
|
||||
}
|
||||
|
||||
|
@ -52,28 +27,3 @@ function Content() {
|
|||
// If user has completed onboarding and still has no tenant, redirect to a special landing page.
|
||||
return <TenantLandingPage />;
|
||||
}
|
||||
|
||||
function Main() {
|
||||
const [searchParameters] = useSearchParams();
|
||||
const { isAuthenticated, isLoading, signIn } = useLogto();
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const href = useHref(currentTenantId + '/callback');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
const isSignUpMode = yes(searchParameters.get(searchKeys.signUp));
|
||||
void signIn(
|
||||
new URL(href, window.location.origin).toString(),
|
||||
conditional(isSignUpMode && 'signUp')
|
||||
);
|
||||
}
|
||||
}, [href, isAuthenticated, isLoading, searchParameters, signIn]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
return <Content />;
|
||||
}
|
||||
|
||||
export default Main;
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
export enum CloudRoute {
|
||||
Callback = 'callback',
|
||||
SocialDemoCallback = 'social-demo-callback',
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getCallbackUrl } from '@/consts';
|
||||
import Button from '@/ds-components/Button';
|
||||
|
||||
import AppError from '../AppError';
|
||||
|
@ -9,10 +10,9 @@ import * as styles from './index.module.scss';
|
|||
|
||||
type Props = {
|
||||
error: Error;
|
||||
callbackHref?: string;
|
||||
};
|
||||
|
||||
function SessionExpired({ callbackHref = '/callback', error }: Props) {
|
||||
function SessionExpired({ error }: Props) {
|
||||
const { signIn } = useLogto();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
|
@ -28,7 +28,7 @@ function SessionExpired({ callbackHref = '/callback', error }: Props) {
|
|||
type="outline"
|
||||
title="session_expired.button"
|
||||
onClick={() => {
|
||||
void signIn(new URL(callbackHref, window.location.origin).toString());
|
||||
void signIn(getCallbackUrl().href);
|
||||
}}
|
||||
/>
|
||||
</AppError>
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { defaultTenantId, ossConsolePath } from '@logto/schemas';
|
||||
import { conditionalArray } from '@silverhand/essentials';
|
||||
|
||||
import { CloudRoute } from '@/cloud/types';
|
||||
|
||||
import { adminEndpoint, isCloud } from './env';
|
||||
|
||||
const getAdminTenantEndpoint = () => {
|
||||
|
@ -24,12 +22,11 @@ export const getUserTenantId = () => {
|
|||
if (isCloud) {
|
||||
const segment = window.location.pathname.split('/')[1];
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (Object.values(CloudRoute).includes(segment as CloudRoute)) {
|
||||
if (!segment || segment === 'callback' || segment.endsWith('-callback')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return segment ?? '';
|
||||
return segment;
|
||||
}
|
||||
|
||||
return defaultTenantId;
|
||||
|
@ -38,7 +35,11 @@ export const getUserTenantId = () => {
|
|||
export const getBasename = () => (isCloud ? '/' + getUserTenantId() : ossConsolePath);
|
||||
|
||||
export const getCallbackUrl = (tenantId?: string) =>
|
||||
new URL('/' + conditionalArray(tenantId, 'callback').join('/'), window.location.origin);
|
||||
new URL(
|
||||
// Only Cloud has tenantId in callback URL
|
||||
'/' + conditionalArray(isCloud ? tenantId : 'console', 'callback').join('/'),
|
||||
window.location.origin
|
||||
);
|
||||
|
||||
export const getSignOutRedirectPathname = () => (isCloud ? '/' : ossConsolePath);
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import { LogtoClientError, LogtoError, useLogto } from '@logto/react';
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Outlet, useHref, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import AppError from '@/components/AppError';
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
|
@ -11,6 +11,7 @@ import { isCloud } from '@/consts/env';
|
|||
import useConfigs from '@/hooks/use-configs';
|
||||
import useScroll from '@/hooks/use-scroll';
|
||||
import useUserPreferences from '@/hooks/use-user-preferences';
|
||||
import useValidateTenantAccess from '@/hooks/use-validate-tenant-access';
|
||||
import Broadcast from '@/onboarding/components/Broadcast';
|
||||
|
||||
import { getPath } from '../ConsoleContent/Sidebar';
|
||||
|
@ -21,8 +22,7 @@ import * as styles from './index.module.scss';
|
|||
import { type AppContentOutletContext } from './types';
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated, isLoading: isLogtoLoading, error, signIn } = useLogto();
|
||||
const href = useHref('/callback');
|
||||
const { error } = useLogto();
|
||||
const { isLoading: isPreferencesLoading } = useUserPreferences();
|
||||
const { isLoading: isConfigsLoading } = useConfigs();
|
||||
|
||||
|
@ -35,11 +35,7 @@ function AppContent() {
|
|||
const { scrollTop } = useScroll(scrollableContent.current);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated && !isLogtoLoading) {
|
||||
void signIn(new URL(href, window.location.origin).toString());
|
||||
}
|
||||
}, [href, isAuthenticated, isLogtoLoading, signIn]);
|
||||
useValidateTenantAccess();
|
||||
|
||||
useEffect(() => {
|
||||
// Navigate to the first menu item after configs are loaded.
|
||||
|
@ -48,9 +44,13 @@ function AppContent() {
|
|||
}
|
||||
}, [firstItem?.title, isLoading, location.pathname, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error instanceof LogtoClientError) {
|
||||
return <SessionExpired error={error} callbackHref={href} />;
|
||||
return <SessionExpired error={error} />;
|
||||
}
|
||||
|
||||
if (error instanceof LogtoError && error.code === 'crypto_subtle_unavailable') {
|
||||
|
@ -60,10 +60,6 @@ function AppContent() {
|
|||
return <AppError errorMessage={error.message} callStack={error.stack} />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated || isLoading) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.app}>
|
||||
|
|
42
packages/console/src/containers/ProtectedRoutes/index.tsx
Normal file
42
packages/console/src/containers/ProtectedRoutes/index.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { yes, conditional } from '@silverhand/essentials';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { Outlet, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import { searchKeys, getCallbackUrl } from '@/consts';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
||||
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) {
|
||||
const isSignUpMode = yes(searchParameters.get(searchKeys.signUp));
|
||||
void signIn(getCallbackUrl(currentTenantId).href, conditional(isSignUpMode && 'signUp'));
|
||||
}
|
||||
}, [currentTenantId, isAuthenticated, isLoading, searchParameters, signIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !loadingTenants && !isInitComplete) {
|
||||
const loadTenants = async () => {
|
||||
const data = await api.get('/api/tenants');
|
||||
resetTenants(data);
|
||||
};
|
||||
|
||||
setLoadingTenants(true);
|
||||
void loadTenants();
|
||||
}
|
||||
}, [api, isAuthenticated, isInitComplete, loadingTenants, resetTenants]);
|
||||
|
||||
if (isLoading || !isInitComplete) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
|
@ -1,38 +1,35 @@
|
|||
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 { useContext, useMemo } from 'react';
|
||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import { getCallbackUrl } from '@/consts';
|
||||
import { getBasename } from '@/consts';
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import useTrackUserId from '@/hooks/use-track-user-id';
|
||||
import OnboardingApp from '@/onboarding/App';
|
||||
import { OnboardingRoutes } from '@/onboarding';
|
||||
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
||||
import ConsoleApp from '@/pages/Main';
|
||||
import { ConsoleRoutes } from '@/pages/ConsoleRoutes';
|
||||
|
||||
function TenantAppContainer() {
|
||||
const { getAccessToken, signIn } = useLogto();
|
||||
const { userEndpoint } = useContext(AppEndpointsContext);
|
||||
const { isOnboarding, isLoaded } = useUserOnboardingData();
|
||||
const { currentTenant } = 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 (currentTenant) {
|
||||
void validate(currentTenant);
|
||||
}
|
||||
}, [currentTenant, getAccessToken, signIn]);
|
||||
const router = useMemo(
|
||||
() =>
|
||||
createBrowserRouter(
|
||||
[{ path: '*', Component: 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()`
|
||||
// to navigate.
|
||||
//
|
||||
// Caveat: To use `history.pushState()`, we'd better to create a browser router in the upper
|
||||
// level of the component tree to make the tenant ID a part of the URL. Otherwise, we need
|
||||
// to handle `popstate` event to update the tenant ID when the user navigates back.
|
||||
{ basename: getBasename() }
|
||||
),
|
||||
[isOnboarding]
|
||||
);
|
||||
|
||||
useTrackUserId();
|
||||
|
||||
|
@ -40,11 +37,7 @@ function TenantAppContainer() {
|
|||
return <AppLoading />;
|
||||
}
|
||||
|
||||
if (isOnboarding) {
|
||||
return <OnboardingApp />;
|
||||
}
|
||||
|
||||
return <ConsoleApp />;
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default TenantAppContainer;
|
||||
|
|
|
@ -32,7 +32,9 @@ type Tenants = {
|
|||
*/
|
||||
currentTenantId: string;
|
||||
currentTenant?: TenantInfo;
|
||||
setCurrentTenantId: (tenantId: string) => void;
|
||||
/** Indicates if the Access Token has been validated for use. Will be reset to false when the current tenant changes. */
|
||||
currentTenantValidated: boolean;
|
||||
setCurrentTenantValidated: () => void;
|
||||
navigateTenant: (tenantId: string, options?: NavigateOptions) => void;
|
||||
};
|
||||
|
||||
|
@ -56,7 +58,8 @@ export const TenantsContext = createContext<Tenants>({
|
|||
removeTenant: noop,
|
||||
updateTenant: noop,
|
||||
currentTenantId: '',
|
||||
setCurrentTenantId: noop,
|
||||
currentTenantValidated: false,
|
||||
setCurrentTenantValidated: noop,
|
||||
navigateTenant: noop,
|
||||
});
|
||||
|
||||
|
@ -75,12 +78,14 @@ function TenantsProvider({ children }: Props) {
|
|||
/** @see {@link initialTenants} */
|
||||
const [isInitComplete, setIsInitComplete] = useState(!isCloud);
|
||||
const [currentTenantId, setCurrentTenantId] = useState(getUserTenantId());
|
||||
const [currentTenantValidated, setCurrentTenantValidated] = useState(false);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const currentTenant = useMemo(
|
||||
|
@ -109,10 +114,20 @@ function TenantsProvider({ children }: Props) {
|
|||
isInitComplete,
|
||||
currentTenantId,
|
||||
currentTenant,
|
||||
setCurrentTenantId,
|
||||
currentTenantValidated,
|
||||
setCurrentTenantValidated: () => {
|
||||
setCurrentTenantValidated(true);
|
||||
},
|
||||
navigateTenant,
|
||||
}),
|
||||
[currentTenant, currentTenantId, isInitComplete, navigateTenant, tenants]
|
||||
[
|
||||
currentTenant,
|
||||
currentTenantId,
|
||||
currentTenantValidated,
|
||||
isInitComplete,
|
||||
navigateTenant,
|
||||
tenants,
|
||||
]
|
||||
);
|
||||
|
||||
return <TenantsContext.Provider value={memorizedContext}>{children}</TenantsContext.Provider>;
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { isKeyInObject } from '@logto/shared/universal';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -20,10 +22,13 @@ const useMeCustomData = () => {
|
|||
|
||||
const fetcher = useSwrFetcher(api);
|
||||
|
||||
const { data, mutate, error } = useSWR<unknown, RequestError>(
|
||||
shouldFetch && `me/custom-data`,
|
||||
fetcher
|
||||
);
|
||||
const {
|
||||
data: meData,
|
||||
mutate,
|
||||
error,
|
||||
// Reuse the same key `me` as `useCurrentUser()` to avoid additional requests.
|
||||
} = useSWR<unknown, RequestError>(shouldFetch && 'me', fetcher);
|
||||
const data = conditional(isKeyInObject(meData, 'customData') && meData.customData);
|
||||
|
||||
const update = useCallback(
|
||||
async (data: Record<string, unknown>) => {
|
||||
|
|
38
packages/console/src/hooks/use-validate-tenant-access.ts
Normal file
38
packages/console/src/hooks/use-validate-tenant-access.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
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, isAuthenticated } = useLogto();
|
||||
const { currentTenant, 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 (isAuthenticated && currentTenant && !currentTenantValidated) {
|
||||
setCurrentTenantValidated();
|
||||
void validate(currentTenant);
|
||||
}
|
||||
}, [
|
||||
currentTenant,
|
||||
currentTenantValidated,
|
||||
getAccessToken,
|
||||
isAuthenticated,
|
||||
setCurrentTenantValidated,
|
||||
signIn,
|
||||
]);
|
||||
};
|
||||
|
||||
export default useValidateTenantAccess;
|
|
@ -3,21 +3,21 @@ import { TrackOnce } from '@logto/app-insights/react';
|
|||
import { Theme } from '@logto/schemas';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Route, Navigate, Outlet, useLocation, Routes } from 'react-router-dom';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import { getBasename } from '@/consts';
|
||||
import AppBoundary from '@/containers/AppBoundary';
|
||||
import ProtectedRoutes from '@/containers/ProtectedRoutes';
|
||||
import { AppThemeContext } from '@/contexts/AppThemeProvider';
|
||||
import Toast from '@/ds-components/Toast';
|
||||
import useSwrOptions from '@/hooks/use-swr-options';
|
||||
import useValidateTenantAccess from '@/hooks/use-validate-tenant-access';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
|
||||
import * as styles from './App.module.scss';
|
||||
import { gtagAwTrackingId, gtagSignUpConversionId, logtoProductionHostname } from './constants';
|
||||
import AppContent from './containers/AppContent';
|
||||
import useUserOnboardingData from './hooks/use-user-onboarding-data';
|
||||
import * as styles from './index.module.scss';
|
||||
import About from './pages/About';
|
||||
import Congrats from './pages/Congrats';
|
||||
import SignInExperience from './pages/SignInExperience';
|
||||
|
@ -32,9 +32,10 @@ const welcomePathname = getOnboardingPage(OnboardingPage.Welcome);
|
|||
*/
|
||||
const shouldReportToGtag = window.location.hostname.endsWith('.' + logtoProductionHostname);
|
||||
|
||||
function App() {
|
||||
function Layout() {
|
||||
const swrOptions = useSwrOptions();
|
||||
const { setThemeOverride } = useContext(AppThemeContext);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
setThemeOverride(Theme.Light);
|
||||
|
@ -65,17 +66,19 @@ function App() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
useValidateTenantAccess();
|
||||
|
||||
const {
|
||||
data: { questionnaire },
|
||||
isLoaded,
|
||||
} = useUserOnboardingData();
|
||||
|
||||
if (!isLoaded) {
|
||||
return <AppLoading />;
|
||||
// Redirect to the welcome page if the user has not started the onboarding process.
|
||||
if (!questionnaire && location.pathname !== welcomePathname) {
|
||||
return <Navigate replace to={welcomePathname} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter basename={getBasename()}>
|
||||
<>
|
||||
<TrackOnce component={Component.Console} event={ConsoleEvent.Onboard} />
|
||||
<div className={styles.app}>
|
||||
<SWRConfig value={swrOptions}>
|
||||
|
@ -90,33 +93,30 @@ function App() {
|
|||
</Helmet>
|
||||
)}
|
||||
<Toast />
|
||||
<Routes>
|
||||
<Route index element={<Navigate replace to={welcomePathname} />} />
|
||||
<Route path={`/${OnboardingRoute.Onboarding}`} element={<AppContent />}>
|
||||
<Route index element={<Navigate replace to={welcomePathname} />} />
|
||||
<Route path={OnboardingPage.Welcome} element={<Welcome />} />
|
||||
<Route
|
||||
path={OnboardingPage.AboutUser}
|
||||
element={questionnaire ? <About /> : <Navigate replace to={welcomePathname} />}
|
||||
/>
|
||||
<Route
|
||||
path={OnboardingPage.SignInExperience}
|
||||
element={
|
||||
questionnaire ? <SignInExperience /> : <Navigate replace to={welcomePathname} />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={OnboardingPage.Congrats}
|
||||
element={questionnaire ? <Congrats /> : <Navigate replace to={welcomePathname} />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<Outlet />
|
||||
</AppBoundary>
|
||||
</SWRConfig>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export function OnboardingRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<ProtectedRoutes />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<Navigate replace to={welcomePathname} />} />
|
||||
<Route path={`/${OnboardingRoute.Onboarding}`} element={<AppContent />}>
|
||||
<Route index element={<Navigate replace to={welcomePathname} />} />
|
||||
<Route path={OnboardingPage.Welcome} element={<Welcome />} />
|
||||
<Route path={OnboardingPage.AboutUser} element={<About />} />
|
||||
<Route path={OnboardingPage.SignInExperience} element={<SignInExperience />} />
|
||||
<Route path={OnboardingPage.Congrats} element={<Congrats />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
46
packages/console/src/pages/ConsoleRoutes/index.tsx
Normal file
46
packages/console/src/pages/ConsoleRoutes/index.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { Component, GeneralEvent } from '@logto/app-insights/custom-event';
|
||||
import { TrackOnce } from '@logto/app-insights/react';
|
||||
import { Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import AppBoundary from '@/containers/AppBoundary';
|
||||
import AppContent from '@/containers/AppContent';
|
||||
import ConsoleContent from '@/containers/ConsoleContent';
|
||||
import ProtectedRoutes from '@/containers/ProtectedRoutes';
|
||||
import Toast from '@/ds-components/Toast';
|
||||
import useSwrOptions from '@/hooks/use-swr-options';
|
||||
import Callback from '@/pages/Callback';
|
||||
import Welcome from '@/pages/Welcome';
|
||||
|
||||
import HandleSocialCallback from '../Profile/containers/HandleSocialCallback';
|
||||
|
||||
function Layout() {
|
||||
const swrOptions = useSwrOptions();
|
||||
|
||||
return (
|
||||
<SWRConfig value={swrOptions}>
|
||||
<AppBoundary>
|
||||
<TrackOnce component={Component.Console} event={GeneralEvent.Visit} />
|
||||
<Toast />
|
||||
<Outlet />
|
||||
</AppBoundary>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConsoleRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="callback" element={<Callback />} />
|
||||
<Route path="welcome" element={<Welcome />} />
|
||||
<Route element={<ProtectedRoutes />}>
|
||||
<Route path="handle-social" element={<HandleSocialCallback />} />
|
||||
<Route element={<AppContent />}>
|
||||
<Route path="*" element={<ConsoleContent />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
import { Component, GeneralEvent } from '@logto/app-insights/custom-event';
|
||||
import { TrackOnce } from '@logto/app-insights/react';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Route,
|
||||
RouterProvider,
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
} from 'react-router-dom';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import { getBasename } from '@/consts';
|
||||
import AppBoundary from '@/containers/AppBoundary';
|
||||
import AppContent from '@/containers/AppContent';
|
||||
import ConsoleContent from '@/containers/ConsoleContent';
|
||||
import Toast from '@/ds-components/Toast';
|
||||
import useSwrOptions from '@/hooks/use-swr-options';
|
||||
import Callback from '@/pages/Callback';
|
||||
import Welcome from '@/pages/Welcome';
|
||||
|
||||
import HandleSocialCallback from '../Profile/containers/HandleSocialCallback';
|
||||
|
||||
function Main() {
|
||||
const swrOptions = useSwrOptions();
|
||||
|
||||
const router = useMemo(
|
||||
() =>
|
||||
createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route path="/*">
|
||||
<Route path="callback" element={<Callback />} />
|
||||
<Route path="welcome" element={<Welcome />} />
|
||||
<Route path="handle-social" element={<HandleSocialCallback />} />
|
||||
<Route element={<AppContent />}>
|
||||
<Route path="*" element={<ConsoleContent />} />
|
||||
</Route>
|
||||
</Route>
|
||||
),
|
||||
// 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()`
|
||||
// to navigate.
|
||||
//
|
||||
// Caveat: To use `history.pushState()`, we'd better to create a browser router in the upper
|
||||
// level of the component tree to make the tenant ID a part of the URL. Otherwise, we need
|
||||
// to handle `popstate` event to update the tenant ID when the user navigates back.
|
||||
{ basename: getBasename() }
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<SWRConfig value={swrOptions}>
|
||||
<AppBoundary>
|
||||
<TrackOnce component={Component.Console} event={GeneralEvent.Visit} />
|
||||
<Toast />
|
||||
<RouterProvider router={router} />
|
||||
</AppBoundary>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
|
||||
export default Main;
|
|
@ -2,11 +2,12 @@ import { LogtoClientError, useLogto } from '@logto/react';
|
|||
import classNames from 'classnames';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useHref } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Logo from '@/assets/images/logo.svg';
|
||||
import AppError from '@/components/AppError';
|
||||
import SessionExpired from '@/components/SessionExpired';
|
||||
import { getCallbackUrl } from '@/consts';
|
||||
import Button from '@/ds-components/Button';
|
||||
import useTheme from '@/hooks/use-theme';
|
||||
|
||||
|
@ -16,7 +17,6 @@ function Welcome() {
|
|||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, error, signIn } = useLogto();
|
||||
const href = useHref('/callback');
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -28,7 +28,7 @@ function Welcome() {
|
|||
|
||||
if (error) {
|
||||
if (error instanceof LogtoClientError) {
|
||||
return <SessionExpired error={error} callbackHref={href} />;
|
||||
return <SessionExpired error={error} />;
|
||||
}
|
||||
|
||||
return <AppError errorMessage={error.message} callStack={error.stack} />;
|
||||
|
@ -50,7 +50,7 @@ function Welcome() {
|
|||
type="branding"
|
||||
title="welcome.create_account"
|
||||
onClick={() => {
|
||||
void signIn(new URL(href, window.location.origin).toString());
|
||||
void signIn(getCallbackUrl().href);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue