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"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@logto/cloud": "0.2.5-2a72cc4"
|
"@logto/cloud": "0.2.5-81f06ea"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@
|
||||||
"@fontsource/roboto-mono": "^5.0.0",
|
"@fontsource/roboto-mono": "^5.0.0",
|
||||||
"@jest/types": "^29.5.0",
|
"@jest/types": "^29.5.0",
|
||||||
"@logto/app-insights": "workspace:^1.4.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/connector-kit": "workspace:^2.1.0",
|
||||||
"@logto/core-kit": "workspace:^2.3.0",
|
"@logto/core-kit": "workspace:^2.3.0",
|
||||||
"@logto/language-kit": "workspace:^1.1.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 AppLoading from '@/components/AppLoading';
|
||||||
|
import { isCloud } from '@/consts/env';
|
||||||
import useCurrentUser from '@/hooks/use-current-user';
|
import useCurrentUser from '@/hooks/use-current-user';
|
||||||
import useUserDefaultTenantId from '@/hooks/use-user-default-tenant-id';
|
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 useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
||||||
|
|
||||||
import AutoCreateTenant from './AutoCreateTenant';
|
import AutoCreateTenant from './AutoCreateTenant';
|
||||||
|
import InvitationList from './InvitationList';
|
||||||
import Redirect from './Redirect';
|
import Redirect from './Redirect';
|
||||||
import TenantLandingPage from './TenantLandingPage';
|
import TenantLandingPage from './TenantLandingPage';
|
||||||
|
|
||||||
|
@ -11,6 +16,7 @@ export default function Main() {
|
||||||
const { isLoaded } = useCurrentUser();
|
const { isLoaded } = useCurrentUser();
|
||||||
const { isOnboarding } = useUserOnboardingData();
|
const { isOnboarding } = useUserOnboardingData();
|
||||||
const { defaultTenantId } = useUserDefaultTenantId();
|
const { defaultTenantId } = useUserDefaultTenantId();
|
||||||
|
const { data } = useUserInvitations(OrganizationInvitationStatus.Pending);
|
||||||
|
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
return <AppLoading />;
|
return <AppLoading />;
|
||||||
|
@ -26,6 +32,11 @@ export default function Main() {
|
||||||
return <AutoCreateTenant />;
|
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.
|
// If user has completed onboarding and still has no tenant, redirect to a special landing page.
|
||||||
return <TenantLandingPage />;
|
return <TenantLandingPage />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,8 @@ export type InvoicesResponse = GuardedResponse<GetRoutes['/api/tenants/:tenantId
|
||||||
|
|
||||||
export type InvitationResponse = GuardedResponse<GetRoutes['/api/invitations/:invitationId']>;
|
export type InvitationResponse = GuardedResponse<GetRoutes['/api/invitations/:invitationId']>;
|
||||||
|
|
||||||
|
export type InvitationListResponse = GuardedResponse<GetRoutes['/api/invitations']>;
|
||||||
|
|
||||||
// The response of GET /api/tenants is TenantResponse[].
|
// The response of GET /api/tenants is TenantResponse[].
|
||||||
export type TenantResponse = GetArrayElementType<GuardedResponse<GetRoutes['/api/tenants']>>;
|
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 {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100vw;
|
height: 100%;
|
||||||
height: 100vh;
|
min-height: 600px;
|
||||||
background: var(--color-surface-1);
|
background: var(--color-surface-1);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow-y: auto;
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -35,7 +35,7 @@ function AcceptInvitation() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(async () => {
|
(async () => {
|
||||||
const { id, tenantId } = invitation;
|
const { id, organizationId } = invitation;
|
||||||
|
|
||||||
// Accept the invitation and redirect to the tenant page.
|
// Accept the invitation and redirect to the tenant page.
|
||||||
await cloudApi.patch(`/api/invitations/:invitationId/status`, {
|
await cloudApi.patch(`/api/invitations/:invitationId/status`, {
|
||||||
|
@ -43,7 +43,7 @@ function AcceptInvitation() {
|
||||||
body: { status: OrganizationInvitationStatus.Accepted },
|
body: { status: OrganizationInvitationStatus.Accepted },
|
||||||
});
|
});
|
||||||
|
|
||||||
navigateTenant(tenantId);
|
navigateTenant(organizationId.slice(2));
|
||||||
})();
|
})();
|
||||||
}, [cloudApi, error, invitation, navigateTenant, t]);
|
}, [cloudApi, error, invitation, navigateTenant, t]);
|
||||||
|
|
||||||
|
|
|
@ -91,7 +91,7 @@
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@logto/cloud": "0.2.5-2a72cc4",
|
"@logto/cloud": "0.2.5-81f06ea",
|
||||||
"@silverhand/eslint-config": "5.0.0",
|
"@silverhand/eslint-config": "5.0.0",
|
||||||
"@silverhand/ts-config": "5.0.0",
|
"@silverhand/ts-config": "5.0.0",
|
||||||
"@types/debug": "^4.1.7",
|
"@types/debug": "^4.1.7",
|
||||||
|
|
|
@ -1235,8 +1235,8 @@ importers:
|
||||||
version: 3.22.4
|
version: 3.22.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@logto/cloud':
|
'@logto/cloud':
|
||||||
specifier: 0.2.5-2a72cc4
|
specifier: 0.2.5-81f06ea
|
||||||
version: 0.2.5-2a72cc4(zod@3.22.4)
|
version: 0.2.5-81f06ea(zod@3.22.4)
|
||||||
'@rollup/plugin-commonjs':
|
'@rollup/plugin-commonjs':
|
||||||
specifier: ^25.0.0
|
specifier: ^25.0.0
|
||||||
version: 25.0.7(rollup@4.12.0)
|
version: 25.0.7(rollup@4.12.0)
|
||||||
|
@ -2715,8 +2715,8 @@ importers:
|
||||||
specifier: workspace:^1.4.0
|
specifier: workspace:^1.4.0
|
||||||
version: link:../app-insights
|
version: link:../app-insights
|
||||||
'@logto/cloud':
|
'@logto/cloud':
|
||||||
specifier: 0.2.5-2a72cc4
|
specifier: 0.2.5-81f06ea
|
||||||
version: 0.2.5-2a72cc4(zod@3.22.4)
|
version: 0.2.5-81f06ea(zod@3.22.4)
|
||||||
'@logto/connector-kit':
|
'@logto/connector-kit':
|
||||||
specifier: workspace:^2.1.0
|
specifier: workspace:^2.1.0
|
||||||
version: link:../toolkit/connector-kit
|
version: link:../toolkit/connector-kit
|
||||||
|
@ -3202,8 +3202,8 @@ importers:
|
||||||
version: 3.22.4
|
version: 3.22.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@logto/cloud':
|
'@logto/cloud':
|
||||||
specifier: 0.2.5-2a72cc4
|
specifier: 0.2.5-81f06ea
|
||||||
version: 0.2.5-2a72cc4(zod@3.22.4)
|
version: 0.2.5-81f06ea(zod@3.22.4)
|
||||||
'@silverhand/eslint-config':
|
'@silverhand/eslint-config':
|
||||||
specifier: 5.0.0
|
specifier: 5.0.0
|
||||||
version: 5.0.0(eslint@8.44.0)(prettier@3.0.0)(typescript@5.3.3)
|
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
|
jose: 5.2.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@logto/cloud@0.2.5-2a72cc4(zod@3.22.4):
|
/@logto/cloud@0.2.5-81f06ea(zod@3.22.4):
|
||||||
resolution: {integrity: sha512-7+2VAQBzTix/uaz5XzF/IVtU6AHLIwXLR05/sEO6dgJnhHQhcmk+PHIw4Gw9wBMVoFa3k6DiF0NQJmay80LIUA==}
|
resolution: {integrity: sha512-7u2VY8qlRoaheWDEbHdoFmQP9MbloKuuCwbz1jk+Wrn2EE1v+tgixVK/MiyFaAN5mLAVLAlCVQ00JIabw+g6YA==}
|
||||||
engines: {node: ^20.9.0}
|
engines: {node: ^20.9.0}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@silverhand/essentials': 2.9.0
|
'@silverhand/essentials': 2.9.0
|
||||||
|
|
Loading…
Reference in a new issue