mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
Merge pull request #4074 from logto-io/gao-optimize-tenant-hooks
refactor(console): optimize tenant context and hooks
This commit is contained in:
commit
71650832d0
31 changed files with 498 additions and 479 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, isSettle, currentTenantId } = useContext(TenantsContext);
|
||||
const { tenants, currentTenantId } = useContext(TenantsContext);
|
||||
|
||||
const resources = useMemo(
|
||||
() =>
|
||||
|
@ -39,7 +39,7 @@ function Content() {
|
|||
// access a URL with Tenant ID, adding the ID from the URL here can possibly remove one
|
||||
// additional redirect.
|
||||
currentTenantId && getManagementApi(currentTenantId).indicator,
|
||||
...(tenants ?? []).map(({ id }) => getManagementApi(id).indicator),
|
||||
...tenants.map(({ id }) => getManagementApi(id).indicator),
|
||||
isCloud && cloudApi.indicator,
|
||||
meApi.indicator
|
||||
)
|
||||
|
@ -76,7 +76,11 @@ function Content() {
|
|||
<AppInsightsBoundary cloudRole="console">
|
||||
<Helmet titleTemplate={`%s - ${mainTitle}`} defaultTitle={mainTitle} />
|
||||
<ErrorBoundary>
|
||||
{!isCloud || isSettle ? (
|
||||
{/**
|
||||
* 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 ? (
|
||||
<AppEndpointsProvider>
|
||||
<AppConfirmModalProvider>
|
||||
<TenantAppContainer />
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import ProtectedRoutes 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 element={<ProtectedRoutes />}>
|
||||
<Route path="*" element={<Main />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
import type router from '@logto/cloud/routes';
|
||||
import { type RouterRoutes } from '@withtyped/client';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { useCloudApi } from './use-cloud-api';
|
||||
|
||||
type GetRoutes = RouterRoutes<typeof router>['get'];
|
||||
|
||||
const normalizeError = (error: unknown) => {
|
||||
if (error === undefined || error === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
};
|
||||
|
||||
export const useCloudSwr = <Key extends keyof GetRoutes>(key: Key) => {
|
||||
const cloudApi = useCloudApi();
|
||||
const response = useSWR(key, async () => cloudApi.get(key));
|
||||
|
||||
// By default, `useSWR()` uses `any` for the error type which is unexpected under our lint rule set.
|
||||
return { ...response, error: normalizeError(response.error) };
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
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,45 +1,17 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import type { TenantInfo } from '@logto/schemas/models';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { useHref } from 'react-router-dom';
|
||||
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
||||
type Props = {
|
||||
tenants: TenantInfo[];
|
||||
toTenantId: string;
|
||||
};
|
||||
|
||||
function Redirect({ tenants, toTenantId }: Props) {
|
||||
const { getAccessToken, signIn } = useLogto();
|
||||
const tenant = tenants.find(({ id }) => id === toTenantId);
|
||||
const { setIsSettle, navigate } = useContext(TenantsContext);
|
||||
const href = useHref(toTenantId + '/callback');
|
||||
function Redirect() {
|
||||
const { navigateTenant, tenants, currentTenant } = useContext(TenantsContext);
|
||||
|
||||
useEffect(() => {
|
||||
const validate = async (indicator: string) => {
|
||||
// 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))) {
|
||||
setIsSettle(true);
|
||||
navigate(toTenantId);
|
||||
} else {
|
||||
void signIn(new URL(href, window.location.origin).toString());
|
||||
}
|
||||
};
|
||||
|
||||
if (tenant) {
|
||||
void validate(tenant.indicator);
|
||||
if (!currentTenant) {
|
||||
/** Fallback to another available tenant instead of showing `Forbidden`. */
|
||||
navigateTenant(tenants[0]?.id ?? '');
|
||||
}
|
||||
}, [getAccessToken, href, navigate, setIsSettle, signIn, tenant, toTenantId]);
|
||||
|
||||
if (!tenant) {
|
||||
/** Fallback to another available tenant instead of showing `Forbidden`. */
|
||||
navigate(tenants[0]?.id ?? '');
|
||||
}
|
||||
}, [navigateTenant, currentTenant, tenants]);
|
||||
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
import { Theme } from '@logto/schemas';
|
||||
import type { TenantInfo } from '@logto/schemas/models';
|
||||
import classNames from 'classnames';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useContext, useState } from 'react';
|
||||
|
||||
import Plus from '@/assets/icons/plus.svg';
|
||||
import TenantLandingPageImageDark from '@/assets/images/tenant-landing-page-dark.svg';
|
||||
import TenantLandingPageImage from '@/assets/images/tenant-landing-page.svg';
|
||||
import { useCloudSwr } from '@/cloud/hooks/use-cloud-swr';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import Button from '@/ds-components/Button';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
|
@ -21,21 +19,12 @@ type Props = {
|
|||
};
|
||||
|
||||
function TenantLandingPageContent({ className }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { tenants, setTenants } = useContext(TenantsContext);
|
||||
const { data: availableTenants, mutate } = useCloudSwr('/api/tenants');
|
||||
|
||||
useEffect(() => {
|
||||
if (availableTenants) {
|
||||
setTenants(availableTenants);
|
||||
}
|
||||
}, [availableTenants, setTenants]);
|
||||
|
||||
const { tenants, prependTenant, navigateTenant } = useContext(TenantsContext);
|
||||
const theme = useTheme();
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
if (tenants?.length) {
|
||||
if (tenants.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -66,8 +55,8 @@ function TenantLandingPageContent({ className }: Props) {
|
|||
isOpen={isCreateModalOpen}
|
||||
onClose={async (tenant?: TenantInfo) => {
|
||||
if (tenant) {
|
||||
void mutate();
|
||||
window.location.assign(new URL(`/${tenant.id}`, window.location.origin).toString());
|
||||
prependTenant(tenant);
|
||||
navigateTenant(tenant.id);
|
||||
}
|
||||
setIsCreateModalOpen(false);
|
||||
}}
|
||||
|
|
|
@ -1,116 +1,34 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { conditional, yes } from '@silverhand/essentials';
|
||||
import { HTTPError } from 'ky';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useHref, useSearchParams } from 'react-router-dom';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import AppError from '@/components/AppError';
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import SessionExpired from '@/components/SessionExpired';
|
||||
import { searchKeys } from '@/consts';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
||||
|
||||
import AutoCreateTenant from './AutoCreateTenant';
|
||||
import Redirect from './Redirect';
|
||||
import TenantLandingPage from './TenantLandingPage';
|
||||
|
||||
function Protected() {
|
||||
const api = useCloudApi();
|
||||
const { tenants, setTenants, currentTenantId, navigate } = useContext(TenantsContext);
|
||||
export default function Main() {
|
||||
const { tenants } = useContext(TenantsContext);
|
||||
const { isOnboarding, isLoaded } = useUserOnboardingData();
|
||||
const [error, setError] = useState<Error>();
|
||||
|
||||
useEffect(() => {
|
||||
const loadTenants = async () => {
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const data = await api.get('/api/tenants');
|
||||
setTenants(data);
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
if (!tenants) {
|
||||
void loadTenants();
|
||||
}
|
||||
}, [api, setTenants, tenants]);
|
||||
|
||||
useEffect(() => {
|
||||
const createFirstTenant = async () => {
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const newTenant = await api.post('/api/tenants', { body: {} }); // Use DB default value.
|
||||
setTenants([newTenant]);
|
||||
navigate(newTenant.id);
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoaded && isOnboarding && tenants?.length === 0) {
|
||||
void createFirstTenant();
|
||||
}
|
||||
}, [api, isOnboarding, isLoaded, setTenants, tenants, navigate]);
|
||||
|
||||
if (error) {
|
||||
if (error instanceof HTTPError && error.response.status === 401) {
|
||||
return <SessionExpired error={error} />;
|
||||
}
|
||||
|
||||
return <AppError errorMessage={error.message} callStack={error.stack} />;
|
||||
}
|
||||
|
||||
if (tenants) {
|
||||
/**
|
||||
* Redirect to the first tenant if the current tenant ID is not set or can not be found.
|
||||
*
|
||||
* `currentTenantId` can be empty string, so that Boolean is required and `??` is
|
||||
* not applicable for current case.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line no-extra-boolean-cast
|
||||
const toTenantId = Boolean(currentTenantId) ? currentTenantId : tenants[0]?.id;
|
||||
if (toTenantId) {
|
||||
return <Redirect tenants={tenants} toTenantId={toTenantId} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Will create a new tenant for new users that need go through onboarding process,
|
||||
* but create tenant takes time, the screen will have a glance of landing page of empty tenant.
|
||||
*/
|
||||
if (isLoaded && !isOnboarding) {
|
||||
return <TenantLandingPage />;
|
||||
}
|
||||
}
|
||||
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!isLoaded) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
return <Protected />;
|
||||
}
|
||||
/**
|
||||
* If current tenant ID is not set, but there is at least one tenant available,
|
||||
* trigger a redirect to the first tenant.
|
||||
*/
|
||||
if (tenants[0]) {
|
||||
return <Redirect />;
|
||||
}
|
||||
|
||||
export default Main;
|
||||
// A new user has just signed up and has no tenant, needs to create a new tenant.
|
||||
if (isOnboarding) {
|
||||
return <AutoCreateTenant />;
|
||||
}
|
||||
|
||||
// If user has completed onboarding and still has no tenant, redirect to a special landing page.
|
||||
return <TenantLandingPage />;
|
||||
}
|
||||
|
|
|
@ -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,6 +1,5 @@
|
|||
import { defaultTenantId, ossConsolePath } from '@logto/schemas';
|
||||
|
||||
import { CloudRoute } from '@/cloud/types';
|
||||
import { conditionalArray } from '@silverhand/essentials';
|
||||
|
||||
import { adminEndpoint, isCloud } from './env';
|
||||
|
||||
|
@ -23,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;
|
||||
|
@ -36,6 +34,13 @@ export const getUserTenantId = () => {
|
|||
|
||||
export const getBasename = () => (isCloud ? '/' + getUserTenantId() : ossConsolePath);
|
||||
|
||||
export const getCallbackUrl = (tenantId?: string) =>
|
||||
new URL(
|
||||
// Only Cloud has tenantId in callback URL
|
||||
'/' + conditionalArray(isCloud ? tenantId : 'console', 'callback').join('/'),
|
||||
window.location.origin
|
||||
);
|
||||
|
||||
export const getSignOutRedirectPathname = () => (isCloud ? '/' : ossConsolePath);
|
||||
|
||||
export const maxFreeTenantNumbers = 3;
|
||||
|
|
|
@ -112,6 +112,12 @@ $dropdown-item-height: 40px;
|
|||
}
|
||||
|
||||
.createTenantButton {
|
||||
all: unset;
|
||||
/**
|
||||
* `inline-size: stretch` is needed since button will have the used value `inline-size: fit-content` by default.
|
||||
* @see {@link https://html.spec.whatwg.org/multipage/rendering.html#button-layout}
|
||||
*/
|
||||
inline-size: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: _.unit(2.5) _.unit(3) _.unit(2.5) _.unit(4);
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
import { adminTenantId } from '@logto/schemas';
|
||||
import { type TenantInfo } from '@logto/schemas/models';
|
||||
import classNames from 'classnames';
|
||||
import { useContext, useRef, useState, useEffect, useMemo } from 'react';
|
||||
import { useContext, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import KeyboardArrowDown from '@/assets/icons/keyboard-arrow-down.svg';
|
||||
import PlusSign from '@/assets/icons/plus.svg';
|
||||
import Tick from '@/assets/icons/tick.svg';
|
||||
import { useCloudSwr } from '@/cloud/hooks/use-cloud-swr';
|
||||
import CreateTenantModal from '@/cloud/pages/Main/TenantLandingPage/TenantLandingPageContent/CreateTenantModal';
|
||||
import AppError from '@/components/AppError';
|
||||
import { maxFreeTenantNumbers } from '@/consts/tenants';
|
||||
import { maxFreeTenantNumbers } from '@/consts';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import Divider from '@/ds-components/Divider';
|
||||
import Dropdown, { DropdownItem } from '@/ds-components/Dropdown';
|
||||
|
@ -20,39 +18,27 @@ import { onKeyDownHandler } from '@/utils/a11y';
|
|||
import TenantEnvTag from './TenantEnvTag';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
function TenantSelector() {
|
||||
export default function TenantSelector() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { tenants, setTenants, currentTenantId } = useContext(TenantsContext);
|
||||
const { data: availableTenants, mutate, error } = useCloudSwr('/api/tenants');
|
||||
|
||||
useEffect(() => {
|
||||
if (availableTenants) {
|
||||
setTenants(availableTenants);
|
||||
}
|
||||
}, [availableTenants, setTenants]);
|
||||
|
||||
const currentTenantInfo = useMemo(() => {
|
||||
return tenants?.find((tenant) => tenant.id === currentTenantId);
|
||||
}, [currentTenantId, tenants]);
|
||||
const {
|
||||
tenants,
|
||||
prependTenant,
|
||||
currentTenant: currentTenantInfo,
|
||||
currentTenantId,
|
||||
navigateTenant,
|
||||
} = useContext(TenantsContext);
|
||||
|
||||
const isCreateButtonDisabled = useMemo(
|
||||
() =>
|
||||
/** Should not block admin tenant owners from creating more than three tenants */
|
||||
tenants &&
|
||||
!tenants.some(({ id }) => id === adminTenantId) &&
|
||||
tenants.length >= maxFreeTenantNumbers,
|
||||
!tenants.some(({ id }) => id === adminTenantId) && tenants.length >= maxFreeTenantNumbers,
|
||||
[tenants]
|
||||
);
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [showCreateTenantModal, setShowCreateTenantModal] = useState(false);
|
||||
|
||||
if (error) {
|
||||
return <AppError errorMessage={error.message} callStack={error.stack} />;
|
||||
}
|
||||
|
||||
if (!tenants?.length || !currentTenantInfo) {
|
||||
if (tenants.length === 0 || !currentTenantInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -90,7 +76,8 @@ function TenantSelector() {
|
|||
key={id}
|
||||
className={styles.dropdownItem}
|
||||
onClick={() => {
|
||||
window.open(new URL(`/${id}`, window.location.origin).toString(), '_self');
|
||||
navigateTenant(id);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
>
|
||||
<div className={styles.dropdownName}>{name}</div>
|
||||
|
@ -102,36 +89,30 @@ function TenantSelector() {
|
|||
))}
|
||||
</OverlayScrollbar>
|
||||
<Divider />
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
tabIndex={0}
|
||||
className={classNames(
|
||||
isCreateButtonDisabled && styles.disabled,
|
||||
styles.createTenantButton
|
||||
)}
|
||||
disabled={isCreateButtonDisabled}
|
||||
onClick={() => {
|
||||
if (isCreateButtonDisabled) {
|
||||
return;
|
||||
}
|
||||
setShowCreateTenantModal(true);
|
||||
}}
|
||||
onKeyDown={onKeyDownHandler(() => {
|
||||
if (isCreateButtonDisabled) {
|
||||
return;
|
||||
}
|
||||
setShowCreateTenantModal(true);
|
||||
})}
|
||||
>
|
||||
<div>{t('cloud.tenant.create_tenant')}</div>
|
||||
<PlusSign />
|
||||
</div>
|
||||
</button>
|
||||
</Dropdown>
|
||||
<CreateTenantModal
|
||||
isOpen={showCreateTenantModal}
|
||||
onClose={async (tenant?: TenantInfo) => {
|
||||
if (tenant) {
|
||||
void mutate();
|
||||
window.location.assign(new URL(`/${tenant.id}`, window.location.origin).toString());
|
||||
prependTenant(tenant);
|
||||
navigateTenant(tenant.id);
|
||||
}
|
||||
setShowCreateTenantModal(false);
|
||||
}}
|
||||
|
@ -139,5 +120,3 @@ function TenantSelector() {
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantSelector;
|
||||
|
|
|
@ -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';
|
||||
|
@ -21,8 +21,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,12 +34,6 @@ 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]);
|
||||
|
||||
useEffect(() => {
|
||||
// Navigate to the first menu item after configs are loaded.
|
||||
if (!isLoading && location.pathname === '/') {
|
||||
|
@ -48,9 +41,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 +57,6 @@ function AppContent() {
|
|||
return <AppError errorMessage={error.message} callStack={error.stack} />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated || isLoading) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.app}>
|
||||
|
|
|
@ -3,10 +3,9 @@ import { HTTPError } from 'ky';
|
|||
import type { ReactNode } from 'react';
|
||||
import { Component } from 'react';
|
||||
|
||||
import AppError from '@/components/AppError';
|
||||
import SessionExpired from '@/components/SessionExpired';
|
||||
|
||||
import AppError from '../../components/AppError';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
@ -15,6 +14,14 @@ type State = {
|
|||
error?: Error;
|
||||
};
|
||||
|
||||
/**
|
||||
* An error boundary that catches errors in its children.
|
||||
* Note it uses several hooks and contexts:
|
||||
*
|
||||
* - Includes `useLogto()` (depending on `<LogtoProvider />`) to provide a "Sign in again" button for session expired errors.
|
||||
* - Includes `useTranslation()` to provide translations for the error messages.
|
||||
* - includes `useTheme()` (depending on `<AppThemeProvider />`) to provide the local stored theme.
|
||||
*/
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
|
|
59
packages/console/src/containers/ProtectedRoutes/index.tsx
Normal file
59
packages/console/src/containers/ProtectedRoutes/index.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { yes, conditional } from '@silverhand/essentials';
|
||||
import { useContext, useEffect } 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';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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 && !isInitComplete) {
|
||||
const loadTenants = async () => {
|
||||
const data = await api.get('/api/tenants');
|
||||
resetTenants(data);
|
||||
};
|
||||
|
||||
void loadTenants();
|
||||
}
|
||||
}, [api, isAuthenticated, isInitComplete, resetTenants]);
|
||||
|
||||
if (!isInitComplete || !isAuthenticated) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
85
packages/console/src/containers/TenantAccess/index.tsx
Normal file
85
packages/console/src/containers/TenantAccess/index.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
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: tenantId }: 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(tenantId).href);
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthenticated && currentTenantId && currentTenantStatus === 'pending') {
|
||||
setCurrentTenantStatus('validating');
|
||||
|
||||
// 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.
|
||||
if (!currentTenant) {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
void validate(currentTenant);
|
||||
}
|
||||
}, [
|
||||
currentTenant,
|
||||
currentTenantId,
|
||||
currentTenantStatus,
|
||||
getAccessToken,
|
||||
isAuthenticated,
|
||||
setCurrentTenantStatus,
|
||||
signIn,
|
||||
]);
|
||||
|
||||
return currentTenantStatus === 'validated' ? <Outlet /> : <AppLoading />;
|
||||
}
|
|
@ -1,28 +1,57 @@
|
|||
import { useContext } from 'react';
|
||||
import { useLogto } from '@logto/react';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import { getBasename } from '@/consts';
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
|
||||
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';
|
||||
|
||||
/** @deprecated Remove this layer. */
|
||||
function TenantAppContainer() {
|
||||
const { userEndpoint } = useContext(AppEndpointsContext);
|
||||
const { isOnboarding, isLoaded } = useUserOnboardingData();
|
||||
const { isAuthenticated } = useLogto();
|
||||
|
||||
const router = useMemo(
|
||||
() =>
|
||||
createBrowserRouter(
|
||||
[
|
||||
{
|
||||
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()`
|
||||
// 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() }
|
||||
),
|
||||
[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 />;
|
||||
}
|
||||
|
||||
if (isOnboarding) {
|
||||
return <OnboardingApp />;
|
||||
}
|
||||
|
||||
return <ConsoleApp />;
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default TenantAppContainer;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { defaultManagementApi } from '@logto/schemas';
|
||||
import { type TenantInfo, TenantTag } from '@logto/schemas/models';
|
||||
import { conditional, noop } from '@silverhand/essentials';
|
||||
import { conditionalArray, noop } from '@silverhand/essentials';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo, createContext, useState } from 'react';
|
||||
import type { NavigateOptions } from 'react-router-dom';
|
||||
|
@ -8,72 +8,128 @@ import type { NavigateOptions } from 'react-router-dom';
|
|||
import { isCloud } from '@/consts/env';
|
||||
import { getUserTenantId } from '@/consts/tenants';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
/**
|
||||
* The current tenant status of access validation. When it's `validated`, it indicates that a
|
||||
* valid Access Token for the current tenant is available.
|
||||
*/
|
||||
type CurrentTenantStatus = 'pending' | 'validating' | 'validated';
|
||||
|
||||
/** @see {@link TenantsProvider} for why `useSWR()` is not applicable for this context. */
|
||||
type Tenants = {
|
||||
tenants?: TenantInfo[];
|
||||
isSettle: boolean;
|
||||
setTenants: (tenants: TenantInfo[]) => void;
|
||||
setIsSettle: (isSettle: boolean) => void;
|
||||
tenants: readonly TenantInfo[];
|
||||
/** Indicates if the tenants data is ready for the first render. */
|
||||
isInitComplete: boolean;
|
||||
/** Reset tenants to the given value. It will overwrite the current tenants data and set `isInitComplete` to `true`. */
|
||||
resetTenants: (tenants: TenantInfo[]) => void;
|
||||
/** Prepend a new tenant to the current tenants data. */
|
||||
prependTenant: (tenant: TenantInfo) => void;
|
||||
/** Remove a tenant by ID from the current tenants data. */
|
||||
removeTenant: (tenantId: string) => void;
|
||||
/** Update a tenant by ID if it exists in the current tenants data. */
|
||||
updateTenant: (tenantId: string, data: Partial<TenantInfo>) => void;
|
||||
/**
|
||||
* The current tenant ID that the URL is pointing to. It is navigated programmatically
|
||||
* since there's [no easy way](https://stackoverflow.com/questions/34999976/detect-changes-on-the-url)
|
||||
* to listen to the URL change without polling.
|
||||
*/
|
||||
currentTenantId: string;
|
||||
setCurrentTenantId: (tenantId: string) => void;
|
||||
navigate: (tenantId: string, options?: NavigateOptions) => void;
|
||||
currentTenant?: TenantInfo;
|
||||
/**
|
||||
* Indicates if the Access Token has been validated for use. Will be reset to `pending` when the current tenant changes.
|
||||
*
|
||||
* @see {@link CurrentTenantStatus}
|
||||
*/
|
||||
currentTenantStatus: CurrentTenantStatus;
|
||||
setCurrentTenantStatus: (status: CurrentTenantStatus) => void;
|
||||
navigateTenant: (tenantId: string, options?: NavigateOptions) => void;
|
||||
};
|
||||
|
||||
const { tenantId, indicator } = defaultManagementApi.resource;
|
||||
const initialTenants = conditional(
|
||||
!isCloud && [
|
||||
{ id: tenantId, name: `tenant_${tenantId}`, tag: TenantTag.Development, indicator }, // Make `tag` value to be string type.
|
||||
]
|
||||
|
||||
/**
|
||||
* - For cloud, the initial tenants data is empty, and it will be fetched from the cloud API.
|
||||
* - OSS has a fixed tenant with ID `default` and no cloud API to dynamically fetch tenants.
|
||||
*/
|
||||
const initialTenants = Object.freeze(
|
||||
conditionalArray(
|
||||
!isCloud && { id: tenantId, name: `tenant_${tenantId}`, tag: TenantTag.Development, indicator }
|
||||
)
|
||||
);
|
||||
|
||||
export const TenantsContext = createContext<Tenants>({
|
||||
tenants: initialTenants,
|
||||
setTenants: noop,
|
||||
isSettle: false,
|
||||
setIsSettle: noop,
|
||||
isInitComplete: false,
|
||||
resetTenants: noop,
|
||||
prependTenant: noop,
|
||||
removeTenant: noop,
|
||||
updateTenant: noop,
|
||||
currentTenantId: '',
|
||||
setCurrentTenantId: noop,
|
||||
navigate: 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;
|
||||
* also for navigating between tenants.
|
||||
*
|
||||
* Note it is not practical to use `useSWR()` for tenants context, since fetching tenants
|
||||
* requires authentication, and the authentication is managed by the `LogtoProvider` which
|
||||
* depends and locates inside the `TenantsProvider`. Thus the fetching tenants action should
|
||||
* be done by a component inside the `LogtoProvider`, which `useSWR()` cannot handle.
|
||||
*/
|
||||
function TenantsProvider({ children }: Props) {
|
||||
const [tenants, setTenants] = useState(initialTenants);
|
||||
const [isSettle, setIsSettle] = useState(false);
|
||||
/** @see {@link initialTenants} */
|
||||
const [isInitComplete, setIsInitComplete] = useState(!isCloud);
|
||||
const [currentTenantId, setCurrentTenantId] = useState(getUserTenantId());
|
||||
const [currentTenantStatus, setCurrentTenantStatus] = useState<CurrentTenantStatus>('pending');
|
||||
|
||||
const navigate = useCallback((tenantId: string, options?: NavigateOptions) => {
|
||||
if (options?.replace) {
|
||||
window.history.replaceState(
|
||||
options.state ?? {},
|
||||
'',
|
||||
new URL(`/${tenantId}`, window.location.origin).toString()
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.pushState(
|
||||
options?.state ?? {},
|
||||
'',
|
||||
new URL(`/${tenantId}`, window.location.origin).toString()
|
||||
);
|
||||
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);
|
||||
setCurrentTenantStatus('pending');
|
||||
}, []);
|
||||
|
||||
const currentTenant = useMemo(
|
||||
() => tenants.find((tenant) => tenant.id === currentTenantId),
|
||||
[currentTenantId, tenants]
|
||||
);
|
||||
|
||||
const memorizedContext = useMemo(
|
||||
() => ({
|
||||
tenants,
|
||||
setTenants,
|
||||
isSettle,
|
||||
setIsSettle,
|
||||
resetTenants: (tenants: TenantInfo[]) => {
|
||||
setTenants(tenants);
|
||||
setCurrentTenantStatus('pending');
|
||||
setIsInitComplete(true);
|
||||
},
|
||||
prependTenant: (tenant: TenantInfo) => {
|
||||
setTenants((tenants) => [tenant, ...tenants]);
|
||||
},
|
||||
removeTenant: (tenantId: string) => {
|
||||
setTenants((tenants) => tenants.filter((tenant) => tenant.id !== tenantId));
|
||||
},
|
||||
updateTenant: (tenantId: string, data: Partial<TenantInfo>) => {
|
||||
setTenants((tenants) =>
|
||||
tenants.map((tenant) => (tenant.id === tenantId ? { ...tenant, ...data } : tenant))
|
||||
);
|
||||
},
|
||||
isInitComplete,
|
||||
currentTenantId,
|
||||
setCurrentTenantId,
|
||||
navigate,
|
||||
currentTenant,
|
||||
currentTenantStatus,
|
||||
setCurrentTenantStatus,
|
||||
navigateTenant,
|
||||
}),
|
||||
[currentTenantId, isSettle, navigate, tenants]
|
||||
[currentTenant, currentTenantId, currentTenantStatus, isInitComplete, navigateTenant, tenants]
|
||||
);
|
||||
|
||||
return <TenantsContext.Provider value={memorizedContext}>{children}</TenantsContext.Provider>;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import type { UserProfileResponse } from '@logto/schemas';
|
||||
import useSWR from 'swr';
|
||||
|
||||
|
@ -5,22 +6,21 @@ import { adminTenantEndpoint, meApi } from '@/consts';
|
|||
|
||||
import type { RequestError } from './use-api';
|
||||
import { useStaticApi } from './use-api';
|
||||
import useLogtoUserId from './use-logto-user-id';
|
||||
import useSwrFetcher from './use-swr-fetcher';
|
||||
|
||||
const useCurrentUser = () => {
|
||||
const userId = useLogtoUserId();
|
||||
const { isAuthenticated } = useLogto();
|
||||
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
|
||||
const fetcher = useSwrFetcher<UserProfileResponse>(api);
|
||||
const {
|
||||
data: user,
|
||||
error,
|
||||
mutate,
|
||||
} = useSWR<UserProfileResponse, RequestError>(userId && 'me', fetcher);
|
||||
} = useSWR<UserProfileResponse, RequestError>(isAuthenticated && 'me', fetcher);
|
||||
|
||||
const isLoading = !user && !error;
|
||||
|
||||
return { user, isLoading, error, reload: mutate };
|
||||
return { user, isLoading, error, reload: mutate, api };
|
||||
};
|
||||
|
||||
export default useCurrentUser;
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const useLogtoUserId = () => {
|
||||
const { getIdTokenClaims, isAuthenticated } = useLogto();
|
||||
const [userId, setUserId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
const claims = await getIdTokenClaims();
|
||||
setUserId(claims?.sub);
|
||||
};
|
||||
|
||||
if (isAuthenticated) {
|
||||
void fetch();
|
||||
} else {
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
setUserId(undefined);
|
||||
}
|
||||
}, [getIdTokenClaims, isAuthenticated]);
|
||||
|
||||
return userId;
|
||||
};
|
||||
|
||||
export default useLogtoUserId;
|
|
@ -1,52 +1,38 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { type JsonObject } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { adminTenantEndpoint, meApi } from '@/consts';
|
||||
|
||||
import type { RequestError } from './use-api';
|
||||
import { useStaticApi } from './use-api';
|
||||
import useLogtoUserId from './use-logto-user-id';
|
||||
import useSwrFetcher from './use-swr-fetcher';
|
||||
import useCurrentUser from './use-current-user';
|
||||
|
||||
const useMeCustomData = () => {
|
||||
const { isAuthenticated, error: authError } = useLogto();
|
||||
const { user, isLoading, error, reload, api } = useCurrentUser();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const userId = useLogtoUserId();
|
||||
const shouldFetch = isAuthenticated && !authError && userId;
|
||||
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
|
||||
|
||||
const fetcher = useSwrFetcher(api);
|
||||
|
||||
const { data, mutate, error } = useSWR<unknown, RequestError>(
|
||||
shouldFetch && `me/custom-data`,
|
||||
fetcher
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (data: Record<string, unknown>) => {
|
||||
if (!userId) {
|
||||
async (customData: JsonObject) => {
|
||||
if (!user) {
|
||||
toast.error(t('errors.unexpected_error'));
|
||||
|
||||
return;
|
||||
}
|
||||
const updated = await api
|
||||
.patch(`me/custom-data`, {
|
||||
json: data,
|
||||
})
|
||||
.json();
|
||||
await mutate(updated);
|
||||
|
||||
await reload({
|
||||
...user,
|
||||
customData: await api
|
||||
.patch(`me/custom-data`, {
|
||||
json: customData,
|
||||
})
|
||||
.json<JsonObject>(),
|
||||
});
|
||||
},
|
||||
[api, mutate, t, userId]
|
||||
[api, reload, t, user]
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
data: user?.customData,
|
||||
error,
|
||||
isLoading: !data && !error,
|
||||
isLoaded: Boolean(data && !error),
|
||||
isLoading,
|
||||
isLoaded: !isLoading && !error,
|
||||
update,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 TenantAccess from '@/containers/TenantAccess';
|
||||
import { AppThemeContext } from '@/contexts/AppThemeProvider';
|
||||
import Toast from '@/ds-components/Toast';
|
||||
import useSwrOptions from '@/hooks/use-swr-options';
|
||||
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);
|
||||
|
@ -67,15 +68,15 @@ function App() {
|
|||
|
||||
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 +91,32 @@ 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={<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>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
|
@ -21,12 +21,12 @@ import * as styles from './index.module.scss';
|
|||
function Congrats() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { update } = useUserOnboardingData();
|
||||
const { navigate, currentTenantId } = useContext(TenantsContext);
|
||||
const { navigateTenant, currentTenantId } = useContext(TenantsContext);
|
||||
|
||||
const enterAdminConsole = () => {
|
||||
void update({ isOnboardingDone: true });
|
||||
// Note: navigate to the admin console page directly instead of using the router
|
||||
navigate(currentTenantId);
|
||||
navigateTenant(currentTenantId);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,18 +1,13 @@
|
|||
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 { Outlet, Route, Routes } 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 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';
|
||||
|
@ -20,35 +15,35 @@ import Welcome from '@/pages/Welcome';
|
|||
|
||||
import HandleSocialCallback from '../Profile/containers/HandleSocialCallback';
|
||||
|
||||
function Main() {
|
||||
function Layout() {
|
||||
const swrOptions = useSwrOptions();
|
||||
const router = useMemo(
|
||||
() =>
|
||||
createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<>
|
||||
<Route path="callback" element={<Callback />} />
|
||||
<Route path="welcome" element={<Welcome />} />
|
||||
<Route path="handle-social" element={<HandleSocialCallback />} />
|
||||
<Route element={<AppContent />}>
|
||||
<Route path="/*" element={<ConsoleContent />} />
|
||||
</Route>
|
||||
</>
|
||||
),
|
||||
{ basename: getBasename() }
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<SWRConfig value={swrOptions}>
|
||||
<AppBoundary>
|
||||
<TrackOnce component={Component.Console} event={GeneralEvent.Visit} />
|
||||
<Toast />
|
||||
<RouterProvider router={router} />
|
||||
<Outlet />
|
||||
</AppBoundary>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
|
||||
export default Main;
|
||||
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={<TenantAccess />}>
|
||||
<Route element={<AppContent />}>
|
||||
<Route path="*" element={<ConsoleContent />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { adminTenantId, defaultTenantId } from '@logto/schemas';
|
||||
import { adminTenantId } from '@logto/schemas';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FormCard from '@/components/FormCard';
|
||||
|
@ -25,7 +25,7 @@ function DeleteCard({ currentTenantId, onClick }: Props) {
|
|||
<Button
|
||||
type="default"
|
||||
title="tenants.deletion_card.tenant_deletion_button"
|
||||
disabled={[adminTenantId, defaultTenantId].includes(currentTenantId)}
|
||||
disabled={[adminTenantId].includes(currentTenantId)}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
import { type TenantInfo, TenantTag } from '@logto/schemas/models';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect, useState, useContext, useMemo } from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { useCloudSwr } from '@/cloud/hooks/use-cloud-swr';
|
||||
import AppError from '@/components/AppError';
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import SubmitFormChangesActionBar from '@/components/SubmitFormChangesActionBar';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
|
@ -29,30 +27,12 @@ const tenantProfileToForm = (tenant?: TenantInfo): TenantSettingsForm => {
|
|||
function TenantBasicSettings() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const api = useCloudApi();
|
||||
const { tenants, setTenants, currentTenantId } = useContext(TenantsContext);
|
||||
const { data: availableTenants, mutate, error: requestError } = useCloudSwr('/api/tenants');
|
||||
const isLoading = !availableTenants && !requestError;
|
||||
|
||||
useEffect(() => {
|
||||
if (availableTenants) {
|
||||
setTenants(availableTenants);
|
||||
}
|
||||
}, [availableTenants, setTenants]);
|
||||
|
||||
const currentTenant = useMemo(() => {
|
||||
return tenants?.find((tenant) => tenant.id === currentTenantId);
|
||||
}, [currentTenantId, tenants]);
|
||||
|
||||
const { currentTenant, currentTenantId, tenants, updateTenant, removeTenant } =
|
||||
useContext(TenantsContext);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [isDeletionModalOpen, setIsDeletionModalOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestError) {
|
||||
setError(requestError);
|
||||
}
|
||||
}, [requestError]);
|
||||
|
||||
const methods = useForm<TenantSettingsForm>({
|
||||
defaultValues: tenantProfileToForm(currentTenant),
|
||||
});
|
||||
|
@ -74,8 +54,8 @@ function TenantBasicSettings() {
|
|||
body: data,
|
||||
});
|
||||
reset({ profile: { name, tag } });
|
||||
void mutate();
|
||||
toast.success(t('tenants.settings.tenant_info_saved'));
|
||||
updateTenant(currentTenantId, data);
|
||||
} catch (error: unknown) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
|
@ -109,7 +89,7 @@ function TenantBasicSettings() {
|
|||
try {
|
||||
await api.delete(`/api/tenants/:tenantId`, { params: { tenantId: currentTenantId } });
|
||||
setIsDeletionModalOpen(false);
|
||||
void mutate();
|
||||
removeTenant(currentTenantId);
|
||||
} catch (error: unknown) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
|
@ -121,24 +101,6 @@ function TenantBasicSettings() {
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Redirect to the first tenant if the current tenant is deleted;
|
||||
* Redirect to Cloud console landing page if there is no tenant.
|
||||
*/
|
||||
if (tenants && !tenants.some(({ id }) => id === currentTenantId)) {
|
||||
window.location.assign(
|
||||
tenants[0]?.id
|
||||
? new URL(`/${tenants[0]?.id}`, window.location.origin).toString()
|
||||
: new URL(window.location.origin).toString()
|
||||
);
|
||||
}
|
||||
}, [currentTenantId, tenants]);
|
||||
|
||||
if (isLoading) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <AppError errorMessage={error.message} callStack={error.stack} />;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -75,7 +75,7 @@ describe('smoke testing for cloud', () => {
|
|||
await expect(page).toClick('div[role=button][class$=item]');
|
||||
|
||||
// Click the next button
|
||||
await expect(page).toClick('div[class$=actions] button:first-child');
|
||||
await expect(page).toClick('div[class$=actions] button', { text: 'Next' });
|
||||
|
||||
// Wait for the next page to load
|
||||
await expect(page).toMatchElement('div[class$=config] div[class$=title]', {
|
||||
|
@ -127,7 +127,7 @@ describe('smoke testing for cloud', () => {
|
|||
|
||||
await page.waitForTimeout(500);
|
||||
const createTenantButton = await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class$=dropdownContainer] > div[class$=dropdown] > div[class$=createTenantButton][role=button]:has(div)'
|
||||
'div[class$=ReactModalPortal] div[class$=dropdownContainer] > div[class$=dropdown] > button[class$=createTenantButton]:has(div)'
|
||||
);
|
||||
await expect(createTenantButton).toMatchElement('div', { text: 'Create tenant' });
|
||||
await createTenantButton.click();
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
cursor: pointer;
|
||||
font: var(--font-label-1);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.2s ease-in-out;
|
||||
user-select: none;
|
||||
|
|
Loading…
Add table
Reference in a new issue