Skip to content

Commit dec4849

Browse files
authored
Validate IPs and IP networks (#2461)
* move ip utils to their own file * validateIpNet util based one existing validateIp * let's try a more interesting result type on the validator * validate IPs and IP nets on the route form * redo validateIp to look more like validateIpNet, esp. including message * tweak validation logic to get more helpful messages, test it * validate transit IPs on network interface edit * split parse from validate, it's clean! * fix e2e failure but it raises existential questions * test leading zeroes in width * pass noop validate functions to comboboxes that get swapped with textfields * validate IPs, IP nets and names on firewall rules form * fix subform revalidation issue * really fix subform revalidation and test it like crazy * add test for transit IPs form, incorporate PR #2480 #2480 * validate transit ip dupes in validate function * tests that catch clear button bug, fix bug * have to do the reset on success effect for transit IPs too * test IP validation in route form * polish up last two todos to do two twos too
1 parent e46216a commit dec4849

14 files changed

+623
-167
lines changed

app/components/form/fields/ComboboxField.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
useController,
1111
type Control,
1212
type FieldPath,
13+
type FieldPathValue,
1314
type FieldValues,
15+
type Validate,
1416
} from 'react-hook-form'
1517

1618
import { Combobox, type ComboboxBaseProps } from '~/ui/lib/Combobox'
@@ -25,6 +27,7 @@ export type ComboboxFieldProps<
2527
name: TName
2628
control: Control<TFieldValues>
2729
onChange?: (value: string | null | undefined) => void
30+
validate?: Validate<FieldPathValue<TFieldValues, TName>, TFieldValues>
2831
} & ComboboxBaseProps
2932

