diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx index 9db1a229c3c..451560e44fb 100644 --- a/packages/console/src/App.tsx +++ b/packages/console/src/App.tsx @@ -1,6 +1,6 @@ import { AppInsightsBoundary } from '@logto/app-insights/react'; import { UserScope } from '@logto/core-kit'; -import { LogtoProvider, useLogto } from '@logto/react'; +import { LogtoProvider, Prompt, useLogto } from '@logto/react'; import { adminConsoleApplicationId, defaultTenantId, @@ -110,6 +110,7 @@ function Providers() { appId: adminConsoleApplicationId, resources, scopes, + prompt: [Prompt.Login, Prompt.Consent], }} > diff --git a/packages/console/src/cloud/AppRoutes.tsx b/packages/console/src/cloud/AppRoutes.tsx index e133831edac..dca40a83dbd 100644 --- a/packages/console/src/cloud/AppRoutes.tsx +++ b/packages/console/src/cloud/AppRoutes.tsx @@ -1,7 +1,9 @@ import { Route, Routes } from 'react-router-dom'; +import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import ProtectedRoutes from '@/containers/ProtectedRoutes'; import { GlobalAnonymousRoute, GlobalRoute } from '@/contexts/TenantsProvider'; +import AcceptInvitation from '@/pages/AcceptInvitation'; import Callback from '@/pages/Callback'; import CheckoutSuccessCallback from '@/pages/CheckoutSuccessCallback'; @@ -17,6 +19,12 @@ function AppRoutes() { } /> } /> }> + {isDevFeaturesEnabled && isCloud && ( + } + /> + )} } /> } /> diff --git a/packages/console/src/cloud/types/router.ts b/packages/console/src/cloud/types/router.ts index 67ccba04067..28cd8c17a17 100644 --- a/packages/console/src/cloud/types/router.ts +++ b/packages/console/src/cloud/types/router.ts @@ -17,9 +17,12 @@ export type SubscriptionUsage = GuardedResponse; +export type InvitationResponse = GuardedResponse; + // The response of GET /api/tenants is TenantResponse[]. export type TenantResponse = GetArrayElementType>; +// Start of the auth routes types. Accessing the auth routes requires an organization token. export type TenantMemberResponse = GetArrayElementType< GuardedResponse >; @@ -27,3 +30,4 @@ export type TenantMemberResponse = GetArrayElementType< export type TenantInvitationResponse = GetArrayElementType< GuardedResponse >; +// End of the auth routes types diff --git a/packages/console/src/contexts/TenantsProvider.tsx b/packages/console/src/contexts/TenantsProvider.tsx index bed76496101..2a99686001a 100644 --- a/packages/console/src/contexts/TenantsProvider.tsx +++ b/packages/console/src/contexts/TenantsProvider.tsx @@ -28,6 +28,7 @@ export enum GlobalAnonymousRoute { */ export enum GlobalRoute { CheckoutSuccessCallback = '/checkout-success-callback', + AcceptInvitation = '/accept', } const reservedRoutes: Readonly = Object.freeze([ @@ -101,7 +102,12 @@ function TenantsProvider({ children }: Props) { return defaultTenantId; } - if (!match || reservedRoutes.includes(match.pathname)) { + if ( + !match || + reservedRoutes.some( + (route) => match.pathname === route || match.pathname.startsWith(route + '/') + ) + ) { return ''; } diff --git a/packages/console/src/pages/AcceptInvitation/SwitchAccount/index.module.scss b/packages/console/src/pages/AcceptInvitation/SwitchAccount/index.module.scss new file mode 100644 index 00000000000..351fbdbdd41 --- /dev/null +++ b/packages/console/src/pages/AcceptInvitation/SwitchAccount/index.module.scss @@ -0,0 +1,43 @@ +@use '@/scss/underscore' as _; + +.container { + display: flex; + flex-direction: column; + width: 100vw; + height: 100vh; + background: var(--color-surface-1); + align-items: center; + justify-content: center; + overflow: hidden; + + .wrapper { + display: flex; + flex-direction: column; + align-items: center; + width: 540px; + padding: _.unit(35) _.unit(17.5); + gap: _.unit(6); + background: var(--color-bg-float); + border-radius: 16px; + box-shadow: var(--shadow-1); + text-align: center; + white-space: pre-wrap; + + .logo { + height: 36px; + } + + .title { + font: var(--font-title-2); + } + + .description { + font: var(--font-body-2); + color: var(--color-text-secondary); + } + + .button { + width: 100%; + } + } +} diff --git a/packages/console/src/pages/AcceptInvitation/SwitchAccount/index.tsx b/packages/console/src/pages/AcceptInvitation/SwitchAccount/index.tsx new file mode 100644 index 00000000000..5c1d7047cec --- /dev/null +++ b/packages/console/src/pages/AcceptInvitation/SwitchAccount/index.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; + +import Logo from '@/assets/images/logo.svg'; +import Button from '@/ds-components/Button'; +import useCurrentUser from '@/hooks/use-current-user'; + +import * as styles from './index.module.scss'; + +type Props = { + onClickSwitch: () => void; +}; + +function SwitchAccount({ onClickSwitch }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { user } = useCurrentUser(); + const { id, primaryEmail, username } = user ?? {}; + + return ( +
+
+ +
+ {/** Since this is a Logto Cloud feature, ideally the primary email should always be available. + * However, in case it's not (e.g. in dev env), we fallback to username and then finally the ID. + */} + {t('invitation.email_not_match_title', { email: primaryEmail ?? username ?? id })} +
+
{t('invitation.email_not_match_description')}
+
+
+ ); +} + +export default SwitchAccount; diff --git a/packages/console/src/pages/AcceptInvitation/index.tsx b/packages/console/src/pages/AcceptInvitation/index.tsx new file mode 100644 index 00000000000..4bd66ea24c3 --- /dev/null +++ b/packages/console/src/pages/AcceptInvitation/index.tsx @@ -0,0 +1,71 @@ +import { useLogto } from '@logto/react'; +import { OrganizationInvitationStatus } from '@logto/schemas'; +import { useContext, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import useSWR from 'swr'; + +import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; +import { type InvitationResponse } from '@/cloud/types/router'; +import AppError from '@/components/AppError'; +import AppLoading from '@/components/AppLoading'; +import { TenantsContext } from '@/contexts/TenantsProvider'; +import { type RequestError } from '@/hooks/use-api'; +import useRedirectUri from '@/hooks/use-redirect-uri'; + +import SwitchAccount from './SwitchAccount'; + +function AcceptInvitation() { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { signIn } = useLogto(); + const redirectUri = useRedirectUri(); + const { invitationId = '' } = useParams(); + const cloudApi = useCloudApi(); + const { navigateTenant } = useContext(TenantsContext); + + // The request is only made when the user has signed-in and the invitation ID is available. + // The response data is returned only when the current user matches the invitee email. Otherwise, it returns 404. + const { data: invitation, error } = useSWR( + invitationId && `/api/invitations/${invitationId}`, + async () => cloudApi.get('/api/invitations/:invitationId', { params: { invitationId } }) + ); + + useEffect(() => { + if (!invitation) { + return; + } + (async () => { + const { id, tenantId } = invitation; + + // Accept the invitation and redirect to the tenant page. + await cloudApi.patch(`/api/invitations/:invitationId/status`, { + params: { invitationId: id }, + body: { status: OrganizationInvitationStatus.Accepted }, + }); + + navigateTenant(tenantId); + })(); + }, [cloudApi, error, invitation, navigateTenant, t]); + + // No invitation returned, indicating the current signed-in user is not the invitee. + if (error?.status === 404) { + return ( + { + void signIn({ + redirectUri: redirectUri.href, + loginHint: `urn:logto:invitation:${invitationId}`, + }); + }} + /> + ); + } + + if (invitation && invitation.status !== OrganizationInvitationStatus.Pending) { + return ; + } + + return ; +} + +export default AcceptInvitation;