Skip to content

Commit c2e491e

Browse files
authored
feat(console): introduce system limit error toast (#7931)
1 parent fb799a2 commit c2e491e

File tree

39 files changed

+656
-2
lines changed

39 files changed

+656
-2
lines changed

packages/console/src/cloud/types/router.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ export type TenantUsageAddOnSkus = GuardedResponse<
1515
GetRoutes['/api/tenants/:tenantId/subscription/add-on-skus']
1616
>;
1717

18+
export type SystemLimit = Required<
19+
GuardedResponse<GetRoutes['/api/tenants/my/subscription']>
20+
>['systemLimit'];
21+
1822
/* ===== Use `New` in the naming to avoid confusion with legacy types ===== */
1923
export type NewSubscriptionUsageResponse = GuardedResponse<
2024
GetRoutes['/api/tenants/:tenantId/subscription-usage']
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { type AdminConsoleKey } from '@logto/phrases';
2+
import classNames from 'classnames';
3+
import { toast } from 'react-hot-toast';
4+
5+
import Error from '@/assets/icons/toast-error.svg?react';
6+
import Success from '@/assets/icons/toast-success.svg?react';
7+
import TextLink from '@/ds-components/TextLink';
8+
9+
import DynamicT from '../DynamicT';
10+
11+
import styles from './index.module.scss';
12+
13+
type ToastVariant = 'success' | 'error';
14+
15+
type ToastWithActionProps = {
16+
readonly message: string;
17+
readonly variant: ToastVariant;
18+
readonly actionText: AdminConsoleKey;
19+
readonly actionHref: string;
20+
};
21+
22+
/**
23+
* A generic toast component with an action link.
24+
* Can be used for success or error toasts that require user interaction.
25+
*/
26+
function ToastWithAction({ message, variant, actionText, actionHref }: ToastWithActionProps) {
27+
const icon = variant === 'success' ? <Success /> : <Error />;
28+
29+
return (
30+
<div className={classNames(styles.toast, styles[variant], styles.withAction)}>
31+
<div className={styles.image}>{icon}</div>
32+
<div className={styles.message}>{message}</div>
33+
<TextLink href={actionHref} className={styles.action}>
34+
<DynamicT forKey={actionText} />
35+
</TextLink>
36+
</div>
37+
);
38+
}
39+
40+
/**
41+
* Display a toast with an action link.
42+
*
43+
* @example
44+
* ```tsx
45+
* // Error toast with contact link
46+
* toastWithAction({
47+
* message: 'You have reached your quota limit',
48+
* actionText: 'Contact us',
49+
* actionHref: contactEmailLink,
50+
* variant: 'error',
51+
* });
52+
*
53+
* // Success toast with custom action
54+
* toastWithAction({
55+
* message: 'Update available',
56+
* actionText: 'Download',
57+
* actionHref: '/downloads',
58+
* variant: 'success',
59+
* });
60+
* ```
61+
*/
62+
export const toastWithAction = (props: ToastWithActionProps) => {
63+
toast.custom(() => <ToastWithAction {...props} />);
64+
};

packages/console/src/ds-components/Toast/index.module.scss

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,20 @@ div.toast {
3535
background-color: var(--color-danger-toast-background);
3636
white-space: pre-line;
3737
}
38+
39+
&.withAction {
40+
.message {
41+
margin-right: _.unit(3);
42+
}
43+
44+
.action {
45+
flex-shrink: 0;
46+
font: var(--font-label-2);
47+
color: var(--color-text-link);
48+
49+
&:hover {
50+
text-decoration: none;
51+
}
52+
}
53+
}
3854
}

packages/console/src/hooks/use-api.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,17 @@ import { useCallback, useContext, useMemo } from 'react';
1919
import { toast } from 'react-hot-toast';
2020
import { useTranslation } from 'react-i18next';
2121

22-
import { requestTimeout } from '@/consts';
22+
import { requestTimeout, contactEmailLink } from '@/consts';
2323
import { isCloud } from '@/consts/env';
2424
import { AppDataContext } from '@/contexts/AppDataProvider';
2525
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
2626
import { TenantsContext } from '@/contexts/TenantsProvider';
27+
import { toastWithAction } from '@/ds-components/Toast/ToastWithAction';
2728
import { useConfirmModal } from '@/hooks/use-confirm-modal';
2829
import useRedirectUri from '@/hooks/use-redirect-uri';
2930

3031
import useSignOut from './use-sign-out';
32+
import { useSystemLimitErrorMessage } from './use-system-limit-error-message';
3133

