Skip to content

Commit e9d9917

Browse files
authored
feat(console,core,toolkit,phrases): add email blocklist page (#7356)
* feat(console,core,toolkit,phrases): add email blocklist page add email blocklist settings page * refactor(phrases): remove unused keys remove unused keys * fix(console): refactor blocklist page code refactor some email blocklist page code
1 parent 49e482f commit e9d9917

File tree

44 files changed

+757
-12
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+757
-12
lines changed

packages/console/src/components/MultiOptionInput/index.module.scss

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,20 @@
7979
outline-color: var(--color-focused-variant);
8080
}
8181

82+
&.disabled {
83+
background: var(--color-layer-2);
84+
cursor: not-allowed;
85+
86+
&:active {
87+
pointer-events: none;
88+
}
89+
90+
&:focus-within {
91+
border-color: var(--color-border);
92+
outline-color: transparent;
93+
}
94+
}
95+
8296
&.error {
8397
border-color: var(--color-error);
8498

@@ -98,3 +112,4 @@ canvas {
98112
margin-top: _.unit(1);
99113
white-space: pre-wrap;
100114
}
115+

packages/console/src/components/MultiOptionInput/index.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ type Props<T> = {
2424
readonly validateInput: (text: string) => CanBePromise<{ value: T } | string>;
2525
readonly error?: string | boolean;
2626
readonly placeholder?: string;
27+
// eslint-disable-next-line react/boolean-prop-naming -- align with input props
28+
readonly disabled?: boolean;
2729
};
2830

2931
function MultiOptionInput<T>({
@@ -38,6 +40,7 @@ function MultiOptionInput<T>({
3840
error,
3941
placeholder,
4042
validateInput,
43+
disabled,
4144
}: Props<T>) {
4245
const ref = useRef<HTMLInputElement>(null);
4346
const [focusedValueId, setFocusedValueId] = useState<Nullable<string>>(null);
@@ -82,13 +85,24 @@ function MultiOptionInput<T>({
8285
return (
8386
<>
8487
<div
85-
className={classNames(styles.input, Boolean(error) && styles.error, className)}
88+
className={classNames(
89+
styles.input,
90+
Boolean(error) && styles.error,
91+
disabled && styles.disabled,
92+
className
93+
)}
8694
role="button"
8795
tabIndex={0}
8896
onKeyDown={onKeyDownHandler(() => {
97+
if (disabled) {
98+
return;
99+
}
89100
ref.current?.focus();
90101
})}
91102
onClick={() => {
103+
if (disabled) {
104+
return;
105+
}
92106
ref.current?.focus();
93107
}}
94108
>
@@ -126,6 +140,7 @@ function MultiOptionInput<T>({
126140
))}
127141
<input
128142
ref={ref}
143+
disabled={disabled}
129144
value={currentValue}
130145
onKeyDown={async (event) => {
131146
switch (event.key) {

packages/console/src/consts/external-links.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const enterpriseSso = '/connectors/enterprise-connectors';
5252
export const security = '/security';
5353
export const captcha = '/security/captcha';
5454
export const sentinel = '/security/identifier-lockout';
55+
export const emailBlocklist = '/security/blocklist';
5556
export const passwordPolicy = '/security/password-policy';
5657
export const spInitiatedSsoFlow = '/end-user-flows/enterprise-sso/sp-initiated-sso';
5758
export const apiResources = '/authorization/api-resources';

packages/console/src/hooks/use-console-routes/routes/security.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Navigate, type RouteObject } from 'react-router-dom';
22
import { safeLazy } from 'react-safe-lazy';
33

4+
import { isDevFeaturesEnabled } from '@/consts/env';
45
import { SecurityTabs } from '@/pages/Security/types';
56

67
const Security = safeLazy(async () => import('@/pages/Security'));
@@ -27,5 +28,14 @@ export const security: RouteObject = {
2728
path: SecurityTabs.General,
2829
element: <Security tab={SecurityTabs.General} />,
2930
},
31+
{
32+
path: SecurityTabs.Blocklist,
33+
// TODO: @simeng remove dev feature guard
34+
element: isDevFeaturesEnabled ? (
35+
<Security tab={SecurityTabs.Blocklist} />
36+
) : (
37+
<Navigate replace to={SecurityTabs.PasswordPolicy} />
38+
),
39+
},
3040
],
3141
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
@use '@/scss/underscore' as _;
2+
3+
.fieldDescription {
4+
font: var(--font-body-2);
5+
color: var(--color-text-secondary);
6+
margin: _.unit(1) 0 _.unit(2);
7+
}
8+
9+
.paywallNotification {
10+
margin-bottom: _.unit(4);
11+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { emailOrEmailDomainRegex } from '@logto/core-kit';
2+
import { type SignInExperience, type EmailBlocklistPolicy } from '@logto/schemas';
3+
import { Controller, useForm } from 'react-hook-form';
4+
import { toast } from 'react-hot-toast';
5+
import { useTranslation } from 'react-i18next';
6+
import { useSWRConfig } from 'swr';
7+
8+
import DetailsForm from '@/components/DetailsForm';
9+
import { CombinedAddOnAndFeatureTag, addOnLabels } from '@/components/FeatureTag';
10+
import FormCard from '@/components/FormCard';
11+
import MultiOptionInput from '@/components/MultiOptionInput';
12+
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
13+
import { emailBlocklist } from '@/consts';
14+
import { latestProPlanId } from '@/consts/subscriptions';
15+
import FormField from '@/ds-components/FormField';
16+
import Switch from '@/ds-components/Switch';
17+
import useApi from '@/hooks/use-api';
18+
import usePaywall from '@/hooks/use-paywall';
19+
import { trySubmitSafe } from '@/utils/form';
20+
21+
import PaywallNotification from '../../PaywallNotification';
22+
23+
import styles from './index.module.scss';
24+
25+
type Props = {
26+
readonly formData: EmailBlocklistPolicy;
27+
};
28+
29+
function BlocklistForm({ formData }: Props) {
30+
const api = useApi();
31+
const { mutate: mutateGlobal } = useSWRConfig();
32+
const { isFreeTenant } = usePaywall();
33+
34+
const { t } = useTranslation(undefined, {
35+
keyPrefix: 'admin_console.security',
36+
});
37+
38+
const { t: globalT } = useTranslation(undefined, {
39+
keyPrefix: 'admin_console',
40+
});
41+
42+
const {
43+
reset,
44+
register,
45+
handleSubmit,
46+
setError,
47+
clearErrors,
48+
control,
49+
formState: { isDirty, isSubmitting, errors },
50+
} = useForm<EmailBlocklistPolicy>({
51+
defaultValues: formData,
52+
});
53+
54+
const onSubmit = handleSubmit(
55+
trySubmitSafe(async (formData: EmailBlocklistPolicy) => {
56+
if (isSubmitting) {
57+
return;
58+
}
59+
60+
const { emailBlocklistPolicy } = await api
61+
.patch('api/sign-in-exp', {
62+
json: {
63+
emailBlocklistPolicy: formData,
64+
},
65+
})
66+
.json<SignInExperience>();
67+
68+
// Reset the form with the updated data
69+
reset(emailBlocklistPolicy);
70+
// Global mutate the SIE data
71+
await mutateGlobal('api/sign-in-exp');
72+
toast.success(globalT('general.saved'));
73+
})
74+
);
75+
76+
return (
77+
<>
78+
<PaywallNotification className={styles.paywallNotification} />
79+
<DetailsForm
80+
isDirty={isDirty}
81+
isSubmitting={isSubmitting}
82+
onSubmit={onSubmit}
83+
onDiscard={reset}
84+
>
85+
<FormCard
86+
title="security.blocklist.card_title"
87+
description="security.blocklist.card_description"
88+
tag={
89+
<CombinedAddOnAndFeatureTag
90+
hasAddOnTag
91+
paywall={latestProPlanId}
92+
addOnLabel={addOnLabels.addOnBundle}
93+
/>
94+
}
95+
learnMoreLink={{ href: emailBlocklist }}
96+
>
97+
<FormField title="security.blocklist.disposable_email.title">
98+
<Switch
99+
disabled={isFreeTenant}
100+
label={t('blocklist.disposable_email.description')}
101+
{...register('blockDisposableAddresses')}
102+
/>
103+
</FormField>
104+
<FormField title="security.blocklist.email_subaddressing.title">
105+
<Switch
106+
disabled={isFreeTenant}
107+
label={t('blocklist.email_subaddressing.description')}
108+
{...register('blockSubaddressing')}
109+
/>
110+
</FormField>
111+
<FormField title="security.blocklist.custom_email_address.title">
112+
<Controller
113+
name="customBlocklist"
114+
control={control}
115+
render={({ field: { onChange, value = [] } }) => (
116+
<MultiOptionInput
117+
disabled={isFreeTenant}
118+
values={value}
119+
placeholder={t('blocklist.custom_email_address.placeholder')}
120+
renderValue={(value) => value}
121+
validateInput={(input) => {
122+
if (value.includes(input)) {
123+
return t('blocklist.custom_email_address.duplicate_error');
124+
}
125+
126+
if (!emailOrEmailDomainRegex.test(input)) {
127+
return t('blocklist.custom_email_address.invalid_format_error');
128+
}
129+
130+
return { value: input };
131+
}}
132+
error={errors.customBlocklist?.message}
133+
onChange={onChange}
134+
onError={(error) => {
135+
setError('customBlocklist', { type: 'custom', message: error });
136+
}}
137+
onClearError={() => {
138+
clearErrors('customBlocklist');
139+
}}
140+
/>
141+
)}
142+
/>
143+
</FormField>
144+
</FormCard>
145+
</DetailsForm>
146+
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
147+
</>
148+
);
149+
}
150+
151+
export default BlocklistForm;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@use '@/scss/underscore' as _;
2+
3+
.content {
4+
display: flex;
5+
flex-direction: column;
6+
flex: 1;
7+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { type SignInExperience, type EmailBlocklistPolicy } from '@logto/schemas';
2+
import { useMemo } from 'react';
3+
import useSWR from 'swr';
4+
5+
import { FormCardSkeleton } from '@/components/FormCard';
6+
import PageMeta from '@/components/PageMeta';
7+
import RequestDataError from '@/components/RequestDataError';
8+
import { type RequestError } from '@/hooks/use-api';
9+
10+
import BlocklistForm from './BlocklistForm';
11+
import styles from './index.module.scss';
12+
13+
const defaultBlockListPolicy: EmailBlocklistPolicy = {
14+
blockDisposableAddresses: false,
15+
blockSubaddressing: false,
16+
customBlocklist: [],
17+
};
18+
19+
const useDataFetch = () => {
20+
const { data, error, isLoading, mutate } = useSWR<SignInExperience, RequestError>(
21+
'api/sign-in-exp'
22+
);
23+
24+
const formData = useMemo<EmailBlocklistPolicy | undefined>(() => {
25+
if (!data) {
26+
return;
27+
}
28+
29+
const { emailBlocklistPolicy } = data;
30+
31+
/**
32+
* Since all properties in the {@link EmailBlocklistPolicy} are optional, we need to
33+
* provide default values for any missing properties in the response.
34+
* This ensures consistency in the form data and prevents unexpected discrepancies.
35+
*/
36+
return {
37+
...defaultBlockListPolicy,
38+
...emailBlocklistPolicy,
39+
};
40+
}, [data]);
41+
42+
return {
43+
isLoading,
44+
formData,
45+
error,
46+
mutate,
47+
};
48+
};
49+
50+
function Blocklist() {
51+
const { isLoading, formData, error, mutate } = useDataFetch();
52+
53+
return (
54+
<div className={styles.content}>
55+
<PageMeta titleKey={['security.tabs.blocklist', 'security.page_title']} />
56+
{isLoading && <FormCardSkeleton formFieldCount={2} />}
57+
{error && <RequestDataError error={error} onRetry={mutate} />}
58+
{formData && <BlocklistForm formData={formData} />}
59+
</div>
60+
);
61+
}
62+
63+
export default Blocklist;

packages/console/src/pages/Security/index.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import classNames from 'classnames';
22
import { useTranslation } from 'react-i18next';
33

44
import PageMeta from '@/components/PageMeta';
5+
import { isDevFeaturesEnabled } from '@/consts/env';
56
import { security } from '@/consts/external-links';
67
import CardTitle from '@/ds-components/CardTitle';
78
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
89
import pageLayout from '@/scss/page-layout.module.scss';
910

11+
import Blocklist from './Blocklist';
1012
import Captcha from './Captcha';
1113
import General from './General';
1214
import PasswordPolicy from './PasswordPolicy';
@@ -43,6 +45,15 @@ function Security({ tab }: Props) {
4345
>
4446
{t('security.tabs.captcha')}
4547
</TabNavItem>
48+
{/** TODO: @simeng remove dev feature guard */}
49+
{isDevFeaturesEnabled && (
50+
<TabNavItem
51+
href={`/security/${SecurityTabs.Blocklist}`}
52+
isActive={tab === SecurityTabs.Blocklist}
53+
>
54+
{t('security.tabs.blocklist')}
55+
</TabNavItem>
56+
)}
4657
<TabNavItem
4758
href={`/security/${SecurityTabs.General}`}
4859
isActive={tab === SecurityTabs.General}
@@ -53,6 +64,8 @@ function Security({ tab }: Props) {
5364
{tab === SecurityTabs.Captcha && <Captcha />}
5465
{tab === SecurityTabs.PasswordPolicy && <PasswordPolicy />}
5566
{tab === SecurityTabs.General && <General />}
67+
{/** TODO: @simeng remove dev feature guard */}
68+
{isDevFeaturesEnabled && tab === SecurityTabs.Blocklist && <Blocklist />}
5669
</div>
5770
);
5871
}

packages/console/src/pages/Security/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export enum SecurityTabs {
22
Captcha = 'captcha',
33
PasswordPolicy = 'password-policy',
44
General = 'general',
5+
Blocklist = 'blocklist',
56
}

0 commit comments

Comments
 (0)