Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add validation for records create/edit forms #416

Merged
merged 3 commits into from
Mar 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions app/components/dns-record/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,17 @@ import type { DnsRecord } from '@prisma/client';
import { useUser } from '~/utils';
import FormField from './form-field';
import { useMemo } from 'react';
import type { z } from 'zod';

type FormMode = 'CREATE' | 'EDIT';

interface dnsRecordFormProps {
mode: FormMode;
typeError?: string; // Error for 'Type' field
dnsRecord?: DnsRecord;
errors?: z.typeToFlattenedError<DnsRecord>;
}

export default function DnsRecordForm({ typeError, dnsRecord, mode }: dnsRecordFormProps) {
export default function DnsRecordForm({ dnsRecord, mode, errors }: dnsRecordFormProps) {
const user = useUser();

const submitButtonText = useMemo(() => (mode === 'CREATE' ? 'Create' : 'Update'), [mode]);
Expand All @@ -32,7 +33,11 @@ export default function DnsRecordForm({ typeError, dnsRecord, mode }: dnsRecordF
return (
<Form className="dns-record-form" method="post">
<VStack maxW="xl" spacing="2">
<FormField label="DNS Record Name" isRequired={true}>
<FormField
label="DNS Record Name"
isRequired={true}
error={errors?.fieldErrors.subdomain?.join(' ')}
>
<InputGroup>
<Input name="subdomain" defaultValue={dnsRecord?.subdomain} />
<InputRightAddon children={`.${user.baseDomain}`} />
Expand All @@ -42,7 +47,7 @@ export default function DnsRecordForm({ typeError, dnsRecord, mode }: dnsRecordF
</Tooltip>
</FormField>

<FormField label="Type" isRequired={true} error={typeError}>
<FormField label="Type" isRequired={true} error={errors?.fieldErrors.type?.join(' ')}>
<Select placeholder="Select a type" name="type" defaultValue={dnsRecord?.type}>
<option value="A">A</option>
<option value="AAAA">AAAA</option>
Expand All @@ -54,28 +59,28 @@ export default function DnsRecordForm({ typeError, dnsRecord, mode }: dnsRecordF
</Tooltip>
</FormField>

<FormField label="Value" isRequired={true}>
<FormField label="Value" isRequired={true} error={errors?.fieldErrors.value?.join(' ')}>
<Input name="value" defaultValue={dnsRecord?.value} />
<Tooltip label="Enter DNS Record value">
<InfoIcon />
</Tooltip>
</FormField>

<FormField label="Ports">
<FormField label="Ports" error={errors?.fieldErrors.ports?.join(' ')}>
<Input name="ports" defaultValue={dnsRecord?.ports ?? ''} />
<Tooltip label="Enter port(s) separated by commas (E.g. 8080, 1234)">
<InfoIcon />
</Tooltip>
</FormField>

<FormField label="Course">
<FormField label="Course" error={errors?.fieldErrors.course?.join(' ')}>
<Input name="course" />
<Tooltip label="Enter course name (E.g. OSD700)">
<InfoIcon />
</Tooltip>
</FormField>

<FormField label="Description">
<FormField label="Description" error={errors?.fieldErrors.description?.join(' ')}>
<Textarea rows={10} name="description" defaultValue={dnsRecord?.description ?? ''} />
</FormField>
</VStack>
Expand Down
19 changes: 18 additions & 1 deletion app/lib/dns.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import type {
GetChangeResponse,
ListResourceRecordSetsResponse,
} from '@aws-sdk/client-route-53';
import type { DnsRecordType } from '@prisma/client';
import { DnsRecordType } from '@prisma/client';
import { z } from 'zod';

const { AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY } = secrets;
const { NODE_ENV } = process.env;
Expand Down Expand Up @@ -348,3 +349,19 @@ export const isValueValid = (type: DnsRecordType, value: string) => {
// CNAME can be any non-empty string. Let AWS validate it.
return value.length >= 1;
};

export const DnsRecordSchema = z
.object({
subdomain: z.string().min(1),
humphd marked this conversation as resolved.
Show resolved Hide resolved
type: z.nativeEnum(DnsRecordType),
value: z.string().min(1),
ports: z.string(),
course: z.string(),
description: z.string(),
})
.refine((data) => isValueValid(data.type, data.value), {
message: 'Record value is invalid',
path: ['value'],
});

export const UpdateDnsRecordSchema = z.intersection(DnsRecordSchema, z.object({ id: z.string() }));
37 changes: 20 additions & 17 deletions app/routes/__index/dns-records/$dnsRecordId.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Container, Heading, Text } from '@chakra-ui/react';
import { DnsRecordType } from '@prisma/client';
import { redirect, typedjson, useTypedLoaderData } from 'remix-typedjson';
import { z } from 'zod';
import { parseFormSafe } from 'zodix';
import type { ActionArgs, LoaderArgs } from '@remix-run/node';
import DnsRecordForm from '~/components/dns-record/form';
import { requireUser } from '~/session.server';
import { getDnsRecordById } from '~/models/dns-record.server';
import { isNameValid, UpdateDnsRecordSchema } from '~/lib/dns.server';
import { useActionData } from '@remix-run/react';
import { buildDomain } from '~/utils';
import { addUpdateDnsRequest } from '~/queues/dns/index.server';

export const loader = async ({ request, params }: LoaderArgs) => {
Expand Down Expand Up @@ -35,22 +36,23 @@ export const loader = async ({ request, params }: LoaderArgs) => {
export const action = async ({ request }: ActionArgs) => {
const user = await requireUser(request);

const DnsRecord = z.object({
id: z.string(),
subdomain: z.string().min(1), // We do not want to consider '' a valid string
type: z.nativeEnum(DnsRecordType),
value: z.string().min(1),
ports: z.string().optional(),
course: z.string().optional(),
description: z.string().optional(),
});

const updatedDnsRecordParams = await parseFormSafe(request, DnsRecord);
const UpdateDnsRecordSchemaWithNameValidation = UpdateDnsRecordSchema.refine(
(data) => {
const fqdn = buildDomain(user.username, data.subdomain);
return isNameValid(fqdn, user.username);
},
{
message: 'Record name is invalid',
path: ['subdomain'],
}
);

const updatedDnsRecordParams = await parseFormSafe(
request,
UpdateDnsRecordSchemaWithNameValidation
);
if (updatedDnsRecordParams.success === false) {
throw new Response(updatedDnsRecordParams.error.message, {
status: 400,
});
return updatedDnsRecordParams.error.flatten();
}

const { data } = updatedDnsRecordParams;
Expand All @@ -68,6 +70,7 @@ export const action = async ({ request }: ActionArgs) => {

export default function DnsRecordRoute() {
const dnsRecord = useTypedLoaderData<typeof loader>();
const actionData = useActionData();

return (
<Container maxW="container.xl" ml={[null, null, '10vw']}>
Expand All @@ -78,7 +81,7 @@ export default function DnsRecordRoute() {
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has
been the industry's standard dummy text ever since the 1500s
</Text>
<DnsRecordForm dnsRecord={dnsRecord} mode="EDIT" />
<DnsRecordForm errors={actionData} dnsRecord={dnsRecord} mode="EDIT" />
</Container>
);
}
71 changes: 25 additions & 46 deletions app/routes/__index/dns-records/new.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,48 @@
import { Container, Heading, Text } from '@chakra-ui/react';
import { DnsRecordType } from '@prisma/client';
import { z } from 'zod';
import { parseFormSafe } from 'zodix';
import { redirect, typedjson, useTypedActionData } from 'remix-typedjson';
import { redirect } from 'remix-typedjson';

import DnsRecordForm from '~/components/dns-record/form';
import { requireUser } from '~/session.server';
import { addCreateDnsRequest } from '~/queues/dns/index.server';

import type { ActionArgs } from '@remix-run/node';
import type { ZodError } from 'zod';
import logger from '~/lib/logger.server';

function errorForField(error: ZodError, field: string) {
return error.issues.find((issue) => issue.path[0] === field)?.message;
}
import { DnsRecordSchema, isNameValid } from '~/lib/dns.server';
import { useActionData } from '@remix-run/react';
import { buildDomain } from '~/utils';

export const action = async ({ request }: ActionArgs) => {
const user = await requireUser(request);
const DnsRecordSchemaWithNameValidation = DnsRecordSchema.refine(
(data) => {
const fqdn = buildDomain(user.username, data.subdomain);
return isNameValid(fqdn, user.username);
},
{
message: 'Record name is invalid',
path: ['subdomain'],
}
);

// Create a Zod schema for validation
// Optional is not needed as we get '' if nothing is entered
const DnsRecord = z.object({
subdomain: z.string().min(1), // We do not want to consider '' a valid string
type: z.nativeEnum(DnsRecordType),
value: z.string().min(1),
ports: z.string(),
course: z.string(),
description: z.string(),
});

const newDnsRecordParams = await parseFormSafe(request, DnsRecord);

// If validations failed, we return the errors to show on the form
// Currently only returns 'type' field errors as no other validations exist
// Also, form cannot be submitted without required values
const newDnsRecordParams = await parseFormSafe(request, DnsRecordSchemaWithNameValidation);
if (newDnsRecordParams.success === false) {
return typedjson({
typeError: errorForField(newDnsRecordParams.error, 'type'),
});
return newDnsRecordParams.error.flatten();
}

// Update the DNS record's name with the user's full base domain.
// In the UI, we only ask the user to give us the first part of
// the domain name (e.g., `foo` in `foo.username.root.com`).
const { data } = newDnsRecordParams;

try {
await addCreateDnsRequest({
username: user.username,
type: data.type,
subdomain: data.subdomain,
value: data.value,
});
await addCreateDnsRequest({
username: user.username,
type: data.type,
subdomain: data.subdomain,
value: data.value,
});

return redirect(`/dns-records`);
} catch (error) {
logger.warn('Add DNS request error', error);
//Need to display an error response
return typedjson({});
}
return redirect(`/dns-records`);
};

export default function NewDnsRecordRoute() {
const errors = useTypedActionData<typeof action>();
const actionData = useActionData();

return (
<Container maxW="container.xl" ml={[null, null, '10vw']}>
Expand All @@ -74,7 +53,7 @@ export default function NewDnsRecordRoute() {
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has
been the industry's standard dummy text ever since the 1500s
</Text>
<DnsRecordForm {...errors} mode="CREATE" />
<DnsRecordForm errors={actionData} mode="CREATE" />
</Container>
);
}