3033
export function ComboboxField<
@@ -51,9 +54,14 @@ export function ComboboxField<
5154
: allowArbitraryValues
5255
? 'Select an option or enter a custom value'
5356
: 'Select an option',
57+
validate,
5458
...props
5559
}: ComboboxFieldProps<TFieldValues, TName>) {
56-
const { field, fieldState } = useController({ name, control, rules: { required } })
60+
const { field, fieldState } = useController({
61+
name,
62+
control,
63+
rules: { required, validate },
64+
})
5765
return (
5866
<div className="max-w-lg">
5967
<Combobox

app/forms/firewall-rules-common.tsx

Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Copyright Oxide Computer Company
77
*/
88

9+
import { useEffect } from 'react'
910
import {
1011
useController,
1112
useForm,
@@ -27,7 +28,7 @@ import { CheckboxField } from '~/components/form/fields/CheckboxField'
2728
import { ComboboxField } from '~/components/form/fields/ComboboxField'
2829
import { DescriptionField } from '~/components/form/fields/DescriptionField'
2930
import { ListboxField } from '~/components/form/fields/ListboxField'
30-
import { NameField } from '~/components/form/fields/NameField'
31+
import { NameField, validateName } from '~/components/form/fields/NameField'
3132
import { NumberField } from '~/components/form/fields/NumberField'
3233
import { RadioField } from '~/components/form/fields/RadioField'
3334
import { TextField, TextFieldInner } from '~/components/form/fields/TextField'
@@ -39,6 +40,7 @@ import * as MiniTable from '~/ui/lib/MiniTable'
3940
import { TextInputHint } from '~/ui/lib/TextInput'
4041
import { KEYS } from '~/ui/util/keys'
4142
import { ALL_ISH } from '~/util/consts'
43+
import { validateIp, validateIpNet } from '~/util/ip'
4244
import { links } from '~/util/links'
4345
import { capitalize } from '~/util/str'
4446

@@ -62,7 +64,6 @@ type TargetAndHostFilterType =
6264
type TargetAndHostFormValues = {
6365
type: TargetAndHostFilterType
6466
value: string
65-
subnetVpc?: string
6667
}
6768

6869
// these are part of the target and host filter form;
@@ -132,8 +133,13 @@ const DynamicTypeAndValueFields = ({
132133
items={items}
133134
allowArbitraryValues
134135
hideOptionalTag
135-
// TODO: validate here, but it's complicated because it's conditional
136-
// on which type is selected
136+
validate={(value) =>
137+
// required: false arg is desirable because otherwise if you put in
138+
// a bad name and submit, causing it to revalidate onChange, then
139+
// clear the field you're left with a BS "Target name is required"
140+
// error message
141+
validateName(value, `${capitalize(sectionType)} name`, false)
142+
}
137143
/>
138144
) : (
139145
<TextField
@@ -146,8 +152,11 @@ const DynamicTypeAndValueFields = ({
146152
onSubmitTextField(e)
147153
}
148154
}}
149-
// TODO: validate here, but it's complicated because it's conditional
150-
// on which type is selected
155+
validate={(value) =>
156+
(valueType === 'ip' && validateIp(value)) ||
157+
(valueType === 'ip_net' && validateIpNet(value)) ||
158+
undefined
159+
}
151160
/>
152161
)}
153162
</>
@@ -233,14 +242,14 @@ type CommonFieldsProps = {
233242
error: ApiError | null
234243
}
235244

245+
const targetAndHostDefaultValues: TargetAndHostFormValues = {
246+
type: 'vpc',
247+
value: '',
248+
}
249+
236250
export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) => {
237251
const { project, vpc } = useVpcSelector()
238-
const targetAndHostDefaultValues: TargetAndHostFormValues = {
239-
type: 'vpc',
240-
value: '',
241-
// only becomes relevant when the type is 'VPC subnet'; ignored otherwise
242-
subnetVpc: vpc,
243-
}
252+
244253
// prefetchedApiQueries below are prefetched in firewall-rules-create and -edit
245254
const {
246255
data: { items: instances },
@@ -255,8 +264,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
255264
// Targets
256265
const targetForm = useForm({ defaultValues: targetAndHostDefaultValues })
257266
const targets = useController({ name: 'targets', control }).field
258-
const targetType = targetForm.watch('type')
259-
const targetValue = targetForm.watch('value')
267+
const [targetType, targetValue] = targetForm.watch(['type', 'value'])
260268
// get the list of items that are not already in the list of targets
261269
const targetItems = {
262270
vpc: availableItems(targets.value, 'vpc', vpcs),
@@ -272,8 +280,20 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
272280
if (!type || !value) return
273281
if (targets.value.some((t) => t.value === value && t.type === type)) return
274282
targets.onChange([...targets.value, { type, value }])
275-
targetForm.reset()
283+
targetForm.reset(targetAndHostDefaultValues)
276284
})
285+
// HACK: we need to reset the target form completely after a successful submit,
286+
// including especially `isSubmitted`, because that governs whether we're in
287+
// the validate regime (which doesn't validate until submit) or the reValidate
288+
// regime (which validate on every keypress). The reset inside `handleSubmit`
289+
// doesn't do that for us because `handleSubmit` set `isSubmitted: true` after
290+
// running the callback. So we have to watch for a successful submit and call
291+
// the reset again here.
292+
// https://github.com/react-hook-form/react-hook-form/blob/9a497a70a/src/logic/createFormControl.ts#L1194-L1203
293+
const { isSubmitSuccessful: targetSubmitSuccessful } = targetForm.formState
294+
useEffect(() => {
295+
if (targetSubmitSuccessful) targetForm.reset(targetAndHostDefaultValues)
296+
}, [targetSubmitSuccessful, targetForm])
277297

278298
// Ports
279299
const portRangeForm = useForm({ defaultValues: { portRange: '' } })
@@ -290,8 +310,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
290310
// Host Filters
291311
const hostForm = useForm({ defaultValues: targetAndHostDefaultValues })
292312
const hosts = useController({ name: 'hosts', control }).field
293-
const hostType = hostForm.watch('type')
294-
const hostValue = hostForm.watch('value')
313+
const [hostType, hostValue] = hostForm.watch(['type', 'value'])
295314
// get the list of items that are not already in the list of host filters
296315
const hostFilterItems = {
297316
vpc: availableItems(hosts.value, 'vpc', vpcs),
@@ -306,8 +325,13 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
306325
if (!type || !value) return
307326
if (hosts.value.some((t) => t.value === value && t.type === type)) return
308327
hosts.onChange([...hosts.value, { type, value }])
309-
hostForm.reset()
328+
hostForm.reset(targetAndHostDefaultValues)
310329
})
330+
// HACK: see comment above about doing the same for target form
331+
const { isSubmitSuccessful: hostSubmitSuccessful } = hostForm.formState
332+
useEffect(() => {
333+
if (hostSubmitSuccessful) hostForm.reset(targetAndHostDefaultValues)
334+
}, [hostSubmitSuccessful, hostForm])
311335

312336
return (
313337
<>
@@ -411,13 +435,18 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
411435
control={targetForm.control}
412436
valueType={targetType}
413437
items={targetItems[targetType]}
414-
onTypeChange={() => targetForm.setValue('value', '')}
438+
// HACK: reset the whole subform, keeping type (because we just set
439+
// it). most importantly, this resets isSubmitted so the form can go
440+
// back to validating on submit instead of change
441+
onTypeChange={() =>
442+
targetForm.reset({ type: targetForm.getValues('type'), value: '' })
443+
}
415444
onInputChange={(value) => targetForm.setValue('value', value)}
416445
onSubmitTextField={submitTarget}
417446
/>
418447
<MiniTable.ClearAndAddButtons
419448
addButtonCopy="Add target"
420-
disableClear={!!targetValue}
449+
disableClear={!targetValue}
421450
onClear={() => targetForm.reset()}
422451
onSubmit={submitTarget}
423452
/>
@@ -468,8 +497,8 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
468497
</div>
469498
<MiniTable.ClearAndAddButtons
470499
addButtonCopy="Add port filter"
471-
disableClear={!!portValue}
472-
onClear={portRangeForm.reset}
500+
disableClear={!portValue}
501+
onClear={() => portRangeForm.reset()}
473502
onSubmit={submitPortRange}
474503
/>
475504
</div>
@@ -518,13 +547,18 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
518547
control={hostForm.control}
519548
valueType={hostType}
520549
items={hostFilterItems[hostType]}
521-
onTypeChange={() => hostForm.setValue('value', '')}
550+
// HACK: reset the whole subform, keeping type (because we just set
551+
// it). most importantly, this resets isSubmitted so the form can go
552+
// back to validating on submit instead of change
553+
onTypeChange={() =>
554+
hostForm.reset({ type: hostForm.getValues('type'), value: '' })
555+
}
522556
onInputChange={(value) => hostForm.setValue('value', value)}
523557
onSubmitTextField={submitHost}
524558
/>
525559
<MiniTable.ClearAndAddButtons
526560
addButtonCopy="Add host filter"
527-
disableClear={!!hostValue}
561+
disableClear={!hostValue}
528562
onClear={() => hostForm.reset()}
529563
onSubmit={submitHost}
530564
/>

app/forms/ip-pool-range-add.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,14 @@ import { SideModalForm } from '~/components/form/SideModalForm'
1515
import { useIpPoolSelector } from '~/hooks/use-params'
1616
import { addToast } from '~/stores/toast'
1717
import { Message } from '~/ui/lib/Message'
18+
import { parseIp } from '~/util/ip'
1819
import { pb } from '~/util/path-builder'
19-
import { validateIp } from '~/util/str'
2020

2121
const defaultValues: IpRange = {
2222
first: '',
2323
last: '',
2424
}
2525

26-
const invalidAddressError = { type: 'pattern', message: 'Not a valid IP address' }
27-
2826
const ipv6Error = { type: 'pattern', message: 'IPv6 ranges are not yet supported' }
2927

3028
/**
@@ -35,20 +33,20 @@ const ipv6Error = { type: 'pattern', message: 'IPv6 ranges are not yet supported
3533
* regex twice, though.
3634
*/
3735
function resolver(values: IpRange) {
38-
const first = validateIp(values.first)
39-
const last = validateIp(values.last)
36+
const first = parseIp(values.first)
37+
const last = parseIp(values.last)
4038

4139
const errors: FieldErrors<IpRange> = {}
4240

43-
if (!first.valid) {
44-
errors.first = invalidAddressError
45-
} else if (first.isv6) {
41+
if (first.type === 'error') {
42+
errors.first = { type: 'pattern', message: first.message }
43+
} else if (first.type === 'v6') {
4644
errors.first = ipv6Error
4745
}
4846

49-
if (!last.valid) {
50-
errors.last = invalidAddressError
51-
} else if (last.isv6) {
47+
if (last.type === 'error') {
48+
errors.last = { type: 'pattern', message: last.message }
49+
} else if (last.type === 'v6') {
5250
errors.last = ipv6Error
5351
}
5452

app/forms/network-interface-edit.tsx

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { useEffect } from 'react'
89
import { useForm } from 'react-hook-form'
910
import * as R from 'remeda'
1011

@@ -25,6 +26,7 @@ import { FieldLabel } from '~/ui/lib/FieldLabel'
2526
import * as MiniTable from '~/ui/lib/MiniTable'
2627
import { TextInputHint } from '~/ui/lib/TextInput'
2728
import { KEYS } from '~/ui/util/keys'
29+
import { validateIpNet } from '~/util/ip'
2830
import { links } from '~/util/links'
2931

3032
type EditNetworkInterfaceFormProps = {
@@ -56,13 +58,18 @@ export function EditNetworkInterfaceForm({
5658
const transitIps = form.watch('transitIps') || []
5759

5860
const transitIpsForm = useForm({ defaultValues: { transitIp: '' } })
61+
const transitIpValue = transitIpsForm.watch('transitIp')
62+
const { isSubmitSuccessful: transitIpSubmitSuccessful } = transitIpsForm.formState
5963

60-
const submitTransitIp = () => {
61-
const transitIp = transitIpsForm.getValues('transitIp')
62-
if (!transitIp) return
64+
const submitTransitIp = transitIpsForm.handleSubmit(({ transitIp }) => {
65+
// validate has already checked to make sure it's not in the list
6366
form.setValue('transitIps', [...transitIps, transitIp])
6467
transitIpsForm.reset()
65-
}
68+
})
69+
70+
useEffect(() => {
71+
if (transitIpSubmitSuccessful) transitIpsForm.reset()
72+
}, [transitIpSubmitSuccessful, transitIpsForm])
6673

6774
return (
6875
<SideModalForm
@@ -92,7 +99,7 @@ export function EditNetworkInterfaceForm({
9299
Transit IPs
93100
</FieldLabel>
94101
<TextInputHint id="transitIp-help-text" className="mb-2">
95-
Enter an IPv4 or IPv6 address.{' '}
102+
An IP network, like 192.168.0.0/16.{' '}
96103
<a href={links.transitIpsDocs} target="_blank" rel="noreferrer">
97104
Learn more about transit IPs.
98105
</a>
@@ -107,12 +114,19 @@ export function EditNetworkInterfaceForm({
107114
submitTransitIp()
108115
}
109116
}}
117+
validate={(value) => {
118+
const error = validateIpNet(value)
119+
if (error) return error
120+
121+
if (transitIps.includes(value)) return 'Transit IP already in list'
122+
}}
123+
placeholder="Enter an IP network"
110124
/>
111125
</div>
112126
<MiniTable.ClearAndAddButtons
113127
addButtonCopy="Add Transit IP"
114-
disableClear={!!transitIpsForm.formState.dirtyFields.transitIp}
115-
onClear={transitIpsForm.reset}
128+
disableClear={!transitIpValue}
129+
onClear={() => transitIpsForm.reset()}
116130
onSubmit={submitTransitIp}
117131
/>
118132
</div>

0 commit comments

Comments
 (0)