Skip to content

Commit 1d39ddb

Browse files
Enhances form validation and error handling
Refactors parameter and form validation to improve error handling, utilizing Zod schemas for both parameters and form data. Introduces error handling for invalid parameters across multiple routes, providing detailed error messages and avoiding code duplication. Improves readability by consolidating validation logic and updating functions to return more informative responses on validation failures. Relates to improved UX on form interactions.
1 parent e771e63 commit 1d39ddb

21 files changed

+334
-153
lines changed

app/features/admin/routes/user.detail.ban.tsx

Lines changed: 37 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,61 +11,51 @@ import { useState, useEffect } from "react";
1111
import type { OutletContext } from "./user.detail";
1212
import { createProtectedAction } from "~/lib/secureRoute";
1313

14-
const banSchema = z.object({
15-
banReason: z.string().min(1, "Ban reason is required").max(500, "Ban reason is too long"),
16-
banExpires: z
17-
.string()
18-
.optional()
19-
.refine(
20-
(date) => {
21-
if (!date) return true;
22-
const selectedDate = new Date(date);
23-
const now = new Date();
24-
return selectedDate > now;
25-
},
26-
{ message: "Ban expiration date must be in the future" }
27-
),
28-
});
29-
3014
export const action = createProtectedAction({
3115
paramValidation: z.object({
3216
id: z.string(),
3317
}),
34-
function: async ({ request, params }) => {
35-
try {
36-
const formData = await request.formData();
37-
const banData = {
38-
banReason: formData.get("banReason") as string,
39-
banExpires: formData.get("banExpires") as string,
40-
};
18+
formValidation: z.object({
19+
banReason: z.string().min(1, "Ban reason is required").max(500, "Ban reason is too long"),
20+
banExpires: z
21+
.string()
22+
.optional()
23+
.refine(
24+
(date) => {
25+
if (!date) return true;
26+
const selectedDate = new Date(date);
27+
const now = new Date();
28+
return selectedDate > now;
29+
},
30+
{ message: "Ban expiration date must be in the future" }
31+
),
32+
}),
33+
function: async ({ request, params, form }) => {
34+
if (params.error) {
35+
return { success: false, message: params.error.message };
36+
}
37+
const { id } = params.data;
4138

42-
const validatedData = banSchema.parse(banData);
39+
if (form.error) {
40+
return { success: false, message: form.error.message, fieldErrors: form.fieldErrors };
41+
}
42+
const { banReason, banExpires } = form.data;
4343

44-
// Calculate ban duration in seconds
45-
const banExpiresIn = validatedData.banExpires
46-
? Math.floor((new Date(validatedData.banExpires).getTime() - Date.now()) / 1000)
47-
: undefined;
44+
// Calculate ban duration in seconds
45+
const banExpiresIn = banExpires
46+
? Math.floor((new Date(banExpires).getTime() - Date.now()) / 1000)
47+
: undefined;
4848

49-
await auth.api.banUser({
50-
headers: request.headers,
51-
body: {
52-
userId: params.id,
53-
banReason: validatedData.banReason,
54-
banExpiresIn,
55-
},
56-
});
49+
await auth.api.banUser({
50+
headers: request.headers,
51+
body: {
52+
userId: id,
53+
banReason: banReason,
54+
banExpiresIn,
55+
},
56+
});
5757

58-
return { success: true, message: "User banned successfully" };
59-
} catch (error) {
60-
if (error instanceof z.ZodError) {
61-
return {
62-
success: false,
63-
error: "Validation failed",
64-
fieldErrors: error.flatten().fieldErrors,
65-
};
66-
}
67-
return { success: false, error: "Failed to ban user" };
68-
}
58+
return { success: true, message: "User banned successfully" };
6959
},
7060
});
7161

app/features/admin/routes/user.detail.delete.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ export const action = createProtectedAction({
1414
id: z.string(),
1515
}),
1616
function: async ({ params }) => {
17+
if (params.error) {
18+
return { success: false, message: params.error.message };
19+
}
20+
const { id } = params.data;
21+
1722
try {
1823
await prisma.user.delete({
19-
where: { id: params.id },
24+
where: { id },
2025
});
2126

2227
return { success: true, message: "User deleted successfully" };

app/features/admin/routes/user.detail.revoke-all.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@ export const action = createProtectedAction({
1414
id: z.string(),
1515
}),
1616
function: async ({ params }) => {
17+
if (params.error) {
18+
return { success: false, message: params.error.message };
19+
}
20+
const { id: userId } = params.data;
21+
1722
try {
1823
// Delete all sessions for the user
1924
await prisma.session.deleteMany({
20-
where: { userId: params.id },
25+
where: { userId },
2126
});
2227

2328
return { success: true, message: "All sessions revoked successfully" };

app/features/admin/routes/user.detail.revoke.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@ export const action = createProtectedAction({
2222
sessionId: z.string(),
2323
}),
2424
function: async ({ request, params }) => {
25+
if (params.error) {
26+
return { success: false, message: params.error.message };
27+
}
28+
const { sessionId } = params.data;
29+
2530
try {
2631
const session = await prisma.session.findUnique({
27-
where: { id: params.sessionId },
32+
where: { id: sessionId },
2833
});
2934

3035
if (!session) {

app/features/admin/routes/user.detail.set-role.tsx

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,40 +11,33 @@ import type { OutletContext } from "./user.detail";
1111
import { Checkbox } from "~/components/ui/checkbox";
1212
import { createProtectedAction } from "~/lib/secureRoute";
1313

14-
const roleSchema = z.object({
15-
roles: z.array(z.enum(["user", "admin"])).min(1, "At least one role is required"),
16-
});
17-
1814
export const action = createProtectedAction({
1915
paramValidation: z.object({
2016
id: z.string(),
2117
}),
22-
function: async ({ request, params }) => {
23-
try {
24-
const formData = await request.formData();
25-
const roles = formData.getAll("roles") as string[];
18+
formValidation: z.object({
19+
roles: z.array(z.enum(["user", "admin"])).min(1, "At least one role is required"),
20+
}),
21+
function: async ({ request, params, form }) => {
22+
if (params.error) {
23+
return { success: false, message: params.error.message };
24+
}
25+
const { id: userId } = params.data;
2626

27-
const validatedData = roleSchema.parse({ roles });
27+
if (form.error) {
28+
return { success: false, message: form.error.message, fieldErrors: form.fieldErrors };
29+
}
30+
const { roles } = form.data;
2831

29-
await auth.api.setRole({
30-
headers: request.headers,
31-
body: {
32-
userId: params.id,
33-
role: validatedData.roles,
34-
},
35-
});
32+
await auth.api.setRole({
33+
headers: request.headers,
34+
body: {
35+
userId,
36+
role: roles,
37+
},
38+
});
3639

37-
return { success: true, message: "User roles updated successfully" };
38-
} catch (error) {
39-
if (error instanceof z.ZodError) {
40-
return {
41-
success: false,
42-
error: "Validation failed",
43-
fieldErrors: error.flatten().fieldErrors,
44-
};
45-
}
46-
return { success: false, error: "Failed to update user roles" };
47-
}
40+
return { success: true, message: "User roles updated successfully" };
4841
},
4942
});
5043

app/features/admin/routes/user.detail.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ export const loader = createProtectedLoader({
1717
id: z.string(),
1818
}),
1919
function: async ({ params }) => {
20+
if (params.error) {
21+
throw new Response(params.error.message, { status: 400 });
22+
}
23+
const { id } = params.data;
24+
2025
const user = await prisma.user.findUnique({
21-
where: { id: params.id },
26+
where: { id },
2227
include: {
2328
sessions: {
2429
orderBy: { createdAt: "desc" },

app/features/admin/routes/user.detail.unban.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ export const action = createProtectedAction({
1414
id: z.string(),
1515
}),
1616
function: async ({ params }) => {
17+
if (params.error) {
18+
return { success: false, message: params.error.message };
19+
}
20+
const { id } = params.data;
21+
1722
try {
1823
await prisma.user.update({
19-
where: { id: params.id },
24+
where: { id },
2025
data: {
2126
banned: false,
2227
banReason: null,

app/features/admin/routes/users.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,18 @@ export const loader = createProtectedLoader({
2020
sortDirection: z.enum(["asc", "desc"]).optional(),
2121
}),
2222
function: async ({ query }) => {
23+
if (query.error) {
24+
throw new Response(query.error.message, { status: 400 });
25+
}
26+
const { page, limit, search, sortBy, sortDirection } = query.data;
27+
2328
// Build the where clause for searching
24-
const where = query.search
29+
const where = search
2530
? {
2631
OR: [
27-
{ name: { contains: query.search, mode: Prisma.QueryMode.insensitive } },
28-
{ email: { contains: query.search, mode: Prisma.QueryMode.insensitive } },
29-
{ role: { contains: query.search, mode: Prisma.QueryMode.insensitive } },
32+
{ name: { contains: search, mode: Prisma.QueryMode.insensitive } },
33+
{ email: { contains: search, mode: Prisma.QueryMode.insensitive } },
34+
{ role: { contains: search, mode: Prisma.QueryMode.insensitive } },
3035
],
3136
}
3237
: {};
@@ -38,10 +43,10 @@ export const loader = createProtectedLoader({
3843
const users = await prisma.user.findMany({
3944
where,
4045
orderBy: {
41-
[query.sortBy]: query.sortDirection,
46+
[sortBy]: sortDirection,
4247
},
43-
skip: (query.page - 1) * query.limit,
44-
take: query.limit,
48+
skip: (page - 1) * limit,
49+
take: limit,
4550
});
4651

4752
return {
@@ -50,10 +55,10 @@ export const loader = createProtectedLoader({
5055
role: user.role ? user.role.split(",").map((role) => role.trim()) : null,
5156
})),
5257
total,
53-
page: query.page,
54-
limit: query.limit,
55-
sortBy: query.sortBy,
56-
sortDirection: query.sortDirection,
58+
page,
59+
limit,
60+
sortBy,
61+
sortDirection,
5762
};
5863
},
5964
});

app/features/dashboard/routes/profile.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,7 @@ import { formatDate } from "~/lib/date";
99
import { Badge } from "~/components/ui/badge";
1010
import { useState, useEffect } from "react";
1111
import { auth } from "~/lib/auth";
12-
import {
13-
redirect,
14-
useLoaderData,
15-
type LoaderFunctionArgs,
16-
type ActionFunctionArgs,
17-
Form,
18-
useActionData,
19-
} from "react-router";
12+
import { redirect, useLoaderData, Form, useActionData } from "react-router";
2013
import { authClient } from "~/lib/auth-client";
2114
import prisma from "~/lib/prismaClient";
2215
import {
@@ -31,6 +24,7 @@ import {
3124
} from "~/components/ui/alert-dialog";
3225
import { toast } from "sonner";
3326
import { createProtectedAction, createProtectedLoader } from "~/lib/secureRoute";
27+
import z from "zod";
3428

3529
type Session = {
3630
id: string;
@@ -39,6 +33,7 @@ type Session = {
3933
isCurrent: boolean;
4034
};
4135

36+
// TODO: split intent into separate actions
4237
export const action = createProtectedAction({
4338
function: async ({ request }) => {
4439
const formData = await request.formData();

app/features/organization/routes/members.invite.cancel.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export const action = createProtectedAction({
2323
inviteId: z.string(),
2424
}),
2525
function: async ({ request, params }) => {
26-
const inviteId = params.inviteId;
26+
if (params.error) {
27+
return { success: false, message: params.error.message };
28+
}
29+
const { inviteId } = params.data;
2730

2831
if (!inviteId) {
2932
return { success: false, message: "Invitation ID is required" };
@@ -49,7 +52,11 @@ export const loader = createProtectedLoader({
4952
inviteId: z.string(),
5053
}),
5154
function: async ({ params }) => {
52-
const inviteId = params.inviteId;
55+
if (params.error) {
56+
throw new Response(params.error.message, { status: 400 });
57+
}
58+
59+
const { inviteId } = params.data;
5360

5461
if (!inviteId) {
5562
return redirect("/app/organization/members");

0 commit comments

Comments
 (0)