Skip to content

Commit

Permalink
Add validation to API routes (#915)
Browse files Browse the repository at this point in the history
  • Loading branch information
Kiran K authored Jan 16, 2024
1 parent a8e83d9 commit 51fc00f
Show file tree
Hide file tree
Showing 11 changed files with 199 additions and 111 deletions.
20 changes: 10 additions & 10 deletions components/apiKey/APIKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,24 @@ const APIKeys = ({ team }: APIKeysProps) => {
const deleteApiKey = async (apiKey: ApiKey | null) => {
if (!apiKey) return;

const res = await fetch(`/api/teams/${team.slug}/api-keys/${apiKey.id}`, {
method: 'DELETE',
});

const { data, error } = (await res.json()) as ApiResponse<null>;
const response = await fetch(
`/api/teams/${team.slug}/api-keys/${apiKey.id}`,
{
method: 'DELETE',
}
);

setSelectedApiKey(null);
setConfirmationDialogVisible(false);

if (error) {
if (!response.ok) {
const { error } = (await response.json()) as ApiResponse;
toast.error(error.message);
return;
}

if (data) {
mutate();
toast.success(t('api-key-deleted'));
}
mutate();
toast.success(t('api-key-deleted'));
};

const apiKeys = data?.data ?? [];
Expand Down
79 changes: 44 additions & 35 deletions components/apiKey/NewAPIKey.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { toast } from 'react-hot-toast';
import { useSWRConfig } from 'swr';
import type { ApiResponse } from 'types';
import Modal from '../shared/Modal';
import { defaultHeaders } from '@/lib/common';
import { useFormik } from 'formik';
import { z } from 'zod';
import { createApiKeySchema } from '@/lib/zod/schema';

const NewAPIKey = ({
team,
Expand Down Expand Up @@ -46,51 +50,56 @@ const CreateAPIKeyForm = ({
onNewAPIKey,
closeModal,
}: CreateAPIKeyFormProps) => {
const [name, setName] = useState('');
const { t } = useTranslation('common');
const [submitting, setSubmitting] = useState(false);

// Handle form submission
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

setSubmitting(true);

const res = await fetch(`/api/teams/${team.slug}/api-keys`, {
method: 'POST',
body: JSON.stringify({ name }),
});

const { data, error } = (await res.json()) as ApiResponse<{
apiKey: string;
}>;

setSubmitting(false);

if (error) {
toast.error(error.message);
return;
}

if (data.apiKey) {
onNewAPIKey(data.apiKey);
toast.success(t('api-key-created'));
}
};
const formik = useFormik<z.infer<typeof createApiKeySchema>>({
initialValues: {
name: '',
},
validateOnBlur: false,
validate: (values) => {
try {
createApiKeySchema.parse(values);
} catch (error: any) {
return error.formErrors.fieldErrors;
}
},
onSubmit: async (values) => {
const response = await fetch(`/api/teams/${team.slug}/api-keys`, {
method: 'POST',
body: JSON.stringify(values),
headers: defaultHeaders,
});

const { data, error } = (await response.json()) as ApiResponse<{
apiKey: string;
}>;

if (error) {
toast.error(error.message);
return;
}

if (data.apiKey) {
onNewAPIKey(data.apiKey);
toast.success(t('api-key-created'));
}
},
});

return (
<form onSubmit={handleSubmit} method="POST">
<form onSubmit={formik.handleSubmit} method="POST">
<Modal.Header>{t('new-api-key')}</Modal.Header>
<Modal.Description>{t('new-api-key-description')}</Modal.Description>
<Modal.Body>
<InputWithLabel
label={t('name')}
name="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
value={formik.values.name}
onChange={formik.handleChange}
placeholder="My API Key"
className="text-sm"
error={formik.errors.name}
/>
</Modal.Body>
<Modal.Footer>
Expand All @@ -100,8 +109,8 @@ const CreateAPIKeyForm = ({
<Button
color="primary"
type="submit"
loading={submitting}
disabled={!name}
loading={formik.isSubmitting}
disabled={!formik.dirty || !formik.isValid}
size="md"
>
{t('create-api-key')}
Expand Down
3 changes: 1 addition & 2 deletions components/team/RemoveTeam.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,10 @@ const RemoveTeam = ({ team, allowDelete }: RemoveTeamProps) => {
headers: defaultHeaders,
});

const json = (await response.json()) as ApiResponse;

setLoading(false);

if (!response.ok) {
const json = (await response.json()) as ApiResponse;
toast.error(json.error.message);
return;
}
Expand Down
24 changes: 13 additions & 11 deletions components/team/TeamSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Card, InputWithLabel } from '@/components/shared';
import { defaultHeaders, domainRegex } from '@/lib/common';
import { defaultHeaders } from '@/lib/common';
import { Team } from '@prisma/client';
import { useFormik } from 'formik';
import { useTranslation } from 'next-i18next';
Expand All @@ -8,28 +8,30 @@ import React from 'react';
import { Button } from 'react-daisyui';
import toast from 'react-hot-toast';
import type { ApiResponse } from 'types';
import * as Yup from 'yup';

import { AccessControl } from '../shared/AccessControl';
import { z } from 'zod';
import { updateTeamSchema } from '@/lib/zod/schema';

const TeamSettings = ({ team }: { team: Team }) => {
const router = useRouter();
const { t } = useTranslation('common');

const formik = useFormik({
const formik = useFormik<z.infer<typeof updateTeamSchema>>({
initialValues: {
name: team.name,
slug: team.slug,
domain: team.domain,
domain: team.domain || '',
},
validationSchema: Yup.object().shape({
name: Yup.string().required('Name is required'),
slug: Yup.string().required('Slug is required'),
domain: Yup.string().nullable().matches(domainRegex, {
message: 'Invalid domain: ${value}',
}),
}),
validateOnBlur: false,
enableReinitialize: true,
validate: (values) => {
try {
updateTeamSchema.parse(values);
} catch (error: any) {
return error.formErrors.fieldErrors;
}
},
onSubmit: async (values) => {
const response = await fetch(`/api/teams/${team.slug}`, {
method: 'PUT',
Expand Down
38 changes: 38 additions & 0 deletions lib/zod/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from 'zod';
import { isValidDomain } from '../common';

export const createApiKeySchema = z.object({
name: z.string().min(1, 'Name is required'),
});

export const deleteApiKeySchema = z.object({
apiKeyId: z.string(),
});

export const teamSlugSchema = z.object({
slug: z.string(),
});

export const updateTeamSchema = z.object({
name: z.string().min(1, 'Name is required'),
slug: z.string().min(3, 'Slug must be at least 3 characters'),
domain: z
.string()
.optional()
.refine(
(domain) => {
if (!domain) {
return true;
}

return isValidDomain(domain);
},
{
message: 'Enter a domain name in the format example.com',
}
),
});

export const createTeamSchema = z.object({
name: z.string().min(1, 'Name is required'),
});
20 changes: 20 additions & 0 deletions models/team.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { prisma } from '@/lib/prisma';
import { getSession } from '@/lib/session';
import { findOrCreateApp } from '@/lib/svix';
import { teamSlugSchema } from '@/lib/zod/schema';
import { Role, Team } from '@prisma/client';
import type { NextApiRequest, NextApiResponse } from 'next';
import { getCurrentUser } from './user';

export const createTeam = async (param: {
userId: string;
Expand Down Expand Up @@ -198,3 +200,21 @@ export const getTeamMember = async (userId: string, slug: string) => {

return teamMember;
};

// Get current user with team info
export const getCurrentUserWithTeam = async (
req: NextApiRequest,
res: NextApiResponse
) => {
const user = await getCurrentUser(req, res);

const { slug } = teamSlugSchema.parse(req.query);

const { role, team } = await getTeamMember(user.id, slug);

return {
...user,
role,
team,
};
};
20 changes: 18 additions & 2 deletions models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Action, Resource, permissions } from '@/lib/permissions';
import { prisma } from '@/lib/prisma';
import { Role, TeamMember } from '@prisma/client';
import type { Session } from 'next-auth';
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from '@/lib/session';

export const createUser = async (param: {
name: string;
Expand Down Expand Up @@ -67,11 +69,11 @@ export const isAllowed = (role: Role, resource: Resource, action: Action) => {
};

export const throwIfNotAllowed = (
teamMember: TeamMember,
user: Pick<TeamMember, 'role'>,
resource: Resource,
action: Action
) => {
if (isAllowed(teamMember.role, resource, action)) {
if (isAllowed(user.role, resource, action)) {
return true;
}

Expand All @@ -80,3 +82,17 @@ export const throwIfNotAllowed = (
`You are not allowed to perform ${action} on ${resource}`
);
};

// Get current user from session
export const getCurrentUser = async (
req: NextApiRequest,
res: NextApiResponse
) => {
const session = await getSession(req, res);

if (!session) {
throw new Error('Unauthorized');
}

return session.user;
};
20 changes: 11 additions & 9 deletions pages/api/teams/[slug]/api-keys/[apiKeyId].ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
import { deleteApiKey } from 'models/apiKey';
import { throwIfNoTeamAccess } from 'models/team';
import { getCurrentUserWithTeam, throwIfNoTeamAccess } from 'models/team';
import { throwIfNotAllowed } from 'models/user';
import type { NextApiRequest, NextApiResponse } from 'next';
import { recordMetric } from '@/lib/metrics';
import env from '@/lib/env';
import { ApiError } from '@/lib/errors';
import { deleteApiKeySchema } from '@/lib/zod/schema';

export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { method } = req;

try {
if (!env.teamFeatures.apiKey) {
throw new ApiError(404, 'Not Found');
}

switch (method) {
await throwIfNoTeamAccess(req, res);

switch (req.method) {
case 'DELETE':
await handleDELETE(req, res);
break;
default:
res.setHeader('Allow', 'DELETE');
res.status(405).json({
error: { message: `Method ${method} Not Allowed` },
error: { message: `Method ${req.method} Not Allowed` },
});
}
} catch (error: any) {
Expand All @@ -37,14 +38,15 @@ export default async function handler(

// Delete an API key
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
const teamMember = await throwIfNoTeamAccess(req, res);
throwIfNotAllowed(teamMember, 'team_api_key', 'delete');
const user = await getCurrentUserWithTeam(req, res);

throwIfNotAllowed(user, 'team_api_key', 'delete');

const { apiKeyId } = req.query as { apiKeyId: string };
const { apiKeyId } = deleteApiKeySchema.parse(req.query);

await deleteApiKey(apiKeyId);

recordMetric('apikey.removed');

res.json({ data: {} });
res.status(204).end();
};
Loading

0 comments on commit 51fc00f

Please sign in to comment.