diff --git a/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx index c9246dbba4..fd10e232cc 100644 --- a/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx +++ b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx @@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type SubmitHandler, useForm } from 'react-hook-form'; import type { Subject } from 'rxjs'; -import { z } from 'zod'; import { useI18nContext } from '../../../../../i18n/i18n-react'; import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; @@ -19,12 +18,15 @@ import type { ToggleOption } from '../../../../../shared/defguard-ui/components/ import useApi from '../../../../../shared/hooks/useApi'; import { useToaster } from '../../../../../shared/hooks/useToaster'; import type { GetAvailableLocationIpResponse } from '../../../../../shared/types'; -import { validateWireguardPublicKey } from '../../../../../shared/validators'; import { type AddStandaloneDeviceFormFields, WGConfigGenChoice, } from '../../AddStandaloneDeviceModal/types'; import { StandaloneDeviceModalFormMode } from '../types'; +import { + type StandaloneDeviceFormFields, + standaloneDeviceFormSchema, +} from './formSchema'; type Props = { onSubmit: (formValues: AddStandaloneDeviceFormFields) => Promise; @@ -32,7 +34,7 @@ type Props = { onLoadingChange: (value: boolean) => void; locationOptions: SelectOption[]; submitSubject: Subject; - defaults: AddStandaloneDeviceFormFields; + defaults: StandaloneDeviceFormFields; reservedNames: string[]; initialIpRecommendation: GetAvailableLocationIpResponse; }; @@ -57,7 +59,6 @@ export const StandaloneDeviceModalForm = ({ // auto assign upon location change is happening const [ipIsLoading, setIpIsLoading] = useState(false); const localLL = LL.modals.addStandaloneDevice.form; - const errors = LL.form.error; const labels = localLL.labels; const submitRef = useRef(null); const toaster = useToaster(); @@ -99,46 +100,12 @@ export const StandaloneDeviceModalForm = ({ const schema = useMemo( () => - z - .object({ - name: z - .string() - .min(1, LL.form.error.required()) - .refine((value) => { - if (mode === StandaloneDeviceModalFormMode.EDIT) { - const filtered = reservedNames.filter((n) => n !== defaults.name.trim()); - return !filtered.includes(value.trim()); - } - return !reservedNames.includes(value.trim()); - }, LL.form.error.reservedName()), - location_id: z.number(), - description: z.string().optional(), - modifiableIpParts: z.array(z.string().min(1, LL.form.error.required())), - generationChoice: z.nativeEnum(WGConfigGenChoice), - wireguard_pubkey: z.string().optional(), - }) - .superRefine((vals, ctx) => { - if (mode === StandaloneDeviceModalFormMode.CREATE_MANUAL) { - if (vals.generationChoice === WGConfigGenChoice.MANUAL) { - const result = validateWireguardPublicKey({ - requiredError: errors.required(), - maxError: errors.maximumLengthOf({ length: 44 }), - minError: errors.minimumLengthOf({ length: 44 }), - validKeyError: errors.invalid(), - }).safeParse(vals.wireguard_pubkey); - if (!result.success) { - result.error.errors.forEach((e) => { - ctx.addIssue({ - path: ['wireguard_pubkey'], - message: e.message, - code: 'custom', - }); - }); - } - } - } - }), - [LL.form.error, defaults.name, errors, mode, reservedNames], + standaloneDeviceFormSchema(LL, { + mode, + reservedNames, + originalName: defaults.name, + }), + [mode, reservedNames, defaults.name, LL], ); const { @@ -163,9 +130,7 @@ export const StandaloneDeviceModalForm = ({ const formIpsSet = new Set(formIps); return Array.from(formIpsSet.difference(initialIpsSet)); } - const submitHandler: SubmitHandler = async ( - formValues, - ) => { + const submitHandler: SubmitHandler = async (formValues) => { const values = formValues; const { modifiableIpParts: modifiableIpPart } = values; values.description = values.description?.trim(); @@ -181,29 +146,40 @@ export const StandaloneDeviceModalForm = ({ await onSubmit(values); return; } - try { - const response = await validateLocationIp({ - ips: newIps(values.modifiableIpParts), - location: values.location_id, - }); - const { available, valid } = response; - if (available && valid) { - await onSubmit(values); - } else { + const ips = newIps(values.modifiableIpParts); + let validationErrors = false; + let index = 0; + for (const newIp of ips) { + try { + const response = await validateLocationIp({ + ips: [newIp], + location: values.location_id, + }); + const { available, valid } = response; if (!available) { - setError('modifiableIpParts', { + validationErrors = true; + setError(`modifiableIpParts.${index}`, { message: LL.form.error.reservedIp(), }); } if (!valid) { - setError('modifiableIpParts', { + validationErrors = true; + setError(`modifiableIpParts.${index}`, { message: LL.form.error.invalidIp(), }); } + } catch (_) { + validationErrors = true; + } finally { + index++; + } + } + if (!validationErrors) { + try { + await onSubmit(values); + } catch (_) { + toaster.error(LL.messages.error()); } - } catch (e) { - toaster.error(LL.messages.error()); - console.error(e); } }; diff --git a/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/formSchema.ts b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/formSchema.ts new file mode 100644 index 0000000000..51fdb34673 --- /dev/null +++ b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/formSchema.ts @@ -0,0 +1,63 @@ +import z from 'zod'; +import type { TranslationFunctions } from '../../../../../i18n/i18n-types'; +import { isPresent } from '../../../../../shared/defguard-ui/utils/isPresent'; +import { validateWireguardPublicKey } from '../../../../../shared/validators'; +import { WGConfigGenChoice } from '../../AddStandaloneDeviceModal/types'; +import { StandaloneDeviceModalFormMode } from '../types'; + +type SchemaProps = { + mode: StandaloneDeviceModalFormMode; + reservedNames: string[]; + originalName?: string; +}; + +export const standaloneDeviceFormSchema = ( + LL: TranslationFunctions, + { mode, reservedNames, originalName }: SchemaProps, +) => { + const errors = LL.form.error; + + return z + .object({ + name: z + .string() + .min(1, LL.form.error.required()) + .refine((value) => { + if (mode === StandaloneDeviceModalFormMode.EDIT && isPresent(originalName)) { + const filtered = reservedNames.filter((n) => n !== originalName.trim()); + return !filtered.includes(value.trim()); + } + return !reservedNames.includes(value.trim()); + }, LL.form.error.reservedName()), + location_id: z.number(), + description: z.string().optional(), + modifiableIpParts: z.array(z.string().min(1, LL.form.error.required())), + generationChoice: z.nativeEnum(WGConfigGenChoice), + wireguard_pubkey: z.string().optional(), + }) + .superRefine((vals, ctx) => { + if (mode === StandaloneDeviceModalFormMode.CREATE_MANUAL) { + if (vals.generationChoice === WGConfigGenChoice.MANUAL) { + const result = validateWireguardPublicKey({ + requiredError: errors.required(), + maxError: errors.maximumLengthOf({ length: 44 }), + minError: errors.minimumLengthOf({ length: 44 }), + validKeyError: errors.invalid(), + }).safeParse(vals.wireguard_pubkey); + if (!result.success) { + result.error.errors.forEach((e) => { + ctx.addIssue({ + path: ['wireguard_pubkey'], + message: e.message, + code: 'custom', + }); + }); + } + } + } + }); +}; + +export type StandaloneDeviceFormFields = z.infer< + ReturnType +>;