mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat(console): support multi-tenancy (2/2) (#3247)
This commit is contained in:
parent
bd158a46fe
commit
f111128320
11 changed files with 199 additions and 87 deletions
|
@ -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 ? (
|
||||
<AppEndpointsProvider>
|
||||
<Main />
|
||||
</AppEndpointsProvider>
|
||||
|
|
45
packages/console/src/cloud/pages/Main/Redirect.tsx
Normal file
45
packages/console/src/cloud/pages/Main/Redirect.tsx
Normal file
|
@ -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 <div>Forbidden</div>;
|
||||
}
|
||||
|
||||
return <AppLoadingOffline />;
|
||||
};
|
||||
|
||||
export default Redirect;
|
41
packages/console/src/cloud/pages/Main/Tenants.tsx
Normal file
41
packages/console/src/cloud/pages/Main/Tenants.tsx
Normal file
|
@ -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 (
|
||||
<div className={styles.wrapper}>
|
||||
<h3>Choose a tenant</h3>
|
||||
{data.map(({ id }) => (
|
||||
<a key={id} href={'/' + id}>
|
||||
<Button title={<DangerousRaw>{id}</DangerousRaw>} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AppLoadingOffline />;
|
||||
};
|
||||
|
||||
export default Tenants;
|
13
packages/console/src/cloud/pages/Main/index.module.scss
Normal file
13
packages/console/src/cloud/pages/Main/index.module.scss
Normal file
|
@ -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);
|
||||
}
|
|
@ -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<TenantInfo[]>();
|
||||
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<TenantInfo[]>();
|
||||
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 <div>no tenant, should automatically create one</div>;
|
||||
if (currentTenantId) {
|
||||
return <Redirect tenants={tenants} toTenantId={currentTenantId} />;
|
||||
}
|
||||
|
||||
if (data.length === 1) {
|
||||
return <div>single tenant: {data[0]?.id}, should automatically redirect</div>;
|
||||
}
|
||||
|
||||
if (data.length > 1) {
|
||||
return (
|
||||
<div>multiple tenants: {data.map(({ id }) => id).join(', ')}, should let user choose</div>
|
||||
);
|
||||
}
|
||||
return <Tenants data={tenants} />;
|
||||
}
|
||||
|
||||
return <AppLoading />;
|
||||
return <AppLoadingOffline />;
|
||||
};
|
||||
|
||||
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 <AppLoadingOffline />;
|
||||
}
|
||||
|
||||
return <Protected />;
|
||||
};
|
||||
|
||||
export default Main;
|
||||
|
|
25
packages/console/src/components/AppLoading/Offline.tsx
Normal file
25
packages/console/src/components/AppLoading/Offline.tsx
Normal file
|
@ -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 (
|
||||
<div className={styles.container}>
|
||||
{theme === 'light' ? <Illustration /> : <IllustrationDark />}
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -25,3 +25,5 @@ export const getUserTenantId = () => {
|
|||
};
|
||||
|
||||
export const getBasename = () => (isCloud ? '/' + getUserTenantId() : ossConsolePath);
|
||||
|
||||
export const getSignOutRedirectPathname = () => (isCloud ? '/' : ossConsolePath);
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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>({
|
||||
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 <TenantsContext.Provider value={memorizedContext}>{children}</TenantsContext.Provider>;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
12
packages/console/src/utils/theme.ts
Normal file
12
packages/console/src/utils/theme.ts
Normal file
|
@ -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;
|
||||
};
|
Loading…
Add table
Reference in a new issue