import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; import { timingSafeEqual } from "crypto"; import { uiComponent } from "@team-plain/typescript-sdk"; import { z } from "zod"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; import { generateImpersonationToken } from "~/services/impersonation.server"; // Schema for the request body from Plain const PlainCustomerCardRequestSchema = z.object({ cardKeys: z.array(z.string()), customer: z .object({ id: z.string(), email: z.string().optional(), externalId: z.string().optional(), }) .refine((data) => data.email || data.externalId, { message: "Either customer.email or customer.externalId must be provided", path: ["customer"], }), thread: z .object({ id: z.string(), }) .optional(), }); function sanitizeHeaders( request: Request, skipHeaders?: string[] ): Partial> { const authHeaderName = (env.PLAIN_CUSTOMER_CARDS_HEADERS || "Authorization").toLowerCase(); const defaultSkipHeaders = skipHeaders || [authHeaderName, "cookie"]; const sanitizedHeaders: Partial> = {}; for (const [key, value] of request.headers.entries()) { if (!defaultSkipHeaders.includes(key.toLowerCase())) { sanitizedHeaders[key] = value; } } return sanitizedHeaders; } // Authenticate the request from Plain function authenticatePlainRequest(request: Request): boolean { const authHeaderName = env.PLAIN_CUSTOMER_CARDS_HEADERS || "Authorization"; const authHeader = request.headers.get(authHeaderName); const expectedSecret = env.PLAIN_CUSTOMER_CARDS_SECRET; if (!expectedSecret) { logger.warn("PLAIN_CUSTOMER_CARDS_SECRET not configured"); return false; } if (!authHeader) { return false; } // Support both "Bearer " and plain token formats const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader; // Use constant-time comparison to prevent timing attacks const encoder = new TextEncoder(); const tokenBuffer = encoder.encode(token); const secretBuffer = encoder.encode(expectedSecret); if (tokenBuffer.byteLength !== secretBuffer.byteLength) { return false; } return timingSafeEqual(tokenBuffer, secretBuffer); } export async function action({ request }: ActionFunctionArgs) { // Only accept POST requests if (request.method !== "POST") { return json({ error: "Method not allowed" }, { status: 405 }); } // Authenticate the request if (!authenticatePlainRequest(request)) { logger.warn("Unauthorized Plain customer card request", { headers: sanitizeHeaders(request), }); return json({ error: "Unauthorized" }, { status: 401 }); } // Parse JSON with separate error handling for malformed JSON let body: unknown; try { body = await request.json(); } catch (error) { // Handle JSON parsing errors as client errors (400) instead of server errors (500) if (error instanceof SyntaxError || error instanceof TypeError) { logger.warn("Malformed JSON in Plain customer card request", { error: error.message, }); return json({ error: "Invalid JSON in request body" }, { status: 400 }); } // Re-throw unexpected errors to be caught by outer catch throw error; } // Validate the request body schema const parsed = PlainCustomerCardRequestSchema.safeParse(body); if (!parsed.success) { logger.warn("Invalid Plain customer card request", { errors: parsed.error.errors, }); return json({ error: "Invalid request body" }, { status: 400 }); } try { const { customer, cardKeys } = parsed.data; const userInclude = { orgMemberships: { where: { organization: { deletedAt: null }, }, include: { organization: { include: { projects: { where: { deletedAt: null }, take: 10, orderBy: { createdAt: "desc" as const }, }, }, }, }, }, }; const where = customer.externalId ? { id: customer.externalId } : customer.email ? { email: customer.email } : null; const user = where ? await prisma.user.findFirst({ where, include: userInclude }) : null; // If user not found, return empty cards if (!user) { logger.info("User not found for Plain customer card request", { customerId: customer.id, externalId: customer.externalId, hasEmail: !!customer.email, }); return json({ cards: [] }); } // Build cards based on requested cardKeys const cards = []; const accountDetailsKey = env.PLAIN_CUSTOMER_CARDS_KEY || "account-details"; for (const cardKey of cardKeys) { switch (cardKey) { case accountDetailsKey: { // Generate a signed one-time token for impersonation const impersonationToken = await generateImpersonationToken(user.id); // Build the impersonate URL with token for CSRF protection const impersonateUrl = `${env.APP_ORIGIN}/admin/impersonate?impersonate=${user.id}&impersonationToken=${encodeURIComponent(impersonationToken)}`; cards.push({ key: accountDetailsKey, timeToLiveSeconds: 15, components: [ uiComponent.container({ content: [ uiComponent.text({ text: "Account Details", size: "L", color: "NORMAL", }), uiComponent.spacer({ size: "M" }), uiComponent.row({ mainContent: [ uiComponent.text({ text: "User ID", size: "S", color: "MUTED", }), ], asideContent: [ uiComponent.copyButton({ value: user.id, tooltip: "Copy", }), ], }), uiComponent.spacer({ size: "S" }), uiComponent.row({ mainContent: [ uiComponent.text({ text: "Email", size: "S", color: "MUTED", }), ], asideContent: [ uiComponent.text({ text: user.email, size: "S", color: "NORMAL", }), ], }), uiComponent.spacer({ size: "S" }), uiComponent.row({ mainContent: [ uiComponent.text({ text: "Name", size: "S", color: "MUTED", }), ], asideContent: [ uiComponent.text({ text: user.name || user.displayName || "N/A", size: "S", color: "NORMAL", }), ], }), uiComponent.spacer({ size: "S" }), uiComponent.row({ mainContent: [ uiComponent.text({ text: "Member Since", size: "S", color: "MUTED", }), ], asideContent: [ uiComponent.text({ text: new Date(user.createdAt).toLocaleDateString(), size: "S", color: "NORMAL", }), ], }), uiComponent.spacer({ size: "M" }), uiComponent.divider({ spacingSize: "M" }), uiComponent.spacer({ size: "M" }), uiComponent.linkButton({ label: "Impersonate User", url: impersonateUrl, }), ], }), ], }); break; } case "organizations": { if (user.orgMemberships.length === 0) { cards.push({ key: "organizations", timeToLiveSeconds: 300, components: [ uiComponent.container({ content: [ uiComponent.text({ text: "Organizations", size: "L", color: "NORMAL", }), uiComponent.spacer({ size: "M" }), uiComponent.text({ text: "No organizations found", size: "S", color: "MUTED", }), ], }), ], }); break; } const orgComponents = user.orgMemberships.flatMap( (membership: (typeof user.orgMemberships)[0], index: number) => { const org = membership.organization; const projectCount = org.projects.length; return [ ...(index > 0 ? [uiComponent.divider({ spacingSize: "M" })] : []), uiComponent.text({ text: org.title, size: "M", color: "NORMAL", }), uiComponent.spacer({ size: "XS" }), uiComponent.row({ mainContent: [ uiComponent.badge({ label: membership.role, color: membership.role === "ADMIN" ? "BLUE" : "GREY", }), ], asideContent: [ uiComponent.text({ text: `${projectCount} recent project${projectCount !== 1 ? "s" : ""}`, size: "S", color: "MUTED", }), ], }), uiComponent.spacer({ size: "XS" }), uiComponent.linkButton({ label: "View in Dashboard", url: `${env.APP_ORIGIN}/@/orgs/${org.slug}`, }), ]; } ); cards.push({ key: "organizations", timeToLiveSeconds: 300, components: [ uiComponent.container({ content: [ uiComponent.text({ text: "Organizations", size: "L", color: "NORMAL", }), uiComponent.spacer({ size: "M" }), ...orgComponents, ], }), ], }); break; } case "projects": { const allProjects = user.orgMemberships.flatMap((membership) => membership.organization.projects.map((project) => ({ ...project, orgSlug: membership.organization.slug, })) ); if (allProjects.length === 0) { cards.push({ key: "projects", timeToLiveSeconds: 300, components: [ uiComponent.container({ content: [ uiComponent.text({ text: "Projects", size: "L", color: "NORMAL", }), uiComponent.spacer({ size: "M" }), uiComponent.text({ text: "No projects found", size: "S", color: "MUTED", }), ], }), ], }); break; } const projectComponents = allProjects .slice(0, 10) .flatMap((project: (typeof allProjects)[0] & { orgSlug: string }, index: number) => { return [ ...(index > 0 ? [uiComponent.divider({ spacingSize: "M" })] : []), uiComponent.text({ text: project.name, size: "M", color: "NORMAL", }), uiComponent.spacer({ size: "XS" }), uiComponent.row({ mainContent: [ uiComponent.badge({ label: project.version, color: project.version === "V3" ? "GREEN" : "GREY", }), ], asideContent: [ uiComponent.linkButton({ label: "View", url: `${env.APP_ORIGIN}/@/orgs/${project.orgSlug}/projects/${project.slug}`, }), ], }), ]; }); cards.push({ key: "projects", timeToLiveSeconds: 300, components: [ uiComponent.container({ content: [ uiComponent.text({ text: "Projects", size: "L", color: "NORMAL", }), uiComponent.spacer({ size: "M" }), ...projectComponents, ], }), ], }); break; } default: // Unknown card key - skip it logger.info("Unknown card key requested", { cardKey }); break; } } return json({ cards }); } catch (error) { logger.error("Error processing Plain customer card request", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); return json({ error: "Internal server error" }, { status: 500 }); } }