Skip to content

Commit

Permalink
feat(console): implement interim landing page for new users to join i…
Browse files Browse the repository at this point in the history
…nvited tenants (#5560)
  • Loading branch information
charIeszhao authored Mar 28, 2024
1 parent 6990a3e commit f83e85b
Show file tree
Hide file tree
Showing 11 changed files with 237 additions and 16 deletions.
2 changes: 1 addition & 1 deletion packages/connectors/connector-logto-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@
"access": "public"
},
"devDependencies": {
"@logto/cloud": "0.2.5-2a72cc4"
"@logto/cloud": "0.2.5-81f06ea"
}
}
2 changes: 1 addition & 1 deletion packages/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@fontsource/roboto-mono": "^5.0.0",
"@jest/types": "^29.5.0",
"@logto/app-insights": "workspace:^1.4.0",
"@logto/cloud": "0.2.5-2a72cc4",
"@logto/cloud": "0.2.5-81f06ea",
"@logto/connector-kit": "workspace:^2.1.0",
"@logto/core-kit": "workspace:^2.3.0",
"@logto/language-kit": "workspace:^1.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
@use '@/scss/underscore' as _;

.container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 600px;
background: var(--color-surface-1);
align-items: center;
justify-content: center;
overflow-y: auto;

.wrapper {
display: flex;
flex-direction: column;
width: 540px;
padding: _.unit(20) _.unit(17.5);
gap: _.unit(6);
background: var(--color-bg-float);
border-radius: 16px;
box-shadow: var(--shadow-1);
white-space: pre-wrap;

.icon {
width: 40px;
height: 40px;
flex-shrink: 0;
}

.title {
font: var(--font-headline-2);
}

.description {
font: var(--font-body-2);
color: var(--color-text-secondary);
}

.tenant {
display: flex;
align-items: center;
padding: _.unit(3) _.unit(4);
gap: _.unit(3);
border-radius: 12px;
border: 1px solid var(--color-divider);

.name {
@include _.multi-line-ellipsis(2);
}

.tag {
margin-left: _.unit(-2);
}
}

.separator {
display: flex;
align-items: center;
gap: _.unit(4);

span {
font: var(--font-body-2);
color: var(--color-text-secondary);
}

hr {
flex: 1;
border: none;
border-top: 1px solid var(--color-divider);
}
}

.createTenantButton {
width: 100%;
}
}
}
89 changes: 89 additions & 0 deletions packages/console/src/cloud/pages/Main/InvitationList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { OrganizationInvitationStatus } from '@logto/schemas';
import { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';

import Icon from '@/assets/icons/organization-preview.svg';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type TenantResponse, type InvitationListResponse } from '@/cloud/types/router';
import CreateTenantModal from '@/components/CreateTenantModal';
import TenantEnvTag from '@/components/TenantEnvTag';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import Spacer from '@/ds-components/Spacer';

import * as styles from './index.module.scss';

type Props = {
invitations: InvitationListResponse;
};

function InvitationList({ invitations }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const cloudApi = useCloudApi();
const { prependTenant, navigateTenant } = useContext(TenantsContext);
const [isJoining, setIsJoining] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);

return (
<>
<div className={styles.container}>
<div className={styles.wrapper}>
<div className={styles.title}>{t('invitation.find_your_tenants')}</div>
<div className={styles.description}>{t('invitation.find_tenants_description')}</div>
{invitations.map(({ id, organizationId, tenantName, tenantTag }) => (
<div key={id} className={styles.tenant}>
<Icon className={styles.icon} />
<span className={styles.name}>{tenantName}</span>
<TenantEnvTag isAbbreviated className={styles.tag} tag={tenantTag} />
<Spacer />
<Button
size="small"
type="primary"
title="general.join"
isLoading={isJoining}
onClick={async () => {
setIsJoining(true);
try {
await cloudApi.patch(`/api/invitations/:invitationId/status`, {
params: { invitationId: id },
body: { status: OrganizationInvitationStatus.Accepted },
});
navigateTenant(organizationId.slice(2));
} finally {
setIsJoining(false);
}
}}
/>
</div>
))}
<div className={styles.separator}>
<hr />
<span>{t('general.or')}</span>
<hr />
</div>
<Button
size="large"
type="outline"
className={styles.createTenantButton}
title="invitation.create_new_tenant"
onClick={() => {
setIsCreateModalOpen(true);
}}
/>
</div>
</div>
<CreateTenantModal
isOpen={isCreateModalOpen}
onClose={async (tenant?: TenantResponse) => {
if (tenant) {
prependTenant(tenant);
navigateTenant(tenant.id);
}
setIsCreateModalOpen(false);
}}
/>
</>
);
}

