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