Skip to content

Commit 3c4118c

Browse files
Enhances route security with parameter validation
Refactors action and loader functions to utilize a secure route utility for consistent permission and parameter validation. Introduces strict parameter validation using Zod schemas to ensure robust data handling across user management routes.
1 parent 642f072 commit 3c4118c

19 files changed

+507
-375
lines changed

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

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { DialogContent, DialogFooter, DialogTitle } from "~/components/ui/dialog
99
import { Dialog } from "~/components/ui/dialog";
1010
import { useState, useEffect } from "react";
1111
import type { OutletContext } from "./user.detail";
12+
import { createProtectedAction } from "~/lib/secureRoute";
1213

1314
const banSchema = z.object({
1415
banReason: z.string().min(1, "Ban reason is required").max(500, "Ban reason is too long"),
@@ -26,42 +27,47 @@ const banSchema = z.object({
2627
),
2728
});
2829

29-
export async function action({ request, params }: { request: Request; params: { id: string } }) {
30-
try {
31-
const formData = await request.formData();
32-
const banData = {
33-
banReason: formData.get("banReason") as string,
34-
banExpires: formData.get("banExpires") as string,
35-
};
30+
export const action = createProtectedAction({
31+
paramValidation: z.object({
32+
id: z.string(),
33+
}),
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+
};
3641

37-
const validatedData = banSchema.parse(banData);
42+
const validatedData = banSchema.parse(banData);
3843

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

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

53-
return { success: true, message: "User banned successfully" };
54-
} catch (error) {
55-
if (error instanceof z.ZodError) {
56-
return {
57-
success: false,
58-
error: "Validation failed",
59-
fieldErrors: error.flatten().fieldErrors,
60-
};
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" };
6168
}
62-
return { success: false, error: "Failed to ban user" };
63-
}
64-
}
69+
},
70+
});
6571

