0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(console): implement interim landing page for new users to join invited tenants (#5560)

This commit is contained in:
Charles Zhao 2024-03-28 10:26:30 +08:00 committed by GitHub
parent 6990a3ebb5
commit f83e85ba55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 237 additions and 16 deletions

View file

@ -48,6 +48,6 @@
"access": "public"
},
"devDependencies": {
"@logto/cloud": "0.2.5-2a72cc4"
"@logto/cloud": "0.2.5-81f06ea"
}
}

View file

@ -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",

View file

@ -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%;
}
}
}

View file

@ -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;

View file

@ -1,9 +1,14 @@
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';
@ -11,6 +16,7 @@ export default function Main() {
const { isLoaded } = useCurrentUser();
const { isOnboarding } = useUserOnboardingData();
const { defaultTenantId } = useUserDefaultTenantId();
const { data } = useUserInvitations(OrganizationInvitationStatus.Pending);
if (!isLoaded) {
return <AppLoading />;
@ -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 />;
}

View file

@ -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']>>;

View file

@ -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;

View file

@ -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;

View file

@ -35,7 +35,7 @@ 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`, {
@ -43,7 +43,7 @@ function AcceptInvitation() {
body: { status: OrganizationInvitationStatus.Accepted },
});
navigateTenant(tenantId);
navigateTenant(organizationId.slice(2));
})();
}, [cloudApi, error, invitation, navigateTenant, t]);

View file

@ -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",

View file

@ -1235,8 +1235,8 @@ importers:
version: 3.22.4
devDependencies:
'@logto/cloud':
specifier: 0.2.5-2a72cc4
version: 0.2.5-2a72cc4(zod@3.22.4)
specifier: 0.2.5-81f06ea
version: 0.2.5-81f06ea(zod@3.22.4)
'@rollup/plugin-commonjs':
specifier: ^25.0.0
version: 25.0.7(rollup@4.12.0)
@ -2715,8 +2715,8 @@ importers:
specifier: workspace:^1.4.0
version: link:../app-insights
'@logto/cloud':
specifier: 0.2.5-2a72cc4
version: 0.2.5-2a72cc4(zod@3.22.4)
specifier: 0.2.5-81f06ea
version: 0.2.5-81f06ea(zod@3.22.4)
'@logto/connector-kit':
specifier: workspace:^2.1.0
version: link:../toolkit/connector-kit
@ -3202,8 +3202,8 @@ importers:
version: 3.22.4
devDependencies:
'@logto/cloud':
specifier: 0.2.5-2a72cc4
version: 0.2.5-2a72cc4(zod@3.22.4)
specifier: 0.2.5-81f06ea
version: 0.2.5-81f06ea(zod@3.22.4)
'@silverhand/eslint-config':
specifier: 5.0.0
version: 5.0.0(eslint@8.44.0)(prettier@3.0.0)(typescript@5.3.3)
@ -7711,8 +7711,8 @@ packages:
jose: 5.2.2
dev: true
/@logto/cloud@0.2.5-2a72cc4(zod@3.22.4):
resolution: {integrity: sha512-7+2VAQBzTix/uaz5XzF/IVtU6AHLIwXLR05/sEO6dgJnhHQhcmk+PHIw4Gw9wBMVoFa3k6DiF0NQJmay80LIUA==}
/@logto/cloud@0.2.5-81f06ea(zod@3.22.4):
resolution: {integrity: sha512-7u2VY8qlRoaheWDEbHdoFmQP9MbloKuuCwbz1jk+Wrn2EE1v+tgixVK/MiyFaAN5mLAVLAlCVQ00JIabw+g6YA==}
engines: {node: ^20.9.0}
dependencies:
'@silverhand/essentials': 2.9.0