3234
export class RequestError extends Error {
3335
constructor(
@@ -50,6 +52,7 @@ const useGlobalRequestErrorHandler = (toastDisabledErrorCodes?: LogtoErrorCode[]
5052
const { signOut } = useSignOut();
5153
const { show } = useConfirmModal();
5254
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
55+
const { parseSystemLimitErrorMessage } = useSystemLimitErrorMessage();
5356

5457
const postSignOutRedirectUri = useRedirectUri('signOut');
5558

@@ -83,6 +86,17 @@ const useGlobalRequestErrorHandler = (toastDisabledErrorCodes?: LogtoErrorCode[]
8386
return;
8487
}
8588

89+
// Toast system limit exceeded error
90+
if (data.code === 'system_limit.limit_exceeded') {
91+
toastWithAction({
92+
message: parseSystemLimitErrorMessage(data),
93+
actionText: 'general.contact_us_action',
94+
actionHref: contactEmailLink,
95+
variant: 'error',
96+
});
97+
return;
98+
}
99+
86100
// Skip showing toast for specific error codes.
87101
if (toastDisabledErrorCodes?.includes(data.code)) {
88102
return;
@@ -93,7 +107,14 @@ const useGlobalRequestErrorHandler = (toastDisabledErrorCodes?: LogtoErrorCode[]
93107
toast.error(httpCodeToMessage[response.status] ?? fallbackErrorMessage);
94108
}
95109
},
96-
[t, toastDisabledErrorCodes, signOut, postSignOutRedirectUri.href, show]
110+
[
111+
t,
112+
toastDisabledErrorCodes,
113+
signOut,
114+
postSignOutRedirectUri.href,
115+
show,
116+
parseSystemLimitErrorMessage,
117+
]
97118
);
98119

99120
return {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { type AdminConsoleKey } from '@logto/phrases';
2+
import { type RequestErrorBody } from '@logto/schemas';
3+
import { useCallback } from 'react';
4+
import { useTranslation } from 'react-i18next';
5+
import { z } from 'zod';
6+
7+
import { type SystemLimit } from '@/cloud/types/router';
8+
9+
type SystemLimitKey = keyof SystemLimit;
10+
11+
const systemLimitKeyGuard = z.enum([
12+
'applicationsLimit',
13+
'thirdPartyApplicationsLimit',
14+
'scopesPerResourceLimit',
15+
'socialConnectorsLimit',
16+
'userRolesLimit',
17+
'machineToMachineRolesLimit',
18+
'scopesPerRoleLimit',
19+
'hooksLimit',
20+
'machineToMachineLimit',
21+
'resourcesLimit',
22+
'enterpriseSsoLimit',
23+
'tenantMembersLimit',
24+
'organizationsLimit',
25+
'samlApplicationsLimit',
26+
'usersPerOrganizationLimit',
27+
'organizationUserRolesLimit',
28+
'organizationMachineToMachineRolesLimit',
29+
'organizationScopesLimit',
30+
]) satisfies z.ZodEnum<[SystemLimitKey, ...SystemLimitKey[]]>;
31+
32+
const errorBodyGuard = z.object({
33+
key: systemLimitKeyGuard,
34+
});
35+
36+
const systemLimitEntityPhrases: Record<SystemLimitKey, AdminConsoleKey> = {
37+
applicationsLimit: 'system_limit.entities.application',
38+
thirdPartyApplicationsLimit: 'system_limit.entities.third_party_application',
39+
scopesPerResourceLimit: 'system_limit.entities.scope_per_resource',
40+
socialConnectorsLimit: 'system_limit.entities.social_connector',
41+
userRolesLimit: 'system_limit.entities.user_role',
42+
machineToMachineRolesLimit: 'system_limit.entities.machine_to_machine_role',
43+
scopesPerRoleLimit: 'system_limit.entities.scope_per_role',
44+
hooksLimit: 'system_limit.entities.hook',
45+
machineToMachineLimit: 'system_limit.entities.machine_to_machine',
46+
resourcesLimit: 'system_limit.entities.resource',
47+
enterpriseSsoLimit: 'system_limit.entities.enterprise_sso',
48+
tenantMembersLimit: 'system_limit.entities.tenant_member',
49+
organizationsLimit: 'system_limit.entities.organization',
50+
samlApplicationsLimit: 'system_limit.entities.saml_application',
51+
usersPerOrganizationLimit: 'system_limit.entities.user_per_organization',
52+
organizationUserRolesLimit: 'system_limit.entities.organization_user_role',
53+
organizationMachineToMachineRolesLimit:
54+
'system_limit.entities.organization_machine_to_machine_role',
55+
organizationScopesLimit: 'system_limit.entities.organization_scope',
56+
};
57+
58+
export const useSystemLimitErrorMessage = () => {
59+
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
60+
const parseSystemLimitErrorMessage = useCallback(
61+
(errorBody: RequestErrorBody): string => {
62+
if (errorBody.code !== 'system_limit.limit_exceeded') {
63+
return t('general.unknown_error');
64+
}
65+
66+
const result = errorBodyGuard.safeParse(errorBody.data);
67+
if (!result.success) {
68+
return t('general.unknown_error');
69+
}
70+
71+
const { key } = result.data;
72+
73+
const entity = t(systemLimitEntityPhrases[key]);
74+
return t('system_limit.limit_exceeded', { entity });
75+
},
76+
[t]
77+
);
78+
79+
return {
80+
parseSystemLimitErrorMessage,
81+
};
82+
};

packages/phrases/src/locales/ar/translation/admin-console/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import session_expired from './session-expired.js';
3636
import sign_in_exp from './sign-in-exp/index.js';
3737
import signing_keys from './signing-keys.js';
3838
import subscription from './subscription/index.js';
39+
import system_limit from './system-limit.js';
3940
import tab_sections from './tab-sections.js';
4041
import tabs from './tabs.js';
4142
import tenant_members from './tenant-members.js';
@@ -90,6 +91,7 @@ const admin_console = {
9091
tenant_members,
9192
topbar,
9293
subscription,
94+
system_limit,
9395
upsell,
9496
guide,
9597
mfa,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const system_limit = {
2+
limit_exceeded: 'وصل هذا المستأجر إلى حد {{entity}} وفقًا لسياسة حد الكيان الخاصة بـ Logto.',
3+
entities: {
4+
application: 'التطبيق',
5+
third_party_application: 'تطبيق الطرف الثالث',
6+
scope_per_resource: 'الإذن لكل مورد',
7+
social_connector: 'موصل اجتماعي',
8+
user_role: 'دور المستخدم',
9+
machine_to_machine_role: 'دور الآلة إلى الآلة',
10+
scope_per_role: 'الإذن لكل دور',
11+
hook: 'webhook',
12+
machine_to_machine: 'تطبيق الآلة إلى الآلة',
13+
resource: 'مورد API',
14+
enterprise_sso: 'تسجيل الدخول الموحد للمؤسسة',
15+
tenant_member: 'عضو المستأجر',
16+
organization: 'المنظمة',
17+
saml_application: 'تطبيق SAML',
18+
user_per_organization: 'المستخدم لكل منظمة',
19+
organization_user_role: 'دور مستخدم المنظمة',
20+
organization_machine_to_machine_role: 'دور الآلة إلى الآلة للمنظمة',
21+
organization_scope: 'إذن المنظمة',
22+
},
23+
};
24+
25+
export default Object.freeze(system_limit);

packages/phrases/src/locales/de/translation/admin-console/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import session_expired from './session-expired.js';
3636
import sign_in_exp from './sign-in-exp/index.js';
3737
import signing_keys from './signing-keys.js';
3838
import subscription from './subscription/index.js';
39+
import system_limit from './system-limit.js';
3940
import tab_sections from './tab-sections.js';
4041
import tabs from './tabs.js';
4142
import tenant_members from './tenant-members.js';
@@ -90,6 +91,7 @@ const admin_console = {
9091
tenant_members,
9192
topbar,
9293
subscription,
94+
system_limit,
9395
upsell,
9496
guide,
9597
mfa,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const system_limit = {
2+
limit_exceeded:
3+
'Dieser Mandant hat das {{entity}}-Limit gemäß Logtos Entitätslimitrichtlinie erreicht.',
4+
entities: {
5+
application: 'Anwendung',
6+
third_party_application: 'Drittanbieter-Anwendung',
7+
scope_per_resource: 'Berechtigung pro Ressource',
8+
social_connector: 'Social Connector',
9+
user_role: 'Benutzerrolle',
10+
machine_to_machine_role: 'Maschine-zu-Maschine-Rolle',
11+
scope_per_role: 'Berechtigung pro Rolle',
12+
hook: 'Webhook',
13+
machine_to_machine: 'Maschine-zu-Maschine-Anwendung',
14+
resource: 'API-Ressource',
15+
enterprise_sso: 'Enterprise SSO',
16+
tenant_member: 'Mandantenmitglied',
17+
organization: 'Organisation',
18+
saml_application: 'SAML-Anwendung',
19+
user_per_organization: 'Benutzer pro Organisation',
20+
organization_user_role: 'Organisationsbenutzerrolle',
21+
organization_machine_to_machine_role: 'Organisations-Maschine-zu-Maschine-Rolle',
22+
organization_scope: 'Organisationsberechtigung',
23+
},
24+
};
25+
26+
export default Object.freeze(system_limit);

packages/phrases/src/locales/en/translation/admin-console/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import session_expired from './session-expired.js';
3636
import sign_in_exp from './sign-in-exp/index.js';
3737
import signing_keys from './signing-keys.js';
3838
import subscription from './subscription/index.js';
39+
import system_limit from './system-limit.js';
3940
import tab_sections from './tab-sections.js';
4041
import tabs from './tabs.js';
4142
import tenant_members from './tenant-members.js';
@@ -90,6 +91,7 @@ const admin_console = {
9091
tenant_members,
9192
topbar,
9293
subscription,
94+
system_limit,
9395
upsell,
9496
guide,
9597
mfa,

0 commit comments

Comments
 (0)