6672
export default function UserBanPage() {
6773
const { user } = useOutletContext<OutletContext>();

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

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,25 @@ import { DialogContent, DialogFooter, DialogTitle } from "~/components/ui/dialog
66
import { Dialog } from "~/components/ui/dialog";
77
import { useEffect, useState } from "react";
88
import type { OutletContext } from "./user.detail";
9+
import { createProtectedAction } from "~/lib/secureRoute";
10+
import { z } from "zod";
911

10-
export async function action({ params }: { params: { id: string } }) {
11-
try {
12-
await prisma.user.delete({
13-
where: { id: params.id },
14-
});
12+
export const action = createProtectedAction({
13+
paramValidation: z.object({
14+
id: z.string(),
15+
}),
16+
function: async ({ params }) => {
17+
try {
18+
await prisma.user.delete({
19+
where: { id: params.id },
20+
});
1521

16-
return { success: true, message: "User deleted successfully" };
17-
} catch (error) {
18-
return { success: false, error: "Failed to delete user" };
19-
}
20-
}
22+
return { success: true, message: "User deleted successfully" };
23+
} catch (error) {
24+
return { success: false, error: "Failed to delete user" };
25+
}
26+
},
27+
});
2128

2229
export default function UserDeletePage() {
2330
const { user } = useOutletContext<OutletContext>();

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

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,26 @@ import { DialogContent, DialogFooter, DialogTitle } from "~/components/ui/dialog
66
import { Dialog } from "~/components/ui/dialog";
77
import { useEffect, useState } from "react";
88
import type { OutletContext } from "./user.detail";
9+
import { createProtectedAction } from "~/lib/secureRoute";
10+
import { z } from "zod";
911

10-
export async function action({ params }: { params: { id: string } }) {
11-
try {
12-
// Delete all sessions for the user
13-
await prisma.session.deleteMany({
14-
where: { userId: params.id },
15-
});
12+
export const action = createProtectedAction({
13+
paramValidation: z.object({
14+
id: z.string(),
15+
}),
16+
function: async ({ params }) => {
17+
try {
18+
// Delete all sessions for the user
19+
await prisma.session.deleteMany({
20+
where: { userId: params.id },
21+
});
1622

17-
return { success: true, message: "All sessions revoked successfully" };
18-
} catch (error) {
19-
return { success: false, error: "Failed to revoke sessions" };
20-
}
21-
}
23+
return { success: true, message: "All sessions revoked successfully" };
24+
} catch (error) {
25+
return { success: false, error: "Failed to revoke sessions" };
26+
}
27+
},
28+
});
2229

2330
export default function UserRevokeAllSessionsPage() {
2431
const { user } = useOutletContext<OutletContext>();

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

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,35 +14,36 @@ import { Dialog } from "~/components/ui/dialog";
1414
import { useEffect, useState } from "react";
1515
import { auth } from "~/lib/auth";
1616
import type { OutletContext } from "./user.detail";
17+
import { createProtectedAction } from "~/lib/secureRoute";
18+
import { z } from "zod";
1719

18-
export async function action({
19-
request,
20-
params,
21-
}: {
22-
request: Request;
23-
params: { sessionId: string };
24-
}) {
25-
try {
26-
const session = await prisma.session.findUnique({
27-
where: { id: params.sessionId },
28-
});
20+
export const action = createProtectedAction({
21+
paramValidation: z.object({
22+
sessionId: z.string(),
23+
}),
24+
function: async ({ request, params }) => {
25+
try {
26+
const session = await prisma.session.findUnique({
27+
where: { id: params.sessionId },
28+
});
2929

30-
if (!session) {
31-
throw new Response("Session not found", { status: 404 });
32-
}
30+
if (!session) {
31+
throw new Response("Session not found", { status: 404 });
32+
}
3333

34-
await auth.api.revokeUserSession({
35-
headers: request.headers,
36-
body: {
37-
sessionToken: session.token,
38-
},
39-
});
34+
await auth.api.revokeUserSession({
35+
headers: request.headers,
36+
body: {
37+
sessionToken: session.token,
38+
},
39+
});
4040

41-
return { success: true, message: "Session revoked successfully" };
42-
} catch (error) {
43-
return { success: false, error: "Failed to revoke session" };
44-
}
45-
}
41+
return { success: true, message: "Session revoked successfully" };
42+
} catch (error) {
43+
return { success: false, error: "Failed to revoke session" };
44+
}
45+
},
46+
});
4647

4748
export default function UserRevokeSessionPage() {
4849
const { user } = useOutletContext<OutletContext>();

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

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,38 +9,44 @@ import { Dialog } from "~/components/ui/dialog";
99
import { useState, useEffect } from "react";
1010
import type { OutletContext } from "./user.detail";
1111
import { Checkbox } from "~/components/ui/checkbox";
12+
import { createProtectedAction } from "~/lib/secureRoute";
1213

1314
const roleSchema = z.object({
1415
roles: z.array(z.enum(["user", "admin"])).min(1, "At least one role is required"),
1516
});
1617

17-
export async function action({ request, params }: { request: Request; params: { id: string } }) {
18-
try {
19-
const formData = await request.formData();
20-
const roles = formData.getAll("roles") as string[];
18+
export const action = createProtectedAction({
19+
paramValidation: z.object({
20+
id: z.string(),
21+
}),
22+
function: async ({ request, params }) => {
23+
try {
24+
const formData = await request.formData();
25+
const roles = formData.getAll("roles") as string[];
2126

22-
const validatedData = roleSchema.parse({ roles });
27+
const validatedData = roleSchema.parse({ roles });
2328

24-
await auth.api.setRole({
25-
headers: request.headers,
26-
body: {
27-
userId: params.id,
28-
role: validatedData.roles,
29-
},
30-
});
29+
await auth.api.setRole({
30+
headers: request.headers,
31+
body: {
32+
userId: params.id,
33+
role: validatedData.roles,
34+
},
35+
});
3136

32-
return { success: true, message: "User roles updated successfully" };
33-
} catch (error) {
34-
if (error instanceof z.ZodError) {
35-
return {
36-
success: false,
37-
error: "Validation failed",
38-
fieldErrors: error.flatten().fieldErrors,
39-
};
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" };
4047
}
41-
return { success: false, error: "Failed to update user roles" };
42-
}
43-
}
48+
},
49+
});
4450

4551
export default function UserSetRolePage() {
4652
const { user } = useOutletContext<OutletContext>();

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

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,36 @@ import prisma from "~/lib/prismaClient";
99
import { formatDate } from "~/lib/date";
1010
import { Link } from "react-router";
1111
import { authClient } from "~/lib/auth-client";
12+
import { createProtectedLoader } from "~/lib/secureRoute";
13+
import { z } from "zod";
1214

13-
export async function loader({ params }: { params: { id: string } }) {
14-
const user = await prisma.user.findUnique({
15-
where: { id: params.id },
16-
include: {
17-
sessions: {
18-
orderBy: { createdAt: "desc" },
19-
take: 30,
15+
export const loader = createProtectedLoader({
16+
paramValidation: z.object({
17+
id: z.string(),
18+
}),
19+
function: async ({ params }) => {
20+
const user = await prisma.user.findUnique({
21+
where: { id: params.id },
22+
include: {
23+
sessions: {
24+
orderBy: { createdAt: "desc" },
25+
take: 30,
26+
},
2027
},
21-
},
22-
});
28+
});
2329

24-
if (!user) {
25-
throw new Response("User not found", { status: 404 });
26-
}
30+
if (!user) {
31+
throw new Response("User not found", { status: 404 });
32+
}
2733

28-
return {
29-
user: {
30-
...user,
31-
role: user.role ? user.role.split(",").map((role) => role.trim()) : null,
32-
},
33-
};
34-
}
34+
return {
35+
user: {
36+
...user,
37+
role: user.role ? user.role.split(",").map((role) => role.trim()) : null,
38+
},
39+
};
40+
},
41+
});
3542

3643
export default function UserDetailPage() {
3744
const { user } = useLoaderData<typeof loader>();

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

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,30 @@ import { DialogContent, DialogFooter, DialogTitle } from "~/components/ui/dialog
66
import { Dialog } from "~/components/ui/dialog";
77
import { useEffect, useState } from "react";
88
import type { OutletContext } from "./user.detail";
9+
import { createProtectedAction } from "~/lib/secureRoute";
10+
import { z } from "zod";
911

10-
export async function action({ params }: { params: { id: string } }) {
11-
try {
12-
await prisma.user.update({
13-
where: { id: params.id },
14-
data: {
15-
banned: false,
16-
banReason: null,
17-
banExpires: null,
18-
},
19-
});
12+
export const action = createProtectedAction({
13+
paramValidation: z.object({
14+
id: z.string(),
15+
}),
16+
function: async ({ params }) => {
17+
try {
18+
await prisma.user.update({
19+
where: { id: params.id },
20+
data: {
21+
banned: false,
22+
banReason: null,
23+
banExpires: null,
24+
},
25+
});
2026

21-
return { success: true, message: "User unbanned successfully" };
22-
} catch (error) {
23-
return { success: false, error: "Failed to unban user" };
24-
}
25-
}
27+
return { success: true, message: "User unbanned successfully" };
28+
} catch (error) {
29+
return { success: false, error: "Failed to unban user" };
30+
}
31+
},
32+
});
2633

2734
export default function UserUnbanPage() {
2835
const { user } = useOutletContext<OutletContext>();

0 commit comments

Comments
 (0)