diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 1dc3091f16..716ff8e45c 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -107,6 +107,7 @@ const EnvironmentSchema = z SMTP_PASSWORD: z.string().optional(), PLAIN_API_KEY: z.string().optional(), + PLAIN_CUSTOMER_CARDS_SECRET: z.string().optional(), WORKER_SCHEMA: z.string().default("graphile_worker"), WORKER_CONCURRENCY: z.coerce.number().int().default(10), WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index aafb818002..10606593f9 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -21,8 +21,7 @@ import { } from "~/components/primitives/Table"; import { useUser } from "~/hooks/useUser"; import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server"; -import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server"; -import { requireUserId } from "~/services/session.server"; +import { requireUser, requireUserId } from "~/services/session.server"; import { createSearchParams } from "~/utils/searchParams"; export const SearchParams = z.object({ @@ -32,7 +31,29 @@ export const SearchParams = z.object({ export type SearchParams = z.infer; +const FormSchema = z.object({ id: z.string() }); + +async function handleImpersonationRequest( + request: Request, + userId: string +): Promise { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + return redirectWithImpersonation(request, userId, "/"); +} + export const loader = async ({ request, params }: LoaderFunctionArgs) => { + // Check if this is an impersonation request via query parameter (e.g., from Plain customer cards) + const url = new URL(request.url); + const impersonateUserId = url.searchParams.get("impersonate"); + + if (impersonateUserId) { + return handleImpersonationRequest(request, impersonateUserId); + } + + // Normal loader logic for admin dashboard const userId = await requireUserId(request); const searchParams = createSearchParams(request.url, SearchParams); @@ -44,8 +65,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson(result); }; -const FormSchema = z.object({ id: z.string() }); - export async function action({ request }: ActionFunctionArgs) { if (request.method.toLowerCase() !== "post") { return new Response("Method not allowed", { status: 405 }); @@ -54,7 +73,7 @@ export async function action({ request }: ActionFunctionArgs) { const payload = Object.fromEntries(await request.formData()); const { id } = FormSchema.parse(payload); - return redirectWithImpersonation(request, id, "/"); + return handleImpersonationRequest(request, id); } export default function AdminDashboardRoute() { diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts new file mode 100644 index 0000000000..2cd9af9f1d --- /dev/null +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -0,0 +1,393 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +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"; + +// 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(), + }), + thread: z + .object({ + id: z.string(), + }) + .optional(), +}); + +// Authenticate the request from Plain +function authenticatePlainRequest(request: Request): boolean { + const authHeader = request.headers.get("Authorization"); + 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; + + return token === expectedSecret; +} + +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: Object.fromEntries(request.headers.entries()), + }); + return json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + // Parse and validate the request body + const body = await request.json(); + const parsed = PlainCustomerCardRequestSchema.safeParse(body); + + if (!parsed.success) { + logger.warn("Invalid Plain customer card request", { + errors: parsed.error.errors, + body, + }); + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const { customer, cardKeys } = parsed.data; + + // Look up the user by externalId (which is User.id) + let user = null; + if (customer.externalId) { + user = await prisma.user.findUnique({ + where: { id: customer.externalId }, + include: { + orgMemberships: { + include: { + organization: { + include: { + projects: { + where: { deletedAt: null }, + take: 10, // Limit to recent projects + orderBy: { createdAt: "desc" }, + }, + }, + }, + }, + }, + }, + }); + } else if (customer.email) { + // Fallback to email lookup if externalId is not provided + user = await prisma.user.findUnique({ + where: { email: customer.email }, + include: { + orgMemberships: { + include: { + organization: { + include: { + projects: { + where: { deletedAt: null }, + take: 10, + orderBy: { createdAt: "desc" }, + }, + }, + }, + }, + }, + }, + }); + } + + // 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, + email: customer.email, + }); + return json({ cards: [] }); + } + + // Build cards based on requested cardKeys + const cards = []; + + for (const cardKey of cardKeys) { + switch (cardKey) { + case "account-details": { + // Build the impersonate URL + const impersonateUrl = `${env.APP_ORIGIN || "https://cloud.trigger.dev"}/admin?impersonate=${user.id}`; + + cards.push({ + key: "account-details", + timeToLiveSeconds: 300, // Cache for 5 minutes + components: [ + uiComponent.container({ + components: [ + uiComponent.text({ + text: "Account Details", + textSize: "L", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "M" }), + uiComponent.row({ + left: uiComponent.text({ + text: "User ID", + textSize: "S", + textColor: "MUTED", + }), + right: uiComponent.copyButton({ + textToCopy: user.id, + buttonLabel: "Copy", + }), + }), + uiComponent.spacer({ spacerSize: "S" }), + uiComponent.row({ + left: uiComponent.text({ + text: "Email", + textSize: "S", + textColor: "MUTED", + }), + right: uiComponent.text({ + text: user.email, + textSize: "S", + textColor: "NORMAL", + }), + }), + uiComponent.spacer({ spacerSize: "S" }), + uiComponent.row({ + left: uiComponent.text({ + text: "Name", + textSize: "S", + textColor: "MUTED", + }), + right: uiComponent.text({ + text: user.name || user.displayName || "N/A", + textSize: "S", + textColor: "NORMAL", + }), + }), + uiComponent.spacer({ spacerSize: "S" }), + uiComponent.row({ + left: uiComponent.text({ + text: "Admin", + textSize: "S", + textColor: "MUTED", + }), + right: uiComponent.badge({ + badgeLabel: user.admin ? "Yes" : "No", + badgeColor: user.admin ? "BLUE" : "GRAY", + }), + }), + uiComponent.spacer({ spacerSize: "S" }), + uiComponent.row({ + left: uiComponent.text({ + text: "Member Since", + textSize: "S", + textColor: "MUTED", + }), + right: uiComponent.text({ + text: new Date(user.createdAt).toLocaleDateString(), + textSize: "S", + textColor: "NORMAL", + }), + }), + uiComponent.spacer({ spacerSize: "M" }), + uiComponent.divider(), + uiComponent.spacer({ spacerSize: "M" }), + uiComponent.linkButton({ + buttonLabel: "Impersonate User", + buttonUrl: impersonateUrl, + buttonStyle: "PRIMARY", + }), + ], + }), + ], + }); + break; + } + + case "organizations": { + if (user.orgMemberships.length === 0) { + cards.push({ + key: "organizations", + timeToLiveSeconds: 300, + components: [ + uiComponent.container({ + components: [ + uiComponent.text({ + text: "Organizations", + textSize: "L", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "M" }), + uiComponent.text({ + text: "No organizations found", + textSize: "S", + textColor: "MUTED", + }), + ], + }), + ], + }); + break; + } + + const orgComponents = user.orgMemberships.flatMap((membership, index) => { + const org = membership.organization; + const projectCount = org.projects.length; + + return [ + ...(index > 0 ? [uiComponent.divider()] : []), + uiComponent.text({ + text: org.title, + textSize: "M", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "XS" }), + uiComponent.row({ + left: uiComponent.badge({ + badgeLabel: membership.role, + badgeColor: membership.role === "ADMIN" ? "BLUE" : "GRAY", + }), + right: uiComponent.text({ + text: `${projectCount} project${projectCount !== 1 ? "s" : ""}`, + textSize: "S", + textColor: "MUTED", + }), + }), + uiComponent.spacer({ spacerSize: "XS" }), + uiComponent.linkButton({ + buttonLabel: "View in Dashboard", + buttonUrl: `https://cloud.trigger.dev/orgs/${org.slug}`, + buttonStyle: "SECONDARY", + }), + ]; + }); + + cards.push({ + key: "organizations", + timeToLiveSeconds: 300, + components: [ + uiComponent.container({ + components: [ + uiComponent.text({ + text: "Organizations", + textSize: "L", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "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({ + components: [ + uiComponent.text({ + text: "Projects", + textSize: "L", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "M" }), + uiComponent.text({ + text: "No projects found", + textSize: "S", + textColor: "MUTED", + }), + ], + }), + ], + }); + break; + } + + const projectComponents = allProjects.slice(0, 10).flatMap((project, index) => { + return [ + ...(index > 0 ? [uiComponent.divider()] : []), + uiComponent.text({ + text: project.name, + textSize: "M", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "XS" }), + uiComponent.row({ + left: uiComponent.badge({ + badgeLabel: project.version, + badgeColor: project.version === "V3" ? "GREEN" : "GRAY", + }), + right: uiComponent.linkButton({ + buttonLabel: "View", + buttonUrl: `https://cloud.trigger.dev/orgs/${project.orgSlug}/projects/${project.slug}`, + buttonStyle: "SECONDARY", + }), + }), + ]; + }); + + cards.push({ + key: "projects", + timeToLiveSeconds: 300, + components: [ + uiComponent.container({ + components: [ + uiComponent.text({ + text: "Projects", + textSize: "L", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "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 }); + } +} diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index dee0a889d1..8f40da009a 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -51,6 +51,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ "/api/v1/authorization-code", "/api/v1/token", "/api/v1/usage/ingest", + "/api/v1/plain/customer-cards", /^\/api\/v1\/tasks\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/tasks/$id/callback/$secret /^\/api\/v1\/runs\/[^\/]+\/tasks\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/runs/$runId/tasks/$id/callback/$secret /^\/api\/v1\/http-endpoints\/[^\/]+\/env\/[^\/]+\/[^\/]+$/, // /api/v1/http-endpoints/$httpEndpointId/env/$envType/$shortcode