export default InvitationList;
11 changes: 11 additions & 0 deletions packages/console/src/cloud/pages/Main/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { OrganizationInvitationStatus } from '@logto/schemas';

import AppLoading from '@/components/AppLoading';
import { isCloud } from '@/consts/env';
import useCurrentUser from '@/hooks/use-current-user';
import useUserDefaultTenantId from '@/hooks/use-user-default-tenant-id';
import useUserInvitations from '@/hooks/use-user-invitations';
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';

import AutoCreateTenant from './AutoCreateTenant';
import InvitationList from './InvitationList';
import Redirect from './Redirect';
import TenantLandingPage from './TenantLandingPage';

export default function Main() {
const { isLoaded } = useCurrentUser();
const { isOnboarding } = useUserOnboardingData();
const { defaultTenantId } = useUserDefaultTenantId();
const { data } = useUserInvitations(OrganizationInvitationStatus.Pending);

if (!isLoaded) {
return <AppLoading />;
Expand All @@ -26,6 +32,11 @@ export default function Main() {
return <AutoCreateTenant />;
}

// If user has pending invitations (onboarding will be skipped), show the invitation list and allow them to quick join.
if (isCloud && data?.length) {
return <InvitationList invitations={data} />;
}

// If user has completed onboarding and still has no tenant, redirect to a special landing page.
return <TenantLandingPage />;
}
2 changes: 2 additions & 0 deletions packages/console/src/cloud/types/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export type InvoicesResponse = GuardedResponse<GetRoutes['/api/tenants/:tenantId

export type InvitationResponse = GuardedResponse<GetRoutes['/api/invitations/:invitationId']>;

export type InvitationListResponse = GuardedResponse<GetRoutes['/api/invitations']>;

// The response of GET /api/tenants is TenantResponse[].
export type TenantResponse = GetArrayElementType<GuardedResponse<GetRoutes['/api/tenants']>>;

Expand Down
42 changes: 42 additions & 0 deletions packages/console/src/hooks/use-user-invitations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { type OrganizationInvitationStatus } from '@logto/schemas';
import { type Optional } from '@silverhand/essentials';
import { useMemo } from 'react';
import useSWR from 'swr';

import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type InvitationListResponse } from '@/cloud/types/router';

import { type RequestError } from './use-api';

/**
*
* @param status Filter invitations by status
* @returns The invitations with tenant info, error, and loading status.
*/
const useUserInvitations = (
status?: OrganizationInvitationStatus
): {
data: Optional<InvitationListResponse>;
error: Optional<RequestError>;
isLoading: boolean;
} => {
const cloudApi = useCloudApi({ hideErrorToast: true });
const { data, isLoading, error } = useSWR<InvitationListResponse, RequestError>(
`/api/invitations}`,
async () => cloudApi.get('/api/invitations')
);

// Filter invitations by given status
const filteredResult = useMemo(
() => (status ? data?.filter((invitation) => status === invitation.status) : data),
[data, status]
);

return {
data: filteredResult,
error,
isLoading,
};
};

export default useUserInvitations;
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
.container {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
height: 100%;
min-height: 600px;
background: var(--color-surface-1);
align-items: center;
justify-content: center;
overflow: hidden;
overflow-y: auto;

.wrapper {
display: flex;
Expand Down
4 changes: 2 additions & 2 deletions packages/console/src/pages/AcceptInvitation/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ function AcceptInvitation() {
return;
}
(async () => {
const { id, tenantId } = invitation;
const { id, organizationId } = 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);
navigateTenant(organizationId.slice(2));
})();
}, [cloudApi, error, invitation, navigateTenant, t]);

Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@logto/cloud": "0.2.5-2a72cc4",
"@logto/cloud": "0.2.5-81f06ea",
"@silverhand/eslint-config": "5.0.0",
"@silverhand/ts-config": "5.0.0",
"@types/debug": "^4.1.7",
Expand Down
16 changes: 8 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit f83e85b

Please sign in to comment.