mirror of
https://github.com/logto-io/logto.git
synced 2025-03-17 22:31:28 -05:00
feat(console): add console landing page to accept user invitation (#5554)
This commit is contained in:
parent
c7a23dfe92
commit
2961b5399c
7 changed files with 176 additions and 2 deletions
|
@ -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],
|
||||
}}
|
||||
>
|
||||
<AppThemeProvider>
|
||||
|
|
|
@ -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() {
|
|||
<Route path={GlobalAnonymousRoute.Callback} element={<Callback />} />
|
||||
<Route path={GlobalAnonymousRoute.SocialDemoCallback} element={<SocialDemoCallback />} />
|
||||
<Route element={<ProtectedRoutes />}>
|
||||
{isDevFeaturesEnabled && isCloud && (
|
||||
<Route
|
||||
path={`${GlobalRoute.AcceptInvitation}/:invitationId`}
|
||||
element={<AcceptInvitation />}
|
||||
/>
|
||||
)}
|
||||
<Route path={GlobalRoute.CheckoutSuccessCallback} element={<CheckoutSuccessCallback />} />
|
||||
<Route index element={<Main />} />
|
||||
</Route>
|
||||
|
|
|
@ -17,9 +17,12 @@ export type SubscriptionUsage = GuardedResponse<GetRoutes['/api/tenants/:tenantI
|
|||
|
||||
export type InvoicesResponse = GuardedResponse<GetRoutes['/api/tenants/:tenantId/invoices']>;
|
||||
|
||||
export type InvitationResponse = GuardedResponse<GetRoutes['/api/invitations/:invitationId']>;
|
||||
|
||||
// The response of GET /api/tenants is TenantResponse[].
|
||||
export type TenantResponse = GetArrayElementType<GuardedResponse<GetRoutes['/api/tenants']>>;
|
||||
|
||||
// Start of the auth routes types. Accessing the auth routes requires an organization token.
|
||||
export type TenantMemberResponse = GetArrayElementType<
|
||||
GuardedResponse<GetTenantAuthRoutes['/api/tenants/:tenantId/members']>
|
||||
>;
|
||||
|
@ -27,3 +30,4 @@ export type TenantMemberResponse = GetArrayElementType<
|
|||
export type TenantInvitationResponse = GetArrayElementType<
|
||||
GuardedResponse<GetTenantAuthRoutes['/api/tenants/:tenantId/invitations']>
|
||||
>;
|
||||
// End of the auth routes types
|
||||
|
|
|
@ -28,6 +28,7 @@ export enum GlobalAnonymousRoute {
|
|||
*/
|
||||
export enum GlobalRoute {
|
||||
CheckoutSuccessCallback = '/checkout-success-callback',
|
||||
AcceptInvitation = '/accept',
|
||||
}
|
||||
|
||||
const reservedRoutes: Readonly<string[]> = 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 '';
|
||||
}
|
||||
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.wrapper}>
|
||||
<Logo className={styles.logo} />
|
||||
<div className={styles.title}>
|
||||
{/** 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 })}
|
||||
</div>
|
||||
<div className={styles.description}>{t('invitation.email_not_match_description')}</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className={styles.button}
|
||||
title="invitation.switch_account"
|
||||
onClick={onClickSwitch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SwitchAccount;
|
71
packages/console/src/pages/AcceptInvitation/index.tsx
Normal file
71
packages/console/src/pages/AcceptInvitation/index.tsx
Normal file
|
@ -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<InvitationResponse, RequestError>(
|
||||
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 (
|
||||
<SwitchAccount
|
||||
onClickSwitch={() => {
|
||||
void signIn({
|
||||
redirectUri: redirectUri.href,
|
||||
loginHint: `urn:logto:invitation:${invitationId}`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (invitation && invitation.status !== OrganizationInvitationStatus.Pending) {
|
||||
return <AppError errorMessage={t('invitation.invalid_invitation_status')} />;
|
||||
}
|
||||
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
export default AcceptInvitation;
|
Loading…
Add table
Reference in a new issue