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:
parent
6990a3ebb5
commit
f83e85ba55
11 changed files with 237 additions and 16 deletions
|
@ -48,6 +48,6 @@
|
|||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@logto/cloud": "0.2.5-2a72cc4"
|
||||
"@logto/cloud": "0.2.5-81f06ea"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
@ -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']>>;
|
||||
|
||||
|
|
42
packages/console/src/hooks/use-user-invitations.ts
Normal file
42
packages/console/src/hooks/use-user-invitations.ts
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue