0
Fork 0
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:
Gao Sun 2023-03-01 12:37:11 +08:00 committed by GitHub
parent bd158a46fe
commit f111128320
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 199 additions and 87 deletions

View file

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

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

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

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

View file

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

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

View file

@ -25,3 +25,5 @@ export const getUserTenantId = () => {
};
export const getBasename = () => (isCloud ? '/' + getUserTenantId() : ossConsolePath);
export const getSignOutRedirectPathname = () => (isCloud ? '/' : ossConsolePath);

View file

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

View file

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

View file

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

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