Skip to content

Commit 2b3ae18

Browse files
authored
feat(console,schemas,core): add sentinel policy settings page (#7273)
add sentinel policy settings page
1 parent e73adf1 commit 2b3ae18

File tree

15 files changed

+291
-47
lines changed

15 files changed

+291
-47
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,9 @@ export const security: RouteObject = {
2323
path: SecurityTabs.PasswordPolicy,
2424
element: <Security tab={SecurityTabs.PasswordPolicy} />,
2525
},
26+
{
27+
path: SecurityTabs.General,
28+
element: <Security tab={SecurityTabs.General} />,
29+
},
2630
],
2731
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
@use '@/scss/underscore' as _;
2+
3+
4+
.numericInput {
5+
// From Figma design
6+
max-width: 200px;
7+
}
8+
9+
.fieldDescription {
10+
font: var(--font-body-2);
11+
color: var(--color-text-secondary);
12+
margin: _.unit(1) 0 _.unit(2);
13+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { type SignInExperience } from '@logto/schemas';
2+
import { type ChangeEvent } from 'react';
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 FormCard from '@/components/FormCard';
10+
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
11+
import FormField from '@/ds-components/FormField';
12+
import NumericInput from '@/ds-components/TextInput/NumericInput';
13+
import useApi from '@/hooks/use-api';
14+
import { trySubmitSafe } from '@/utils/form';
15+
16+
import { generalFormParser, type GeneralFormData } from '../use-data-fetch';
17+
18+
import styles from './index.module.scss';
19+
20+
type Props = {
21+
readonly formData: GeneralFormData;
22+
};
23+
24+
function GeneralForm({ formData }: Props) {
25+
const api = useApi();
26+
const { mutate: mutateGlobal } = useSWRConfig();
27+
28+
const { t } = useTranslation(undefined, {
29+
keyPrefix: 'admin_console.security',
30+
});
31+
32+
const { t: globalT } = useTranslation(undefined, {
33+
keyPrefix: 'admin_console',
34+
});
35+
36+
const {
37+
control,
38+
reset,
39+
handleSubmit,
40+
formState: { isDirty, isSubmitting, errors },
41+
} = useForm<GeneralFormData>({
42+
defaultValues: formData,
43+
});
44+
45+
const onSubmit = handleSubmit(
46+
trySubmitSafe(async (formData: GeneralFormData) => {
47+
if (isSubmitting) {
48+
return;
49+
}
50+
51+
const updatedData = await api
52+
.patch('api/sign-in-exp', {
53+
json: generalFormParser.toSignInExperience(formData),
54+
})
55+
.json<SignInExperience>();
56+
57+
// Reset the form with the updated data
58+
reset(generalFormParser.fromSignInExperience(updatedData));
59+
60+
// Global mutate the SIE data
61+
await mutateGlobal('api/sign-in-exp');
62+
63+
toast.success(globalT('general.saved'));
64+
})
65+
);
66+
67+
return (
68+
<>
69+
<DetailsForm
70+
isDirty={isDirty}
71+
isSubmitting={isSubmitting}
72+
onSubmit={onSubmit}
73+
onDiscard={reset}
74+
>
75+
<FormCard
76+
title="security.sentinel_policy.card_title"
77+
description="security.sentinel_policy.card_description"
78+
>
79+
<FormField title="security.sentinel_policy.max_attempts.title">
80+
<div className={styles.fieldDescription}>
81+
{t('sentinel_policy.max_attempts.description')}
82+
</div>
83+
<Controller
84+
name="sentinelPolicy.maxAttempts"
85+
control={control}
86+
rules={{
87+
min: 1,
88+
}}
89+
render={({ field: { onChange, value, name } }) => (
90+
<NumericInput
91+
className={styles.numericInput}
92+
name={name}
93+
value={String(value)}
94+
min={1}
95+
error={
96+
errors.sentinelPolicy?.maxAttempts &&
97+
t('sentinel_policy.max_attempts.error_message')
98+
}
99+
onChange={({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
100+
onChange(value && Number(value));
101+
}}
102+
onValueUp={() => {
103+
onChange(value + 1);
104+
}}
105+
onValueDown={() => {
106+
onChange(value - 1);
107+
}}
108+
/>
109+
)}
110+
/>
111+
</FormField>
112+
<FormField title="security.sentinel_policy.lockout_duration.title">
113+
<div className={styles.fieldDescription}>
114+
{t('sentinel_policy.lockout_duration.description')}
115+
</div>
116+
<Controller
117+
name="sentinelPolicy.lockoutDuration"
118+
control={control}
119+
rules={{
120+
min: 1,
121+
}}
122+
render={({ field: { onChange, value, name } }) => (
123+
<NumericInput
124+
className={styles.numericInput}
125+
name={name}
126+
value={String(value)}
127+
min={1}
128+
error={
129+
errors.sentinelPolicy?.lockoutDuration &&
130+
t('sentinel_policy.lockout_duration.error_message')
131+
}
132+
onChange={({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
133+
onChange(value && Number(value));
134+
}}
135+
onValueUp={() => {
136+
onChange(value + 1);
137+
}}
138+
onValueDown={() => {
139+
onChange(value - 1);
140+
}}
141+
/>
142+
)}
143+
/>
144+
</FormField>
145+
</FormCard>
146+
</DetailsForm>
147+
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
148+
</>
149+
);
150+
}
151+
152+
export default GeneralForm;
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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { FormCardSkeleton } from '@/components/FormCard';
2+
import PageMeta from '@/components/PageMeta';
3+
import RequestDataError from '@/components/RequestDataError';
4+
5+
import GeneralForm from './GeneralForm';
6+
import styles from './index.module.scss';
7+
import useDataFetch from './use-data-fetch';
8+
9+
function General() {
10+
const { isLoading, formData, error, mutate } = useDataFetch();
11+
12+
return (
13+
<div className={styles.content}>
14+
<PageMeta titleKey={['security.tabs.general', 'security.page_title']} />
15+
{isLoading ? <FormCardSkeleton formFieldCount={2} /> : null}
16+
{error && <RequestDataError error={error} onRetry={mutate} />}
17+
{formData && <GeneralForm formData={formData} />}
18+
</div>
19+
);
20+
}
21+
22+
export default General;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { defaultSentinelPolicy, type SentinelPolicy, type SignInExperience } from '@logto/schemas';
2+
import { useMemo } from 'react';
3+
import useSWR from 'swr';
4+
5+
import { type RequestError } from '@/hooks/use-api';
6+
7+
type RequiredSentinelPolicy = Required<Pick<SentinelPolicy, keyof SentinelPolicy>>;
8+
9+
export type GeneralFormData = {
10+
sentinelPolicy: RequiredSentinelPolicy;
11+
};
12+
13+
export const generalFormParser = {
14+
fromSignInExperience: ({ sentinelPolicy }: SignInExperience): GeneralFormData => ({
15+
sentinelPolicy: {
16+
// Fallback to default values if not provided
17+
...defaultSentinelPolicy,
18+
...sentinelPolicy,
19+
},
20+
}),
21+
toSignInExperience: (formData: GeneralFormData): Pick<SignInExperience, 'sentinelPolicy'> => ({
22+
sentinelPolicy: formData.sentinelPolicy,
23+
}),
24+
};
25+
26+
const useDataFetch = () => {
27+
const { data, error, isLoading, mutate } = useSWR<SignInExperience, RequestError>(
28+
'api/sign-in-exp'
29+
);
30+
31+
const formData = useMemo<GeneralFormData | undefined>(() => {
32+
if (!data) {
33+
return;
34+
}
35+
36+
return generalFormParser.fromSignInExperience(data);
37+
}, [data]);
38+
39+
return {
40+
isLoading: isLoading && !error,
41+
formData,
42+
error,
43+
mutate,
44+
};
45+
};
46+
47+
export default useDataFetch;

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ type Props = {
2929

3030
function PasswordPolicyForm({ data }: Props) {
3131
const api = useApi();
32-
3332
const { mutate: mutateGlobal } = useSWRConfig();
3433

3534
const { t } = useTranslation(undefined, {

packages/console/src/pages/Security/PasswordPolicy/index.module.scss

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,3 @@
55
flex-direction: column;
66
flex: 1;
77
}
8-
9-
.title {
10-
@include _.section-head-1;
11-
color: var(--color-neutral-variant-60);
12-
}
13-
14-
.formFieldDescription {
15-
font: var(--font-body-2);
16-
color: var(--color-text-secondary);
17-
margin: _.unit(1) 0 _.unit(2);
18-
}
19-
20-
21-
.minLength > div[class*='container'] {
22-
// From Figma design
23-
max-width: 156px;
24-
}
25-
26-
.characterTypes {
27-
display: flex;
28-
gap: _.unit(6);
29-
}
30-
31-
.textarea {
32-
margin-inline-start: _.unit(7);
33-
margin-top: _.unit(2);
34-
}

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { useTranslation } from 'react-i18next';
2-
31
import { FormCardSkeleton } from '@/components/FormCard';
42
import PageMeta from '@/components/PageMeta';
53
import RequestDataError from '@/components/RequestDataError';
@@ -11,10 +9,6 @@ import usePasswordPolicy from './use-password-policy';
119
function PasswordPolicy() {
1210
const { isLoading, data, error, mutate } = usePasswordPolicy();
1311

14-
const { t } = useTranslation(undefined, {
15-
keyPrefix: 'admin_console.security.password_policy',
16-
});
17-
1812
return (
1913
<div className={styles.content}>
2014
<PageMeta titleKey={['security.tabs.password_policy', 'security.page_title']} />

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import TabNav, { TabNavItem } from '@/ds-components/TabNav';
99
import pageLayout from '@/scss/page-layout.module.scss';
1010

1111
import Captcha from './Captcha';
12+
import General from './General';
1213
import PasswordPolicy from './PasswordPolicy';
1314
import styles from './index.module.scss';
1415
import { SecurityTabs } from './types';
@@ -45,10 +46,19 @@ function Security({ tab }: Props) {
4546
{t('security.tabs.password_policy')}
4647
</TabNavItem>
4748
)}
49+
{isDevFeaturesEnabled && (
50+
<TabNavItem
51+
href={`/security/${SecurityTabs.General}`}
52+
isActive={tab === SecurityTabs.General}
53+
>
54+
{t('security.tabs.general')}
55+
</TabNavItem>
56+
)}
4857
</TabNav>
4958
{tab === SecurityTabs.Captcha && <Captcha />}
5059
{/** TODO: Remove the dev feature guard */}
5160
{tab === SecurityTabs.PasswordPolicy && isDevFeaturesEnabled && <PasswordPolicy />}
61+
{tab === SecurityTabs.General && isDevFeaturesEnabled && <General />}
5262
</div>
5363
);
5464
}

0 commit comments

Comments
 (0)