diff --git a/packages/connectors/connector-logto-email/package.json b/packages/connectors/connector-logto-email/package.json
index d95b01210b7..4ee889f3958 100644
--- a/packages/connectors/connector-logto-email/package.json
+++ b/packages/connectors/connector-logto-email/package.json
@@ -49,6 +49,6 @@
"access": "public"
},
"devDependencies": {
- "@logto/cloud": "0.2.5-2a72cc4"
+ "@logto/cloud": "0.2.5-81f06ea"
}
}
diff --git a/packages/console/package.json b/packages/console/package.json
index c0617c87cc7..18674ba94ef 100644
--- a/packages/console/package.json
+++ b/packages/console/package.json
@@ -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",
diff --git a/packages/console/src/cloud/pages/Main/InvitationList/index.module.scss b/packages/console/src/cloud/pages/Main/InvitationList/index.module.scss
new file mode 100644
index 00000000000..e4ecad53430
--- /dev/null
+++ b/packages/console/src/cloud/pages/Main/InvitationList/index.module.scss
@@ -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%;
+ }
+ }
+}
diff --git a/packages/console/src/cloud/pages/Main/InvitationList/index.tsx b/packages/console/src/cloud/pages/Main/InvitationList/index.tsx
new file mode 100644
index 00000000000..c67f3d19192
--- /dev/null
+++ b/packages/console/src/cloud/pages/Main/InvitationList/index.tsx
@@ -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 (
+ <>
+
+
+
{t('invitation.find_your_tenants')}
+
{t('invitation.find_tenants_description')}
+ {invitations.map(({ id, organizationId, tenantName, tenantTag }) => (
+
+
+ {tenantName}
+
+
+
+ ))}
+
+
+ {t('general.or')}
+
+
+
+
+ {
+ if (tenant) {
+ prependTenant(tenant);
+ navigateTenant(tenant.id);
+ }
+ setIsCreateModalOpen(false);
+ }}
+ />
+ >
+ );
+}
+
+export default InvitationList;
diff --git a/packages/console/src/cloud/pages/Main/index.tsx b/packages/console/src/cloud/pages/Main/index.tsx
index 51cc2de313f..42947a5562d 100644
--- a/packages/console/src/cloud/pages/Main/index.tsx
+++ b/packages/console/src/cloud/pages/Main/index.tsx
@@ -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 ;
@@ -26,6 +32,11 @@ export default function Main() {
return ;
}
+ // If user has pending invitations (onboarding will be skipped), show the invitation list and allow them to quick join.
+ if (isCloud && data?.length) {
+ return ;
+ }
+
// If user has completed onboarding and still has no tenant, redirect to a special landing page.
return ;
}
diff --git a/packages/console/src/cloud/types/router.ts b/packages/console/src/cloud/types/router.ts
index 28cd8c17a17..ed175b7afda 100644
--- a/packages/console/src/cloud/types/router.ts
+++ b/packages/console/src/cloud/types/router.ts
@@ -19,6 +19,8 @@ export type InvoicesResponse = GuardedResponse;
+export type InvitationListResponse = GuardedResponse;
+
// The response of GET /api/tenants is TenantResponse[].
export type TenantResponse = GetArrayElementType>;
diff --git a/packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.module.scss b/packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.module.scss
new file mode 100644
index 00000000000..fa2a78575e3
--- /dev/null
+++ b/packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.module.scss
@@ -0,0 +1,26 @@
+@use '@/scss/underscore' as _;
+
+.item {
+ display: flex;
+ align-items: center;
+ padding: _.unit(2.5) _.unit(4);
+ margin: _.unit(1);
+ border-radius: 6px;
+ transition: background-color 0.2s ease-in-out;
+ justify-content: space-between;
+
+ &:hover {
+ background: var(--color-hover);
+ }
+
+ .meta {
+ display: flex;
+ align-items: center;
+ gap: _.unit(2);
+
+ .name {
+ font: var(--font-body-2);
+ @include _.text-ellipsis;
+ }
+ }
+}
diff --git a/packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.tsx b/packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.tsx
new file mode 100644
index 00000000000..0a067bedadc
--- /dev/null
+++ b/packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.tsx
@@ -0,0 +1,50 @@
+import { OrganizationInvitationStatus, type TenantTag } from '@logto/schemas';
+import { useContext } from 'react';
+
+import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
+import TenantEnvTag from '@/components/TenantEnvTag';
+import { TenantsContext } from '@/contexts/TenantsProvider';
+import Button from '@/ds-components/Button';
+
+import * as styles from './index.module.scss';
+
+type Props = {
+ data: {
+ id: string;
+ organizationId: string;
+ tenantName: string;
+ tenantTag: TenantTag;
+ };
+};
+
+function TenantInvitationDropdownItem({ data }: Props) {
+ const cloudApi = useCloudApi();
+ const { navigateTenant, resetTenants } = useContext(TenantsContext);
+ const { id, organizationId, tenantName, tenantTag } = data;
+
+ return (
+
+
+
+ );
+}
+
+export default TenantInvitationDropdownItem;
diff --git a/packages/console/src/components/Topbar/TenantSelector/index.tsx b/packages/console/src/components/Topbar/TenantSelector/index.tsx
index 274500ae18b..99e4eee98b7 100644
--- a/packages/console/src/components/Topbar/TenantSelector/index.tsx
+++ b/packages/console/src/components/Topbar/TenantSelector/index.tsx
@@ -1,3 +1,4 @@
+import { OrganizationInvitationStatus } from '@logto/schemas';
import { useContext, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -11,9 +12,11 @@ import Divider from '@/ds-components/Divider';
import Dropdown from '@/ds-components/Dropdown';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import useUserDefaultTenantId from '@/hooks/use-user-default-tenant-id';
+import useUserInvitations from '@/hooks/use-user-invitations';
import { onKeyDownHandler } from '@/utils/a11y';
import TenantDropdownItem from './TenantDropdownItem';
+import TenantInvitationDropdownItem from './TenantInvitationDropdownItem';
import * as styles from './index.module.scss';
export default function TenantSelector() {
@@ -25,6 +28,7 @@ export default function TenantSelector() {
currentTenantId,
navigateTenant,
} = useContext(TenantsContext);
+ const { data: pendingInvitations } = useUserInvitations(OrganizationInvitationStatus.Pending);
const anchorRef = useRef(null);
const [showDropdown, setShowDropdown] = useState(false);
@@ -76,6 +80,9 @@ export default function TenantSelector() {
}}
/>
))}
+ {pendingInvitations?.map((invitation) => (
+
+ ))}