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 ( +
+
+
{tenantName}
+ +
+
+ ); +} + +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) => ( + + ))}