Skip to content

Commit f8ddb76

Browse files
authored
feat: Plain customer cards (#2933)
1 parent 61ae67c commit f8ddb76

7 files changed

Lines changed: 625 additions & 24 deletions

File tree

apps/webapp/app/env.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ const EnvironmentSchema = z
151151
SMTP_PASSWORD: z.string().optional(),
152152

153153
PLAIN_API_KEY: z.string().optional(),
154+
PLAIN_CUSTOMER_CARDS_SECRET: z.string().optional(),
155+
PLAIN_CUSTOMER_CARDS_KEY: z.string().optional(),
156+
PLAIN_CUSTOMER_CARDS_HEADERS: z.string().optional(),
154157
WORKER_SCHEMA: z.string().default("graphile_worker"),
155158
WORKER_CONCURRENCY: z.coerce.number().int().default(10),
156159
WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),

apps/webapp/app/models/admin.server.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,13 @@ export async function adminGetOrganizations(userId: string, { page, search }: Se
210210
};
211211
}
212212

213-
export async function redirectWithImpersonation(request: Request, userId: string, path: string) {
214-
const user = await requireUser(request);
213+
export async function redirectWithImpersonation(
214+
request: Request,
215+
userId: string,
216+
path: string,
217+
currentUser?: { id: string; admin: boolean }
218+
) {
219+
const user = currentUser ?? (await requireUser(request));
215220
if (!user.admin) {
216221
throw new Error("Unauthorized");
217222
}

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

Lines changed: 4 additions & 22 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,9 +17,7 @@ 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 { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server";
20+
import { adminGetUsers } from "~/models/admin.server";
2521
import { requireUserId } from "~/services/session.server";
2622
import { createSearchParams } from "~/utils/searchParams";
2723

@@ -32,7 +28,7 @@ export const SearchParams = z.object({
3228

3329
export type SearchParams = z.infer<typeof SearchParams>;
3430

35-
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
31+
export const loader = async ({ request }: LoaderFunctionArgs) => {
3632
const userId = await requireUserId(request);
3733

3834
const searchParams = createSearchParams(request.url, SearchParams);
@@ -44,21 +40,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
4440
return typedjson(result);
4541
};
4642

47-
const FormSchema = z.object({ id: z.string() });
48-
49-
export async function action({ request }: ActionFunctionArgs) {
50-
if (request.method.toLowerCase() !== "post") {
51-
return new Response("Method not allowed", { status: 405 });
52-
}
53-
54-
const payload = Object.fromEntries(await request.formData());
55-
const { id } = FormSchema.parse(payload);
56-
57-
return redirectWithImpersonation(request, id, "/");
58-
}
59-
6043
export default function AdminDashboardRoute() {
61-
const user = useUser();
6244
const { users, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
6345

6446
return (
@@ -136,7 +118,7 @@ export default function AdminDashboardRoute() {
136118
</TableCell>
137119
<TableCell>{user.admin ? "✅" : ""}</TableCell>
138120
<TableCell isSticky={true}>
139-
<Form method="post" reloadDocument>
121+
<Form method="post" action="/admin/impersonate" reloadDocument>
140122
<input type="hidden" name="id" value={user.id} />
141123
<Button
142124
type="submit"
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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, "/", user);
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+
// Check admin BEFORE consuming the one-time token
33+
const user = await requireUser(request);
34+
if (!user.admin) {
35+
return redirect("/");
36+
}
37+
38+
const validatedUserId = await validateAndConsumeImpersonationToken(impersonationToken);
39+
40+
if (!validatedUserId || validatedUserId !== impersonateUserId) {
41+
logger.warn("Invalid or expired impersonation token");
42+
return redirect("/");
43+
}
44+
45+
return redirectWithImpersonation(request, impersonateUserId, "/", user);
46+
};
47+
48+
export async function action({ request }: ActionFunctionArgs) {
49+
if (request.method.toLowerCase() !== "post") {
50+
return new Response("Method not allowed", { status: 405 });
51+
}
52+
53+
const payload = Object.fromEntries(await request.formData());
54+
const { id } = FormSchema.parse(payload);
55+
56+
return handleImpersonationRequest(request, id);
57+
}

0 commit comments

Comments
 (0)