0
Fork 0
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:
Gao Sun 2023-06-26 18:32:15 +08:00 committed by GitHub
commit 71650832d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 498 additions and 479 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +0,0 @@
export enum CloudRoute {
Callback = 'callback',
SocialDemoCallback = 'social-demo-callback',
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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