diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx
index 7c1170085..bf92a8457 100644
--- a/packages/console/src/App.tsx
+++ b/packages/console/src/App.tsx
@@ -23,14 +23,12 @@ import Main from './pages/Main';
void initI18n();
const Content = () => {
- const {
- tenants: { data, isSettle },
- } = useContext(TenantsContext);
+ const { tenants, isSettle } = useContext(TenantsContext);
const currentTenantId = getUserTenantId();
const resources = deduplicate([
...(currentTenantId && [getManagementApi(currentTenantId).indicator]),
- ...(data ?? []).map(({ id }) => getManagementApi(id).indicator),
+ ...(tenants ?? []).map(({ id }) => getManagementApi(id).indicator),
...(isCloud ? [cloudApi.indicator] : []),
meApi.indicator,
]);
@@ -51,7 +49,7 @@ const Content = () => {
scopes,
}}
>
- {!isCloud || (data && isSettle && currentTenantId) ? (
+ {!isCloud || isSettle ? (
diff --git a/packages/console/src/cloud/pages/Main/Redirect.tsx b/packages/console/src/cloud/pages/Main/Redirect.tsx
new file mode 100644
index 000000000..c1f897d29
--- /dev/null
+++ b/packages/console/src/cloud/pages/Main/Redirect.tsx
@@ -0,0 +1,45 @@
+import { useLogto } from '@logto/react';
+import type { TenantInfo } from '@logto/schemas';
+import { trySafe } from '@silverhand/essentials';
+import { useContext, useEffect } from 'react';
+import { useHref } from 'react-router-dom';
+
+import { AppLoadingOffline } from '@/components/AppLoading/Offline';
+import { TenantsContext } from '@/contexts/TenantsProvider';
+
+type Props = {
+ tenants: TenantInfo[];
+ toTenantId: string;
+};
+
+const Redirect = ({ tenants, toTenantId }: Props) => {
+ const { getAccessToken, signIn } = useLogto();
+ const tenant = tenants.find(({ id }) => id === toTenantId);
+ const { setIsSettle } = useContext(TenantsContext);
+ const href = useHref(toTenantId + '/callback');
+
+ 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);
+ } else {
+ void signIn(new URL(href, window.location.origin).toString());
+ }
+ };
+
+ if (tenant) {
+ void validate(tenant.indicator);
+ }
+ }, [getAccessToken, href, setIsSettle, signIn, tenant]);
+
+ if (!tenant) {
+ return
Forbidden
;
+ }
+
+ return ;
+};
+
+export default Redirect;
diff --git a/packages/console/src/cloud/pages/Main/Tenants.tsx b/packages/console/src/cloud/pages/Main/Tenants.tsx
new file mode 100644
index 000000000..9d52a43b2
--- /dev/null
+++ b/packages/console/src/cloud/pages/Main/Tenants.tsx
@@ -0,0 +1,41 @@
+import type { TenantInfo } from '@logto/schemas';
+import { useEffect } from 'react';
+
+import { AppLoadingOffline } from '@/components/AppLoading/Offline';
+import Button from '@/components/Button';
+import DangerousRaw from '@/components/DangerousRaw';
+
+import * as styles from './index.module.scss';
+
+type Props = {
+ data: TenantInfo[];
+};
+
+const Tenants = ({ data }: Props) => {
+ useEffect(() => {
+ if (data.length <= 1) {
+ if (data[0]) {
+ window.location.assign('/' + data[0].id);
+ } else {
+ // Todo: create tenant
+ }
+ }
+ }, [data]);
+
+ if (data.length > 1) {
+ return (
+
+
Choose a tenant
+ {data.map(({ id }) => (
+
+
+ ))}
+
+ );
+ }
+
+ return ;
+};
+
+export default Tenants;
diff --git a/packages/console/src/cloud/pages/Main/index.module.scss b/packages/console/src/cloud/pages/Main/index.module.scss
new file mode 100644
index 000000000..977206f68
--- /dev/null
+++ b/packages/console/src/cloud/pages/Main/index.module.scss
@@ -0,0 +1,13 @@
+@use '@/scss/underscore' as _;
+
+.wrapper {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: _.unit(4);
+}
diff --git a/packages/console/src/cloud/pages/Main/index.tsx b/packages/console/src/cloud/pages/Main/index.tsx
index 746fe681c..ee9eb6279 100644
--- a/packages/console/src/cloud/pages/Main/index.tsx
+++ b/packages/console/src/cloud/pages/Main/index.tsx
@@ -1,77 +1,59 @@
import { useLogto } from '@logto/react';
import type { TenantInfo } from '@logto/schemas';
-import { trySafe } from '@silverhand/essentials';
-import { useCallback, useContext, useEffect } from 'react';
-import { useHref, useNavigate } from 'react-router-dom';
+import { useContext, useEffect } from 'react';
+import { useHref } from 'react-router-dom';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
-import AppLoading from '@/components/AppLoading';
+import { AppLoadingOffline } from '@/components/AppLoading/Offline';
import { getUserTenantId } from '@/consts/tenants';
import { TenantsContext } from '@/contexts/TenantsProvider';
-const Main = () => {
- const { isAuthenticated, isLoading, signIn, getAccessToken } = useLogto();
+import Redirect from './Redirect';
+import Tenants from './Tenants';
+
+const Protected = () => {
const api = useCloudApi();
- const {
- tenants: { data, isSettle },
- setTenants,
- } = useContext(TenantsContext);
- const navigate = useNavigate();
- const href = useHref(getUserTenantId() + '/callback');
-
- const loadTenants = useCallback(async () => {
- const data = await api.get('/api/tenants').json();
- const currentId = getUserTenantId();
- const current = data.find(({ id }) => id === currentId);
-
- if (currentId) {
- if (current) {
- // 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(current.indicator)))) {
- setTenants({ data, isSettle: false });
-
- return;
- }
- } else {
- // TODO: this tenant id is not in the list, should show an error
- navigate('/', { replace: true });
- }
- }
-
- setTenants({ data, isSettle: true });
- }, [api, getAccessToken, navigate, setTenants]);
+ const { tenants, setTenants } = useContext(TenantsContext);
useEffect(() => {
- if (isAuthenticated && data === undefined) {
+ const loadTenants = async () => {
+ const data = await api.get('/api/tenants').json();
+ setTenants(data);
+ };
+
+ if (!tenants) {
void loadTenants();
}
- }, [data, isAuthenticated, loadTenants]);
+ }, [api, setTenants, tenants]);
- useEffect(() => {
- if ((!isLoading && !isAuthenticated) || (isAuthenticated && !isSettle)) {
- void signIn(new URL(href, window.location.origin).toString());
- }
- }, [href, isAuthenticated, isLoading, isSettle, signIn]);
+ if (tenants) {
+ const currentTenantId = getUserTenantId();
- if (data) {
- if (data.length === 0) {
- return no tenant, should automatically create one
;
+ if (currentTenantId) {
+ return ;
}
- if (data.length === 1) {
- return single tenant: {data[0]?.id}, should automatically redirect
;
- }
-
- if (data.length > 1) {
- return (
- multiple tenants: {data.map(({ id }) => id).join(', ')}, should let user choose
- );
- }
+ return ;
}
- return ;
+ return ;
+};
+
+const Main = () => {
+ const { isAuthenticated, isLoading, signIn } = useLogto();
+ const href = useHref(getUserTenantId() + '/callback');
+
+ useEffect(() => {
+ if (!isLoading && !isAuthenticated) {
+ void signIn(new URL(href, window.location.origin).toString());
+ }
+ }, [href, isAuthenticated, isLoading, signIn]);
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return ;
};
export default Main;
diff --git a/packages/console/src/components/AppLoading/Offline.tsx b/packages/console/src/components/AppLoading/Offline.tsx
new file mode 100644
index 000000000..4e62602a6
--- /dev/null
+++ b/packages/console/src/components/AppLoading/Offline.tsx
@@ -0,0 +1,25 @@
+import { AppearanceMode } from '@logto/schemas';
+import { trySafe } from '@silverhand/essentials';
+import { z } from 'zod';
+
+import IllustrationDark from '@/assets/images/loading-illustration-dark.svg';
+import Illustration from '@/assets/images/loading-illustration.svg';
+import { Daisy as Spinner } from '@/components/Spinner';
+import { themeStorageKey } from '@/consts';
+import { getTheme } from '@/utils/theme';
+
+import * as styles from './index.module.scss';
+
+export const AppLoadingOffline = () => {
+ const theme = getTheme(
+ trySafe(() => z.nativeEnum(AppearanceMode).parse(localStorage.getItem(themeStorageKey))) ??
+ AppearanceMode.SyncWithSystem
+ );
+
+ return (
+
+ {theme === 'light' ? : }
+
+
+ );
+};
diff --git a/packages/console/src/consts/tenants.ts b/packages/console/src/consts/tenants.ts
index 894047963..06381110a 100644
--- a/packages/console/src/consts/tenants.ts
+++ b/packages/console/src/consts/tenants.ts
@@ -25,3 +25,5 @@ export const getUserTenantId = () => {
};
export const getBasename = () => (isCloud ? '/' + getUserTenantId() : ossConsolePath);
+
+export const getSignOutRedirectPathname = () => (isCloud ? '/' : ossConsolePath);
diff --git a/packages/console/src/containers/AppLayout/components/UserInfo/index.tsx b/packages/console/src/containers/AppLayout/components/UserInfo/index.tsx
index 2ef929f97..24d61d13f 100644
--- a/packages/console/src/containers/AppLayout/components/UserInfo/index.tsx
+++ b/packages/console/src/containers/AppLayout/components/UserInfo/index.tsx
@@ -16,7 +16,7 @@ import Dropdown, { DropdownItem } from '@/components/Dropdown';
import Spacer from '@/components/Spacer';
import { Ring as Spinner } from '@/components/Spinner';
import UserAvatar from '@/components/UserAvatar';
-import { getBasename } from '@/consts';
+import { getSignOutRedirectPathname } from '@/consts';
import useUserPreferences from '@/hooks/use-user-preferences';
import { onKeyDownHandler } from '@/utils/a11y';
@@ -145,7 +145,7 @@ const UserInfo = () => {
return;
}
setIsLoading(true);
- void signOut(new URL(getBasename(), window.location.origin).toString());
+ void signOut(new URL(getSignOutRedirectPathname(), window.location.origin).toString());
}}
>
{t('menu.sign_out')}
diff --git a/packages/console/src/contexts/TenantsProvider.tsx b/packages/console/src/contexts/TenantsProvider.tsx
index 1e1467be9..677d14f4f 100644
--- a/packages/console/src/contexts/TenantsProvider.tsx
+++ b/packages/console/src/contexts/TenantsProvider.tsx
@@ -1,6 +1,6 @@
import type { TenantInfo } from '@logto/schemas';
import { defaultManagementApi } from '@logto/schemas';
-import { conditional } from '@silverhand/essentials';
+import { conditional, noop } from '@silverhand/essentials';
import type { ReactNode } from 'react';
import { useMemo, createContext, useState } from 'react';
@@ -10,29 +10,30 @@ type Props = {
children: ReactNode;
};
-type Payload = { data?: TenantInfo[]; isSettle: boolean };
-
export type Tenants = {
- tenants: Payload;
- setTenants: (payload: Payload) => void;
+ tenants?: TenantInfo[];
+ isSettle: boolean;
+ setTenants: (tenants: TenantInfo[]) => void;
+ setIsSettle: (isSettle: boolean) => void;
};
const { tenantId, indicator } = defaultManagementApi.resource;
-const initialPayload: Payload = {
- data: conditional(!isCloud && [{ id: tenantId, indicator }]),
- isSettle: true,
-};
+const initialTenants = conditional(!isCloud && [{ id: tenantId, indicator }]);
export const TenantsContext = createContext({
- tenants: initialPayload,
- setTenants: () => {
- throw new Error('Not implemented');
- },
+ tenants: initialTenants,
+ setTenants: noop,
+ isSettle: false,
+ setIsSettle: noop,
});
const TenantsProvider = ({ children }: Props) => {
- const [tenants, setTenants] = useState(initialPayload);
- const memorizedContext = useMemo(() => ({ tenants, setTenants }), [tenants]);
+ const [tenants, setTenants] = useState(initialTenants);
+ const [isSettle, setIsSettle] = useState(false);
+ const memorizedContext = useMemo(
+ () => ({ tenants, setTenants, isSettle, setIsSettle }),
+ [isSettle, tenants]
+ );
return {children};
};
diff --git a/packages/console/src/hooks/use-theme.ts b/packages/console/src/hooks/use-theme.ts
index 4e2045f9c..8b853b892 100644
--- a/packages/console/src/hooks/use-theme.ts
+++ b/packages/console/src/hooks/use-theme.ts
@@ -1,4 +1,4 @@
-import { AppearanceMode } from '@logto/schemas';
+import { getTheme } from '@/utils/theme';
import useUserPreferences from './use-user-preferences';
@@ -7,12 +7,5 @@ export const useTheme = () => {
data: { appearanceMode },
} = useUserPreferences();
- if (appearanceMode !== AppearanceMode.SyncWithSystem) {
- return appearanceMode;
- }
-
- const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)');
- const theme = darkThemeWatchMedia.matches ? AppearanceMode.DarkMode : AppearanceMode.LightMode;
-
- return theme;
+ return getTheme(appearanceMode);
};
diff --git a/packages/console/src/utils/theme.ts b/packages/console/src/utils/theme.ts
new file mode 100644
index 000000000..b1598ea51
--- /dev/null
+++ b/packages/console/src/utils/theme.ts
@@ -0,0 +1,12 @@
+import { AppearanceMode } from '@logto/schemas';
+
+export const getTheme = (appearanceMode: AppearanceMode) => {
+ if (appearanceMode !== AppearanceMode.SyncWithSystem) {
+ return appearanceMode;
+ }
+
+ const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)');
+ const theme = darkThemeWatchMedia.matches ? AppearanceMode.DarkMode : AppearanceMode.LightMode;
+
+ return theme;
+};