diff --git a/packages/console/package.json b/packages/console/package.json
index d9a20851c..b96b7aa28 100644
--- a/packages/console/package.json
+++ b/packages/console/package.json
@@ -26,7 +26,7 @@
"@fontsource/roboto-mono": "^5.0.0",
"@jest/types": "^29.5.0",
"@logto/app-insights": "workspace:^1.3.1",
- "@logto/cloud": "0.2.5-1795c3d",
+ "@logto/cloud": "0.2.5-71b7fea",
"@logto/connector-kit": "workspace:^1.1.1",
"@logto/core-kit": "workspace:^2.0.1",
"@logto/language-kit": "workspace:^1.0.0",
@@ -60,7 +60,7 @@
"@types/react-helmet": "^6.1.6",
"@types/react-modal": "^3.13.1",
"@types/react-syntax-highlighter": "^15.5.1",
- "@withtyped/client": "^0.7.21",
+ "@withtyped/client": "^0.7.22",
"buffer": "^5.7.1",
"classnames": "^2.3.1",
"clean-deep": "^3.4.0",
diff --git a/packages/console/src/components/MauExceededModal/index.tsx b/packages/console/src/components/MauExceededModal/index.tsx
index f2884dd90..3b0fbc705 100644
--- a/packages/console/src/components/MauExceededModal/index.tsx
+++ b/packages/console/src/components/MauExceededModal/index.tsx
@@ -1,9 +1,7 @@
import { useContext, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
-import useSWRImmutable from 'swr/immutable';
-import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import PlanUsage from '@/components/PlanUsage';
import { contactEmailLink } from '@/consts';
import { subscriptionPage } from '@/consts/pages';
@@ -21,35 +19,22 @@ import PlanName from '../PlanName';
import * as styles from './index.module.scss';
function MauExceededModal() {
- const { currentTenantId } = useContext(TenantsContext);
- const cloudApi = useCloudApi();
+ const { currentTenant } = useContext(TenantsContext);
+ const { usage, subscription } = currentTenant ?? {};
+
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { navigate } = useTenantPathname();
- const [hasClosed, setHasClosed] = useState(false);
+ const [hasClosed, setHasClosed] = useState(false);
const handleCloseModal = () => {
setHasClosed(true);
};
const { data: subscriptionPlans } = useSubscriptionPlans();
- const { data: currentSubscription } = useSWRImmutable(
- `/api/tenants/${currentTenantId}/subscription`,
- async () =>
- cloudApi.get('/api/tenants/:tenantId/subscription', { params: { tenantId: currentTenantId } })
- );
+ const currentPlan = subscriptionPlans?.find((plan) => plan.id === subscription?.planId);
- const { data: currentUsage } = useSWRImmutable(
- `/api/tenants/${currentTenantId}/usage`,
- async () =>
- cloudApi.get('/api/tenants/:tenantId/usage', { params: { tenantId: currentTenantId } })
- );
-
- const currentPlan =
- currentSubscription &&
- subscriptionPlans?.find((plan) => plan.id === currentSubscription.planId);
-
- if (!currentPlan || !currentUsage || hasClosed) {
+ if (!subscription || !usage || !currentPlan || hasClosed) {
return null;
}
@@ -58,7 +43,7 @@ function MauExceededModal() {
name: planName,
} = currentPlan;
- const isMauExceeded = mauLimit !== null && currentUsage.activeUsers >= mauLimit;
+ const isMauExceeded = mauLimit !== null && usage.activeUsers >= mauLimit;
if (!isMauExceeded) {
return null;
@@ -102,8 +87,8 @@ function MauExceededModal() {
diff --git a/packages/console/src/components/PaymentOverdueModal/index.tsx b/packages/console/src/components/PaymentOverdueModal/index.tsx
index f4f5762fc..968ef6fe7 100644
--- a/packages/console/src/components/PaymentOverdueModal/index.tsx
+++ b/packages/console/src/components/PaymentOverdueModal/index.tsx
@@ -1,11 +1,9 @@
-import { conditional } from '@silverhand/essentials';
-import { useContext, useMemo, useState } from 'react';
+import { useContext, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
-import useSWRImmutable from 'swr/immutable';
-import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { contactEmailLink } from '@/consts';
+import { isCloud } from '@/consts/env';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
@@ -13,7 +11,6 @@ import InlineNotification from '@/ds-components/InlineNotification';
import ModalLayout from '@/ds-components/ModalLayout';
import useSubscribe from '@/hooks/use-subscribe';
import * as modalStyles from '@/scss/modal.module.scss';
-import { getLatestUnpaidInvoice } from '@/utils/subscription';
import BillInfo from '../BillInfo';
@@ -22,26 +19,17 @@ import * as styles from './index.module.scss';
function PaymentOverdueModal() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { currentTenant, currentTenantId } = useContext(TenantsContext);
- const cloudApi = useCloudApi();
- const { data: invoicesResponse } = useSWRImmutable(
- `/api/tenants/${currentTenantId}/invoices`,
- async () =>
- cloudApi.get('/api/tenants/:tenantId/invoices', { params: { tenantId: currentTenantId } })
- );
+ const { openInvoices = [] } = currentTenant ?? {};
+
const { visitManagePaymentPage } = useSubscribe();
const [isActionLoading, setIsActionLoading] = useState(false);
- const latestUnpaidInvoice = useMemo(
- () => conditional(invoicesResponse && getLatestUnpaidInvoice(invoicesResponse.invoices)),
- [invoicesResponse]
- );
-
const [hasClosed, setHasClosed] = useState(false);
const handleCloseModal = () => {
setHasClosed(true);
};
- if (!invoicesResponse || !latestUnpaidInvoice || hasClosed) {
+ if (!isCloud || openInvoices.length === 0 || hasClosed) {
return null;
}
@@ -82,7 +70,12 @@ function PaymentOverdueModal() {
)}
-
+ total + currentInvoice.amountDue,
+ 0
+ )}
+ />
diff --git a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx
index 3f5f5be0d..9aab7c5ab 100644
--- a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx
+++ b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx
@@ -1,35 +1,16 @@
-import { conditional } from '@silverhand/essentials';
-import { useMemo } from 'react';
-
+import { type TenantResponse } from '@/cloud/types/router';
import DynamicT from '@/ds-components/DynamicT';
import Tag from '@/ds-components/Tag';
-import useInvoices from '@/hooks/use-invoices';
-import useSubscriptionPlan from '@/hooks/use-subscription-plan';
-import useSubscriptionUsage from '@/hooks/use-subscription-usage';
-import { getLatestUnpaidInvoice } from '@/utils/subscription';
+import { type SubscriptionPlan } from '@/types/subscriptions';
type Props = {
- tenantId: string;
+ tenantData: TenantResponse;
+ tenantPlan: SubscriptionPlan;
className?: string;
};
-function TenantStatusTag({ tenantId, className }: Props) {
- const { data: usage, error: fetchUsageError } = useSubscriptionUsage(tenantId);
- const { data: invoices, error: fetchInvoiceError } = useInvoices(tenantId);
- const { data: subscriptionPlan, error: fetchSubscriptionError } = useSubscriptionPlan(tenantId);
-
- const isLoadingUsage = !usage && !fetchUsageError;
- const isLoadingInvoice = !invoices && !fetchInvoiceError;
- const isLoadingSubscription = !subscriptionPlan && !fetchSubscriptionError;
-
- const latestUnpaidInvoice = useMemo(
- () => conditional(invoices && getLatestUnpaidInvoice(invoices)),
- [invoices]
- );
-
- if (isLoadingUsage || isLoadingInvoice || isLoadingSubscription) {
- return null;
- }
+function TenantStatusTag({ tenantData, tenantPlan, className }: Props) {
+ const { usage, openInvoices } = tenantData;
/**
* Tenant status priority:
@@ -38,7 +19,7 @@ function TenantStatusTag({ tenantId, className }: Props) {
* 3. mau exceeded
*/
- if (invoices && latestUnpaidInvoice) {
+ if (openInvoices.length > 0) {
return (
@@ -46,22 +27,20 @@ function TenantStatusTag({ tenantId, className }: Props) {
);
}
- if (subscriptionPlan && usage) {
- const { activeUsers } = usage;
+ const { activeUsers } = usage;
- const {
- quota: { mauLimit },
- } = subscriptionPlan;
+ const {
+ quota: { mauLimit },
+ } = tenantPlan;
- const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit;
+ const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit;
- if (isMauExceeded) {
- return (
-
-
-
- );
- }
+ if (isMauExceeded) {
+ return (
+
+
+
+ );
}
return null;
diff --git a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx
index b18a7ca4b..31d2e6515 100644
--- a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx
+++ b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx
@@ -1,10 +1,11 @@
import classNames from 'classnames';
+import { useMemo } from 'react';
import Tick from '@/assets/icons/tick.svg';
import { type TenantResponse } from '@/cloud/types/router';
import PlanName from '@/components/PlanName';
import { DropdownItem } from '@/ds-components/Dropdown';
-import useSubscriptionPlan from '@/hooks/use-subscription-plan';
+import useSubscriptionPlans from '@/hooks/use-subscription-plans';
import TenantEnvTag from '../TenantEnvTag';
@@ -18,8 +19,18 @@ type Props = {
};
function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) {
- const { id, name, tag } = tenantData;
- const { data: tenantPlan } = useSubscriptionPlan(id);
+ const {
+ name,
+ tag,
+ subscription: { planId },
+ } = tenantData;
+
+ const { data: plans } = useSubscriptionPlans();
+ const tenantPlan = useMemo(() => plans?.find((plan) => plan.id === planId), [plans, planId]);
+
+ if (!tenantPlan) {
+ return null;
+ }
return (
@@ -27,9 +38,15 @@ function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) {
+
-
diff --git a/packages/console/src/contexts/TenantsProvider.tsx b/packages/console/src/contexts/TenantsProvider.tsx
index 69d5de2ea..da10471a3 100644
--- a/packages/console/src/contexts/TenantsProvider.tsx
+++ b/packages/console/src/contexts/TenantsProvider.tsx
@@ -1,6 +1,7 @@
import { defaultManagementApi, defaultTenantId } from '@logto/schemas';
import { TenantTag } from '@logto/schemas/models';
import { conditionalArray, noop } from '@silverhand/essentials';
+import dayjs from 'dayjs';
import type { ReactNode } from 'react';
import { useCallback, useMemo, createContext, useState } from 'react';
import { useMatch, useNavigate } from 'react-router-dom';
@@ -65,17 +66,27 @@ const { tenantId, indicator } = defaultManagementApi.resource;
* - For cloud, the initial tenants data is empty, and it will be fetched from the cloud API.
* - OSS has a fixed tenant with ID `default` and no cloud API to dynamically fetch tenants.
*/
-const initialTenants = Object.freeze(
- conditionalArray(
- !isCloud && {
- id: tenantId,
- name: `tenant_${tenantId}`,
- tag: TenantTag.Development,
- indicator,
- planId: `${ReservedPlanId.free}`, // `planId` is string type.
- }
- )
-);
+const defaultTenantResponse: TenantResponse = {
+ id: tenantId,
+ name: `tenant_${tenantId}`,
+ tag: TenantTag.Development,
+ indicator,
+ subscription: {
+ status: 'active',
+ planId: ReservedPlanId.free,
+ currentPeriodStart: dayjs().toDate(),
+ currentPeriodEnd: dayjs().add(1, 'month').toDate(),
+ },
+ usage: {
+ activeUsers: 0,
+ cost: 0,
+ },
+ openInvoices: [],
+ isSuspended: false,
+ planId: ReservedPlanId.free, // Reserved for compatibility with cloud
+};
+
+const initialTenants = Object.freeze(conditionalArray(!isCloud && defaultTenantResponse));
export const TenantsContext = createContext({
tenants: initialTenants,
diff --git a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PaymentOverdueNotification/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PaymentOverdueNotification/index.tsx
index 22afa2c4c..c69812612 100644
--- a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PaymentOverdueNotification/index.tsx
+++ b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PaymentOverdueNotification/index.tsx
@@ -1,32 +1,29 @@
-import { conditional } from '@silverhand/essentials';
-import { useContext, useMemo, useState } from 'react';
+import { useContext, useState } from 'react';
import { TenantsContext } from '@/contexts/TenantsProvider';
import DynamicT from '@/ds-components/DynamicT';
import InlineNotification from '@/ds-components/InlineNotification';
-import useInvoices from '@/hooks/use-invoices';
import useSubscribe from '@/hooks/use-subscribe';
-import { getLatestUnpaidInvoice } from '@/utils/subscription';
type Props = {
className?: string;
};
function PaymentOverdueNotification({ className }: Props) {
- const { currentTenantId } = useContext(TenantsContext);
+ const { currentTenant, currentTenantId } = useContext(TenantsContext);
+ const { openInvoices = [] } = currentTenant ?? {};
const { visitManagePaymentPage } = useSubscribe();
const [isActionLoading, setIsActionLoading] = useState(false);
- const { data: invoices, error } = useInvoices(currentTenantId);
- const isLoadingInvoices = !invoices && !error;
- const latestUnpaidInvoice = useMemo(
- () => conditional(invoices && getLatestUnpaidInvoice(invoices)),
- [invoices]
- );
- if (isLoadingInvoices || !latestUnpaidInvoice) {
+ if (openInvoices.length === 0) {
return null;
}
+ const totalAmountDue = openInvoices.reduce(
+ (total, currentInvoice) => total + currentInvoice.amountDue,
+ 0
+ );
+
return (
);
diff --git a/packages/console/src/types/subscriptions.ts b/packages/console/src/types/subscriptions.ts
index 429535541..d1f3f20d2 100644
--- a/packages/console/src/types/subscriptions.ts
+++ b/packages/console/src/types/subscriptions.ts
@@ -76,6 +76,4 @@ export const localCheckoutSessionGuard = z.object({
export type LocalCheckoutSession = z.infer;
-export type Invoice = InvoicesResponse['invoices'][number];
-
-export type InvoiceStatus = Invoice['status'];
+export type InvoiceStatus = InvoicesResponse['invoices'][number]['status'];
diff --git a/packages/console/src/utils/subscription.ts b/packages/console/src/utils/subscription.ts
index 343efa5ab..35b4a4e44 100644
--- a/packages/console/src/utils/subscription.ts
+++ b/packages/console/src/utils/subscription.ts
@@ -5,7 +5,6 @@ import { tryReadResponseErrorBody } from '@/cloud/hooks/use-cloud-api';
import { type SubscriptionPlanResponse } from '@/cloud/types/router';
import { communitySupportEnabledMap, ticketSupportResponseTimeMap } from '@/consts/plan-quotas';
import { reservedPlanIdOrder } from '@/consts/subscriptions';
-import { type Invoice } from '@/types/subscriptions';
export const addSupportQuotaToPlan = (subscriptionPlanResponse: SubscriptionPlanResponse) => {
const { id, quota } = subscriptionPlanResponse;
@@ -43,15 +42,6 @@ export const formatPeriod = ({ periodStart, periodEnd, displayYear }: FormatPeri
return `${formattedStart} - ${formattedEnd}`;
};
-export const getLatestUnpaidInvoice = (invoices: Invoice[]) =>
- invoices
- .slice()
- .sort(
- (invoiceA, invoiceB) =>
- new Date(invoiceB.createdAt).getTime() - new Date(invoiceA.createdAt).getTime()
- )
- .find(({ status }) => status === 'uncollectible');
-
/**
* Note: this is a temporary solution to handle the case when the user tries to downgrade but the quota limit is exceeded.
* Need a better solution to handle this case by sharing the error type between the console and cloud. - LOG-6608
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 704a01571..29bd68951 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2834,8 +2834,8 @@ importers:
specifier: workspace:^1.3.1
version: link:../app-insights
'@logto/cloud':
- specifier: 0.2.5-1795c3d
- version: 0.2.5-1795c3d(zod@3.20.2)
+ specifier: 0.2.5-71b7fea
+ version: 0.2.5-71b7fea(zod@3.20.2)
'@logto/connector-kit':
specifier: workspace:^1.1.1
version: link:../toolkit/connector-kit
@@ -2936,8 +2936,8 @@ importers:
specifier: ^15.5.1
version: 15.5.1
'@withtyped/client':
- specifier: ^0.7.21
- version: 0.7.21(zod@3.20.2)
+ specifier: ^0.7.22
+ version: 0.7.22(zod@3.20.2)
buffer:
specifier: ^5.7.1
version: 5.7.1
@@ -7330,12 +7330,12 @@ packages:
jose: 4.14.4
dev: true
- /@logto/cloud@0.2.5-1795c3d(zod@3.20.2):
- resolution: {integrity: sha512-zxy9zr5swOxbzYJNYtKXofj2tSIS9565d+1pT6RSbmx3Hn+JG6uzsb75PZXW9vlmmm7p1sGZeTQ+xVzKNFPsMg==}
+ /@logto/cloud@0.2.5-71b7fea(zod@3.20.2):
+ resolution: {integrity: sha512-howllmEV6kWAgusP+2OSloG5bZQ146UiKn0PpA7xi9HcpgM6Fd1NPuNjc3BZdInJ5Qn0en6LOZL7c2EwTRx3jw==}
engines: {node: ^18.12.0}
dependencies:
'@silverhand/essentials': 2.8.4
- '@withtyped/server': 0.12.8(zod@3.20.2)
+ '@withtyped/server': 0.12.9(zod@3.20.2)
transitivePeerDependencies:
- zod
dev: true
@@ -10048,15 +10048,6 @@ packages:
eslint-visitor-keys: 3.4.1
dev: true
- /@withtyped/client@0.7.21(zod@3.20.2):
- resolution: {integrity: sha512-N9dvH5nqIwaT7YxaIm83RRQf9AEjxwJ4ugJviZJSxtWy8zLul2/odEMc6epieylFVa6CcLg82yJmRSlqPtJiTw==}
- dependencies:
- '@withtyped/server': 0.12.8(zod@3.20.2)
- '@withtyped/shared': 0.2.2
- transitivePeerDependencies:
- - zod
- dev: true
-
/@withtyped/client@0.7.22(zod@3.20.2):
resolution: {integrity: sha512-emNtcO0jc0dFWhvL7eUIRYzhTfn+JqgIvCmXb8ZUFOR8wdSSGrr9VDlm+wgQD06DEBBpmqtTHMMHTNXJdUC/Qw==}
dependencies:
@@ -10064,17 +10055,6 @@ packages:
'@withtyped/shared': 0.2.2
transitivePeerDependencies:
- zod
- dev: false
-
- /@withtyped/server@0.12.8(zod@3.20.2):
- resolution: {integrity: sha512-fv9feTOKJhtlaoYM/Kbs2gSTcIXlmu4OMUFwGmK5jqdbVNIOkDBIPxtcC5ZEwevWFgOcd5OqBW+FvbjiaF27Fw==}
- peerDependencies:
- zod: ^3.19.1
- dependencies:
- '@silverhand/essentials': 2.8.4
- '@withtyped/shared': 0.2.2
- zod: 3.20.2
- dev: true
/@withtyped/server@0.12.9(zod@3.20.2):
resolution: {integrity: sha512-K5zoV9D+WpawbghtlJKF1KOshKkBjq+gYzNRWuZk13YmFWFLcmZn+QCblNP55z9IGdcHWpTRknqb1APuicdzgA==}