Skip to content

Commit

Permalink
feat(console): add console landing page to accept user invitation
Browse files Browse the repository at this point in the history
  • Loading branch information
charIeszhao committed Mar 26, 2024
1 parent 4766d22 commit 79cea64
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 2 deletions.
3 changes: 2 additions & 1 deletion packages/console/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -110,6 +110,7 @@ function Providers() {
appId: adminConsoleApplicationId,
resources,
scopes,
prompt: [Prompt.Login, Prompt.Consent],
}}
>
<AppThemeProvider>
Expand Down
8 changes: 8 additions & 0 deletions packages/console/src/cloud/AppRoutes.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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>
Expand Down
4 changes: 4 additions & 0 deletions packages/console/src/cloud/types/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ 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']>
>;

export type TenantInvitationResponse = GetArrayElementType<
GuardedResponse<GetTenantAuthRoutes['/api/tenants/:tenantId/invitations']>
>;
// End of the auth routes types
8 changes: 7 additions & 1 deletion packages/console/src/contexts/TenantsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export enum GlobalAnonymousRoute {
*/
export enum GlobalRoute {
CheckoutSuccessCallback = '/checkout-success-callback',
AcceptInvitation = '/accept',
}

const reservedRoutes: Readonly<string[]> = Object.freeze([
Expand Down Expand Up @@ -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 '';
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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%;
}
}
}
Original file line number Diff line number Diff line change
@@ -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 changes: 71 additions & 0 deletions packages/console/src/pages/AcceptInvitation/index.tsx
Original file line number Diff line number Diff line change
@@ -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;

0 comments on commit 79cea64

Please sign in to comment.