diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index bdc0fb18fe..32d53a4102 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -522,6 +522,9 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do }, form: { submit: 'Add user', + error: { + emailReserved: 'Email already taken', + }, fields: { username: { placeholder: 'login', @@ -1991,19 +1994,22 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do 'By default, all users will be allowed to connect to this location. If you want to restrict access to this location to a specific group, please select it below.', aclFeatureDisabled: "ACL functionality is an enterprise feature and you've exceeded the user, device or network limits to use it. In order to use this feature, purchase an enterprise license or upgrade your existing one.", - peerDisconnectThreshold: 'Clients authorized with MFA will be disconnected from the location once there has been no network activity detected between them and the VPN gateway for a length of time configured below.', + peerDisconnectThreshold: + 'Clients authorized with MFA will be disconnected from the location once there has been no network activity detected between them and the VPN gateway for a length of time configured below.', locationMfaMode: { - description: 'Choose how MFA is enforced when connecting to this location:', - internal: "Internal MFA - MFA is enforced using Defguard's built-in MFA (e.g. TOTP, WebAuthn) with internal identity", - external: 'External MFA - If configured (see [OpenID settings](settings)) this option uses external identity provider for MFA', + description: 'Choose how MFA is enforced when connecting to this location:', + internal: + "Internal MFA - MFA is enforced using Defguard's built-in MFA (e.g. TOTP, WebAuthn) with internal identity", + external: + 'External MFA - If configured (see [OpenID settings](settings)) this option uses external identity provider for MFA', }, }, sections: { accessControl: { - header: 'Access Control & Firewall' + header: 'Access Control & Firewall', }, mfa: { - header: 'Multi-Factor Authentication' + header: 'Multi-Factor Authentication', }, }, messages: { @@ -2047,7 +2053,7 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do }, location_mfa_mode: { label: 'MFA requirement', - } + }, }, controls: { submit: 'Save changes', diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 3931b0862c..af7be4fe0c 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -1229,6 +1229,12 @@ type RootTranslation = { * A​d​d​ ​u​s​e​r */ submit: string + error: { + /** + * E​m​a​i​l​ ​a​l​r​e​a​d​y​ ​t​a​k​e​n + */ + emailReserved: string + } fields: { username: { /** @@ -7852,6 +7858,12 @@ export type TranslationFunctions = { * Add user */ submit: () => LocalizedString + error: { + /** + * Email already taken + */ + emailReserved: () => LocalizedString + } fields: { username: { /** diff --git a/web/src/pages/auth/Login/Login.tsx b/web/src/pages/auth/Login/Login.tsx index e9ec1e1ae2..0f25007922 100644 --- a/web/src/pages/auth/Login/Login.tsx +++ b/web/src/pages/auth/Login/Login.tsx @@ -6,7 +6,6 @@ import type { AxiosError } from 'axios'; import { useMemo } from 'react'; import { type SubmitHandler, useForm } from 'react-hook-form'; import { z } from 'zod'; - import { useI18nContext } from '../../../i18n/i18n-react'; import { FormInput } from '../../../shared/defguard-ui/components/Form/FormInput/FormInput'; import { Button } from '../../../shared/defguard-ui/components/Layout/Button/Button'; @@ -15,6 +14,7 @@ import { ButtonStyleVariant, } from '../../../shared/defguard-ui/components/Layout/Button/types'; import { LoaderSpinner } from '../../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; +import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; import { useAppStore } from '../../../shared/hooks/store/useAppStore'; import { useAuthStore } from '../../../shared/hooks/store/useAuthStore'; import useApi from '../../../shared/hooks/useApi'; @@ -41,6 +41,7 @@ export const Login = () => { const toaster = useToaster(); const enterpriseEnabled = useAppStore((s) => s.appInfo?.license_info.enterprise); + const { data: openIdInfo, isLoading: openIdLoading } = useQuery({ enabled: enterpriseEnabled, queryKey: [QueryKeys.FETCH_OPENID_INFO], @@ -82,8 +83,9 @@ export const Login = () => { mutationKey: [MutationKeys.LOG_IN], onSuccess: (data) => loginSubject.next(data), onError: (error: AxiosError) => { - if (error.response) { - switch (error.response.status) { + const status = error.response?.status; + if (isPresent(status)) { + switch (status) { case 401: { setError( 'password', @@ -99,12 +101,10 @@ export const Login = () => { break; } default: { - console.error(error); toaster.error(LL.messages.error()); } } } else { - console.error(error); toaster.error(LL.messages.error()); } }, diff --git a/web/src/pages/users/UsersOverview/modals/AddUserModal/components/AddUserForm/AddUserForm.tsx b/web/src/pages/users/UsersOverview/modals/AddUserModal/components/AddUserForm/AddUserForm.tsx index 1527cf9569..fc0954ac18 100644 --- a/web/src/pages/users/UsersOverview/modals/AddUserModal/components/AddUserForm/AddUserForm.tsx +++ b/web/src/pages/users/UsersOverview/modals/AddUserModal/components/AddUserForm/AddUserForm.tsx @@ -1,7 +1,7 @@ import './style.scss'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import parse from 'html-react-parser'; import { omit } from 'lodash-es'; import { useMemo, useRef, useState } from 'react'; @@ -17,6 +17,7 @@ import { ButtonSize, ButtonStyleVariant, } from '../../../../../../../shared/defguard-ui/components/Layout/Button/types'; +import { isPresent } from '../../../../../../../shared/defguard-ui/utils/isPresent'; import { useAppStore } from '../../../../../../../shared/hooks/store/useAppStore'; import { useEnterpriseUpgradeStore } from '../../../../../../../shared/hooks/store/useEnterpriseUpgradeStore'; import useApi from '../../../../../../../shared/hooks/useApi'; @@ -34,12 +35,21 @@ import { useAddUserModal } from '../../hooks/useAddUserModal'; export const AddUserForm = () => { const { LL } = useI18nContext(); const { - user: { addUser, usernameAvailable }, + user: { addUser, usernameAvailable, getUsers }, getAppInfo, } = useApi(); const reservedUserNames = useRef([]); + const { data: userEmails, isLoading: emailsLoading } = useQuery({ + queryKey: ['user'], + queryFn: getUsers, + refetchOnWindowFocus: false, + refetchOnMount: true, + select: (users) => users.map((user) => user.email), + placeholderData: (perv) => perv, + }); + const [checkingUsername, setCheckingUsername] = useState(false); const zodSchema = useMemo( @@ -55,8 +65,15 @@ export const AddUserForm = () => { password: z.string(), email: z .string() + .trim() .min(1, LL.form.error.required()) - .email(LL.form.error.invalid()), + .email(LL.form.error.invalid()) + .refine((value) => { + if (isPresent(userEmails)) { + return !userEmails.includes(value.toLowerCase()); + } + return true; + }, LL.modals.addUser.form.error.emailReserved()), last_name: z.string().min(1, LL.form.error.required()), first_name: z.string().min(1, LL.form.error.required()), phone: z.string(), @@ -97,7 +114,7 @@ export const AddUserForm = () => { }); } }), - [LL], + [LL, userEmails], ); type FormFields = z.infer; @@ -164,10 +181,8 @@ export const AddUserForm = () => { close(); } }, - onError: (err) => { - close(); + onError: () => { toaster.error(LL.messages.error()); - console.error(err); }, }); @@ -277,7 +292,7 @@ export const AddUserForm = () => { styleVariant={ButtonStyleVariant.PRIMARY} text={LL.modals.addUser.form.submit()} disabled={!isValid} - loading={addUserMutation.isPending || checkingUsername} + loading={addUserMutation.isPending || checkingUsername || emailsLoading} /> diff --git a/web/src/shared/hooks/api/api.ts b/web/src/shared/hooks/api/api.ts index 95213f5c93..4533c84bbb 100644 --- a/web/src/shared/hooks/api/api.ts +++ b/web/src/shared/hooks/api/api.ts @@ -44,9 +44,8 @@ import type { UpdateInfo } from '../store/useUpdatesStore'; const unpackRequest = (res: AxiosResponse): T => res.data; export const buildApi = (client: Axios): Api => { - const addUser = (data: AddUserRequest) => { - return client.post(`/user`, data).then(unpackRequest); - }; + const addUser = (data: AddUserRequest) => + client.post(`/user`, data).then(unpackRequest); const getMe = () => client.get(`/me`).then(unpackRequest); diff --git a/web/src/shared/hooks/api/provider.tsx b/web/src/shared/hooks/api/provider.tsx index bcd3628296..a3c6560fd3 100644 --- a/web/src/shared/hooks/api/provider.tsx +++ b/web/src/shared/hooks/api/provider.tsx @@ -15,22 +15,21 @@ const ApiContextManager = ({ children }: PropsWithChildren) => { if (client && LL && LL.messages) { const defaultResponseInterceptor = client.interceptors.response.use( (res) => { - // API sometimes returns null in optional fields. + // API returns null in optional fields. if (res.data) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment res.data = removeNulls(res.data); } return res; }, (error) => { console.error('Axios Error ', error); + throw error; }, ); return () => { client.interceptors.response.eject(defaultResponseInterceptor); }; } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [LL?.messages, client]); if (!client || !endpoints) return null;