Skip to content

Commit 8671cc4

Browse files
committed
moved the impersonate logic to /admin/impersonate
1 parent 39ac615 commit 8671cc4

File tree

3 files changed

+58
-64
lines changed

3 files changed

+58
-64
lines changed

apps/webapp/app/routes/admin._index.tsx

Lines changed: 5 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
22
import { Form } from "@remix-run/react";
3-
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
4-
import { redirect } from "@remix-run/server-runtime";
3+
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
54
import { typedjson, useTypedLoaderData } from "remix-typedjson";
65
import { z } from "zod";
76
import { Button, LinkButton } from "~/components/primitives/Buttons";
87
import { CopyableText } from "~/components/primitives/CopyableText";
9-
import { Header1 } from "~/components/primitives/Headers";
108
import { Input } from "~/components/primitives/Input";
119
import { PaginationControls } from "~/components/primitives/Pagination";
1210
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -19,14 +17,9 @@ import {
1917
TableHeaderCell,
2018
TableRow,
2119
} from "~/components/primitives/Table";
22-
import { useUser } from "~/hooks/useUser";
23-
import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server";
24-
import { requireUser, requireUserId } from "~/services/session.server";
25-
import {
26-
validateAndConsumeImpersonationToken,
27-
} from "~/services/impersonation.server";
20+
import { adminGetUsers } from "~/models/admin.server";
21+
import { requireUserId } from "~/services/session.server";
2822
import { createSearchParams } from "~/utils/searchParams";
29-
import { logger } from "~/services/logger.server";
3023

3124
export const SearchParams = z.object({
3225
page: z.coerce.number().optional(),
@@ -35,44 +28,7 @@ export const SearchParams = z.object({
3528

3629
export type SearchParams = z.infer<typeof SearchParams>;
3730

38-
const FormSchema = z.object({ id: z.string() });
39-
40-
async function handleImpersonationRequest(
41-
request: Request,
42-
userId: string
43-
): Promise<Response> {
44-
const user = await requireUser(request);
45-
if (!user.admin) {
46-
return redirect("/");
47-
}
48-
return redirectWithImpersonation(request, userId, "/");
49-
}
50-
51-
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
52-
// Check if this is an impersonation request via query parameter (e.g., from Plain customer cards)
53-
const url = new URL(request.url);
54-
const impersonateUserId = url.searchParams.get("impersonate");
55-
const impersonationToken = url.searchParams.get("impersonationToken");
56-
57-
if (impersonateUserId) {
58-
// Require both userId and token for GET-based impersonation
59-
if (!impersonationToken) {
60-
logger.warn("Impersonation request missing token");
61-
return redirect("/");
62-
}
63-
64-
// Validate and consume the token (prevents replay attacks)
65-
const validatedUserId = await validateAndConsumeImpersonationToken(impersonationToken);
66-
67-
if (!validatedUserId || validatedUserId !== impersonateUserId) {
68-
logger.warn("Invalid or expired impersonation token");
69-
return redirect("/");
70-
}
71-
72-
return handleImpersonationRequest(request, impersonateUserId);
73-
}
74-
75-
// Normal loader logic for admin dashboard
31+
export const loader = async ({ request }: LoaderFunctionArgs) => {
7632
const userId = await requireUserId(request);
7733

7834
const searchParams = createSearchParams(request.url, SearchParams);
@@ -84,19 +40,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
8440
return typedjson(result);
8541
};
8642

87-
export async function action({ request }: ActionFunctionArgs) {
88-
if (request.method.toLowerCase() !== "post") {
89-
return new Response("Method not allowed", { status: 405 });
90-
}
91-
92-
const payload = Object.fromEntries(await request.formData());
93-
const { id } = FormSchema.parse(payload);
94-
95-
return handleImpersonationRequest(request, id);
96-
}
97-
9843
export default function AdminDashboardRoute() {
99-
const user = useUser();
10044
const { users, filters, page, pageCount } = useTypedLoaderData<typeof loader>() as any;
10145

10246
return (
@@ -174,7 +118,7 @@ export default function AdminDashboardRoute() {
174118
</TableCell>
175119
<TableCell>{user.admin ? "✅" : ""}</TableCell>
176120
<TableCell isSticky={true}>
177-
<Form method="post" reloadDocument>
121+
<Form method="post" action="/admin/impersonate" reloadDocument>
178122
<input type="hidden" name="id" value={user.id} />
179123
<Button
180124
type="submit"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { redirectWithImpersonation } from "~/models/admin.server";
4+
import { requireUser } from "~/services/session.server";
5+
import { validateAndConsumeImpersonationToken } from "~/services/impersonation.server";
6+
import { logger } from "~/services/logger.server";
7+
8+
const FormSchema = z.object({ id: z.string() });
9+
10+
async function handleImpersonationRequest(request: Request, userId: string): Promise<Response> {
11+
const user = await requireUser(request);
12+
if (!user.admin) {
13+
return redirect("/");
14+
}
15+
return redirectWithImpersonation(request, userId, "/");
16+
}
17+
18+
export const loader = async ({ request }: LoaderFunctionArgs) => {
19+
const url = new URL(request.url);
20+
const impersonateUserId = url.searchParams.get("impersonate");
21+
const impersonationToken = url.searchParams.get("impersonationToken");
22+
23+
if (!impersonateUserId) {
24+
return redirect("/admin");
25+
}
26+
27+
if (!impersonationToken) {
28+
logger.warn("Impersonation request missing token");
29+
return redirect("/");
30+
}
31+
32+
const validatedUserId = await validateAndConsumeImpersonationToken(impersonationToken);
33+
34+
if (!validatedUserId || validatedUserId !== impersonateUserId) {
35+
logger.warn("Invalid or expired impersonation token");
36+
return redirect("/");
37+
}
38+
39+
return handleImpersonationRequest(request, impersonateUserId);
40+
};
41+
42+
export async function action({ request }: ActionFunctionArgs) {
43+
if (request.method.toLowerCase() !== "post") {
44+
return new Response("Method not allowed", { status: 405 });
45+
}
46+
47+
const payload = Object.fromEntries(await request.formData());
48+
const { id } = FormSchema.parse(payload);
49+
50+
return handleImpersonationRequest(request, id);
51+
}

apps/webapp/app/routes/api.v1.plain.customer-cards.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
2-
import { json } from "@remix-run/server-runtime";
1+
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
32
import { timingSafeEqual } from "crypto";
43
import { uiComponent } from "@team-plain/typescript-sdk";
54
import { z } from "zod";
@@ -186,7 +185,7 @@ export async function action({ request }: ActionFunctionArgs) {
186185
// Generate a signed one-time token for impersonation
187186
const impersonationToken = await generateImpersonationToken(user.id);
188187
// Build the impersonate URL with token for CSRF protection
189-
const impersonateUrl = `${env.APP_ORIGIN}/admin?impersonate=${user.id}&impersonationToken=${encodeURIComponent(impersonationToken)}`;
188+
const impersonateUrl = `${env.APP_ORIGIN}/admin/impersonate?impersonate=${user.id}&impersonationToken=${encodeURIComponent(impersonationToken)}`;
190189

191190
cards.push({
192191
key: accountDetailsKey,

0 commit comments

Comments
 (0)