From c941f26dfe37b601c4f60b0c69d2f6054bf3671e Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Tue, 26 Mar 2024 22:25:49 +0800 Subject: [PATCH 1/2] feat(console): implement interim landing page for new users to join invited tenants --- .../connector-logto-email/package.json | 2 +- packages/console/package.json | 2 +- .../Main/InvitationList/index.module.scss | 77 ++++++++++++++++ .../cloud/pages/Main/InvitationList/index.tsx | 89 +++++++++++++++++++ .../console/src/cloud/pages/Main/index.tsx | 11 +++ packages/console/src/cloud/types/router.ts | 2 + .../console/src/hooks/use-user-invitations.ts | 42 +++++++++ .../SwitchAccount/index.module.scss | 6 +- .../src/pages/AcceptInvitation/index.tsx | 4 +- packages/core/package.json | 2 +- pnpm-lock.yaml | 16 ++-- 11 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 packages/console/src/cloud/pages/Main/InvitationList/index.module.scss create mode 100644 packages/console/src/cloud/pages/Main/InvitationList/index.tsx create mode 100644 packages/console/src/hooks/use-user-invitations.ts 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/hooks/use-user-invitations.ts b/packages/console/src/hooks/use-user-invitations.ts new file mode 100644 index 00000000000..cd6830b33ca --- /dev/null +++ b/packages/console/src/hooks/use-user-invitations.ts @@ -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; + error: Optional; + isLoading: boolean; +} => { + const cloudApi = useCloudApi({ hideErrorToast: true }); + const { data, isLoading, error } = useSWR( + `/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; diff --git a/packages/console/src/pages/AcceptInvitation/SwitchAccount/index.module.scss b/packages/console/src/pages/AcceptInvitation/SwitchAccount/index.module.scss index 351fbdbdd41..3344a666de3 100644 --- a/packages/console/src/pages/AcceptInvitation/SwitchAccount/index.module.scss +++ b/packages/console/src/pages/AcceptInvitation/SwitchAccount/index.module.scss @@ -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; diff --git a/packages/console/src/pages/AcceptInvitation/index.tsx b/packages/console/src/pages/AcceptInvitation/index.tsx index 4bd66ea24c3..c4a73548b7c 100644 --- a/packages/console/src/pages/AcceptInvitation/index.tsx +++ b/packages/console/src/pages/AcceptInvitation/index.tsx @@ -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]); diff --git a/packages/core/package.json b/packages/core/package.json index 012a6c3e94c..efd7edf481c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b351c95b43..410a0c3fa85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1322,8 +1322,8 @@ importers: specifier: ^29.5.0 version: 29.6.3 '@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) @@ -2919,8 +2919,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 @@ -3406,8 +3406,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) @@ -7929,8 +7929,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 From 6db104944fc0af56ad881acbd94f52dbe6f6f0e5 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Wed, 27 Mar 2024 18:16:55 +0800 Subject: [PATCH 2/2] feat(console): add quick join options in tenant selector dropdown --- .../index.module.scss | 26 ++++++++++ .../TenantInvitationDropdownItem/index.tsx | 50 +++++++++++++++++++ .../Topbar/TenantSelector/index.tsx | 7 +++ 3 files changed, 83 insertions(+) create mode 100644 packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.module.scss create mode 100644 packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.tsx 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) => ( + + ))}