From 0d2a08d71f1204ff66f62df8909ec2ad0cf98658 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Thu, 23 Apr 2026 06:58:20 -0400 Subject: [PATCH 1/7] chore(webapp): remove v2/v3 columns from admin orgs list --- apps/webapp/app/routes/admin.orgs.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx index 6f49636294..5aff68c9d4 100644 --- a/apps/webapp/app/routes/admin.orgs.tsx +++ b/apps/webapp/app/routes/admin.orgs.tsx @@ -85,15 +85,13 @@ export default function AdminDashboardRoute() { Slug Members id - v2? - v3? Deleted? Actions {organizations.length === 0 ? ( - + No orgs found for search ) : ( @@ -120,8 +118,6 @@ export default function AdminDashboardRoute() { - {org.v2Enabled ? "✅" : ""} - {org.v3Enabled ? "✅" : ""} {org.deletedAt ? "☠️" : ""}
From 5eb9b32d4f6bbc77d6940f84c1c797e72efa9fff Mon Sep 17 00:00:00 2001 From: isshaddad Date: Thu, 23 Apr 2026 10:02:06 -0400 Subject: [PATCH 2/7] feat(webapp): add Back Office admin tab with org rate limit editor --- .../webapp/app/components/primitives/Tabs.tsx | 17 +- .../app/routes/admin.back-office._index.tsx | 29 ++ .../routes/admin.back-office.orgs.$orgId.tsx | 420 ++++++++++++++++++ apps/webapp/app/routes/admin.back-office.tsx | 23 + apps/webapp/app/routes/admin.orgs.tsx | 11 +- apps/webapp/app/routes/admin.tsx | 5 + 6 files changed, 500 insertions(+), 5 deletions(-) create mode 100644 apps/webapp/app/routes/admin.back-office._index.tsx create mode 100644 apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx create mode 100644 apps/webapp/app/routes/admin.back-office.tsx diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx index cbc5cf4275..0fb8a020a7 100644 --- a/apps/webapp/app/components/primitives/Tabs.tsx +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -11,6 +11,7 @@ export type TabsProps = { tabs: { label: string; to: string; + end?: boolean; }[]; className?: string; layoutId: string; @@ -21,7 +22,13 @@ export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsP return ( {tabs.map((tab, index) => ( - + {tab.label} ))} @@ -62,18 +69,20 @@ export function TabLink({ children, layoutId, variant = "underline", + end = true, }: { to: string; children: ReactNode; layoutId: string; variant?: Variants; + end?: boolean; }) { if (variant === "segmented") { return ( {({ isActive, isPending }) => { const active = isActive || isPending; @@ -110,7 +119,7 @@ export function TabLink({ {({ isActive, isPending }) => { const active = isActive || isPending; @@ -131,7 +140,7 @@ export function TabLink({ // underline variant (default) return ( - + {({ isActive, isPending }) => { return ( <> diff --git a/apps/webapp/app/routes/admin.back-office._index.tsx b/apps/webapp/app/routes/admin.back-office._index.tsx new file mode 100644 index 0000000000..15e6f699b9 --- /dev/null +++ b/apps/webapp/app/routes/admin.back-office._index.tsx @@ -0,0 +1,29 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect, typedjson } from "remix-typedjson"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { Header2 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { requireUser } from "~/services/session.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + return typedjson({}); +} + +export default function BackOfficeIndex() { + return ( +
+ Back office + + Back-office actions are applied to a single organization. Pick an org from the + Organizations tab to open its detail page. + + + Pick an organization + +
+ ); +} diff --git a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx new file mode 100644 index 0000000000..774a0f46f2 --- /dev/null +++ b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx @@ -0,0 +1,420 @@ +import { Form, useNavigation, useSearchParams } from "@remix-run/react"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { Prisma } from "@trigger.dev/database"; +import { useEffect, useState } from "react"; +import { redirect, typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { FormError } from "~/components/primitives/FormError"; +import { Header1, Header2 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import * as Property from "~/components/primitives/PropertyTable"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { + RateLimitTokenBucketConfig, + type RateLimiterConfig, +} from "~/services/authorizationRateLimitMiddleware.server"; +import { logger } from "~/services/logger.server"; +import { type Duration } from "~/services/rateLimiter.server"; +import { requireUser } from "~/services/session.server"; + +type EffectiveRateLimit = { + source: "override" | "default"; + config: RateLimiterConfig; +}; + +function systemDefaultRateLimit(): RateLimiterConfig { + return { + type: "tokenBucket", + refillRate: env.API_RATE_LIMIT_REFILL_RATE, + interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration, + maxTokens: env.API_RATE_LIMIT_MAX, + }; +} + +function resolveEffectiveRateLimit(override: unknown): EffectiveRateLimit { + if (override == null) { + return { source: "default", config: systemDefaultRateLimit() }; + } + const parsed = RateLimitTokenBucketConfig.safeParse(override); + if (parsed.success) { + return { source: "override", config: parsed.data }; + } + // Override exists but isn't tokenBucket (fixedWindow/slidingWindow). We can't + // edit it from this UI — show the default and let the admin know. + return { source: "default", config: systemDefaultRateLimit() }; +} + +function parseDurationToMs(duration: string): number { + const match = duration.trim().match(/^(\d+)\s*(ms|s|m|h|d)$/); + if (!match) return 0; + const value = parseInt(match[1], 10); + switch (match[2]) { + case "ms": + return value; + case "s": + return value * 1_000; + case "m": + return value * 60_000; + case "h": + return value * 3_600_000; + case "d": + return value * 86_400_000; + default: + return 0; + } +} + +function describeRateLimit( + refillRate: number, + intervalMs: number, + maxTokens: number +): { sustained: string; burst: string } | null { + if (refillRate <= 0 || intervalMs <= 0 || maxTokens <= 0) return null; + const perMin = Math.round((refillRate * 60_000) / intervalMs); + return { + sustained: `${perMin.toLocaleString()} requests per minute`, + burst: `${maxTokens.toLocaleString()} request burst allowance`, + }; +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + + const orgId = params.orgId; + if (!orgId) { + throw new Response(null, { status: 404 }); + } + + const org = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { + id: true, + slug: true, + title: true, + createdAt: true, + apiRateLimiterConfig: true, + }, + }); + + if (!org) { + throw new Response(null, { status: 404 }); + } + + const effective = resolveEffectiveRateLimit(org.apiRateLimiterConfig); + const overrideIsIncompatible = + org.apiRateLimiterConfig != null && effective.source === "default"; + + return typedjson({ + org, + effective, + overrideIsIncompatible, + }); +} + +const SetRateLimitSchema = z.object({ + intent: z.literal("set-rate-limit"), + refillRate: z.coerce.number().int().min(1), + interval: z.string().min(1), + maxTokens: z.coerce.number().int().min(1), +}); + +const ResetRateLimitSchema = z.object({ + intent: z.literal("reset-rate-limit"), +}); + +const ActionSchema = z.discriminatedUnion("intent", [ + SetRateLimitSchema, + ResetRateLimitSchema, +]); + +export async function action({ request, params }: ActionFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + + const orgId = params.orgId; + if (!orgId) { + throw new Response(null, { status: 404 }); + } + + const formData = await request.formData(); + const submission = ActionSchema.safeParse(Object.fromEntries(formData)); + if (!submission.success) { + return json( + { errors: submission.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + const existing = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { apiRateLimiterConfig: true }, + }); + if (!existing) { + throw new Response(null, { status: 404 }); + } + + let next: RateLimiterConfig | null; + if (submission.data.intent === "set-rate-limit") { + const built = RateLimitTokenBucketConfig.safeParse({ + type: "tokenBucket", + refillRate: submission.data.refillRate, + interval: submission.data.interval, + maxTokens: submission.data.maxTokens, + }); + if (!built.success) { + return json( + { errors: built.error.flatten().fieldErrors }, + { status: 400 } + ); + } + next = built.data; + } else { + next = null; + } + + await prisma.organization.update({ + where: { id: orgId }, + data: { + apiRateLimiterConfig: next === null ? Prisma.JsonNull : (next as any), + }, + }); + + logger.info("admin.backOffice.rateLimit", { + adminUserId: user.id, + orgId, + intent: submission.data.intent, + previous: existing.apiRateLimiterConfig, + next, + }); + + return redirect(`/admin/back-office/orgs/${orgId}?saved=1`); +} + +export default function BackOfficeOrgPage() { + const { org, effective, overrideIsIncompatible } = + useTypedLoaderData(); + const actionData = useTypedActionData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state !== "idle"; + + const errors = + actionData && "errors" in actionData ? actionData.errors : null; + const hasFieldErrors = + !!errors && typeof errors === "object" && Object.keys(errors).length > 0; + const fieldError = (field: string) => + errors && typeof errors === "object" && field in errors + ? (errors as Record)[field]?.[0] + : undefined; + + const current = + effective.config.type === "tokenBucket" ? effective.config : null; + + const [isEditing, setIsEditing] = useState(false); + const [refillRate, setRefillRate] = useState( + current ? String(current.refillRate) : "" + ); + const [intervalStr, setIntervalStr] = useState( + current ? String(current.interval) : "" + ); + const [maxTokens, setMaxTokens] = useState( + current ? String(current.maxTokens) : "" + ); + + const [searchParams, setSearchParams] = useSearchParams(); + const savedJustNow = searchParams.get("saved") === "1"; + + // If a submit comes back with validation errors, re-open edit mode so the + // admin can see and correct them without clicking Edit again. + useEffect(() => { + if (hasFieldErrors) setIsEditing(true); + }, [hasFieldErrors]); + + // On successful save, drop back to view mode (the component stays mounted + // across the same-route redirect, so `isEditing` wouldn't reset on its own). + useEffect(() => { + if (savedJustNow) setIsEditing(false); + }, [savedJustNow]); + + // Auto-dismiss the "saved" banner after a few seconds. + useEffect(() => { + if (!savedJustNow) return; + const t = setTimeout(() => { + setSearchParams( + (prev) => { + prev.delete("saved"); + return prev; + }, + { replace: true, preventScrollReset: true } + ); + }, 3000); + return () => clearTimeout(t); + }, [savedJustNow, setSearchParams]); + + const currentDescription = current + ? describeRateLimit( + current.refillRate, + parseDurationToMs(String(current.interval)), + current.maxTokens + ) + : null; + + const previewDescription = describeRateLimit( + Number(refillRate) || 0, + parseDurationToMs(intervalStr), + Number(maxTokens) || 0 + ); + + const cancelEdit = () => { + setRefillRate(current ? String(current.refillRate) : ""); + setIntervalStr(current ? String(current.interval) : ""); + setMaxTokens(current ? String(current.maxTokens) : ""); + setIsEditing(false); + }; + + return ( +
+
+
+ {org.title} + + · + +
+ + Back to organizations + +
+ +
+
+ API rate limit + {!isEditing && ( + + )} +
+ + {savedJustNow && ( +
+ + Rate limit saved. + +
+ )} + + + Status:{" "} + {effective.source === "override" + ? "Custom override active." + : "Using system default."} + {overrideIsIncompatible && ( + + (An override exists but is not a tokenBucket — not editable here.) + + )} + + + {!isEditing ? ( + + {currentDescription ? ( + <> + + Sustained rate + {currentDescription.sustained} + + + Burst allowance + {currentDescription.burst} + + + ) : ( + + + No editable rate limit configured. + + + )} + + ) : ( +
+ + +
+ + setRefillRate(e.target.value)} + required + /> + {fieldError("refillRate")} +
+ +
+ + setIntervalStr(e.target.value)} + required + /> + {fieldError("interval")} +
+ +
+ + setMaxTokens(e.target.value)} + required + /> + {fieldError("maxTokens")} +
+ + + {previewDescription + ? `Preview: ${previewDescription.sustained} · ${previewDescription.burst}.` + : "Preview: enter valid values to see the effective limit."} + + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/routes/admin.back-office.tsx b/apps/webapp/app/routes/admin.back-office.tsx new file mode 100644 index 0000000000..026fc13fdc --- /dev/null +++ b/apps/webapp/app/routes/admin.back-office.tsx @@ -0,0 +1,23 @@ +import { Outlet } from "@remix-run/react"; +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect, typedjson } from "remix-typedjson"; +import { requireUser } from "~/services/session.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + return typedjson({}); +} + +export default function BackOfficeLayout() { + return ( +
+ +
+ ); +} diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx index 5aff68c9d4..6d16ab99c9 100644 --- a/apps/webapp/app/routes/admin.orgs.tsx +++ b/apps/webapp/app/routes/admin.orgs.tsx @@ -86,12 +86,13 @@ export default function AdminDashboardRoute() { Members id Deleted? + Back office Actions {organizations.length === 0 ? ( - + No orgs found for search ) : ( @@ -119,6 +120,14 @@ export default function AdminDashboardRoute() { {org.deletedAt ? "☠️" : ""} + + + Open + +
@@ -323,34 +311,56 @@ export default function BackOfficeOrgPage() { {effective.source === "override" ? "Custom override active." : "Using system default."} - {overrideIsIncompatible && ( - - (An override exists but is not a tokenBucket — not editable here.) - - )} {!isEditing ? ( - - {currentDescription ? ( - <> - - Sustained rate - {currentDescription.sustained} - - - Burst allowance - {currentDescription.burst} - - - ) : ( - - - No editable rate limit configured. - - + <> + + {effective.config.type === "tokenBucket" ? ( + currentDescription ? ( + <> + + Sustained rate + {currentDescription.sustained} + + + Burst allowance + {currentDescription.burst} + + + ) : ( + + + Invalid interval on the stored config. + + + ) + ) : ( + <> + + Type + {effective.config.type} + + + Window + {String(effective.config.window)} + + + Tokens + + {effective.config.tokens.toLocaleString()} + + + + )} + + {effective.config.type !== "tokenBucket" && ( + + This override is a {effective.config.type} limit and can't be + edited from this form. Change it in the database directly. + )} - + ) : (
From 697acd67f03da6a82cc41b1dfb71619c4020ba76 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Thu, 23 Apr 2026 10:52:26 -0400 Subject: [PATCH 6/7] more coderabbit fixes --- .../routes/admin.back-office.orgs.$orgId.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx index 284ceaf763..433d513500 100644 --- a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx +++ b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx @@ -78,9 +78,23 @@ function describeRateLimit( maxTokens: number ): { sustained: string; burst: string } | null { if (refillRate <= 0 || intervalMs <= 0 || maxTokens <= 0) return null; - const perMin = Math.round((refillRate * 60_000) / intervalMs); + const perMin = (refillRate * 60_000) / intervalMs; + let sustained: string; + if (perMin >= 1) { + sustained = `${Math.round(perMin).toLocaleString()} requests per minute`; + } else { + const perHour = perMin * 60; + if (perHour >= 1) { + sustained = `${Math.round(perHour).toLocaleString()} requests per hour`; + } else { + const perDay = perHour * 24; + const formatted = + perDay >= 10 ? Math.round(perDay).toLocaleString() : perDay.toFixed(1); + sustained = `${formatted} requests per day`; + } + } return { - sustained: `${perMin.toLocaleString()} requests per minute`, + sustained, burst: `${maxTokens.toLocaleString()} request burst allowance`, }; } From 76be07a1e77ea2208bdf66089c18d5baaaacf1f6 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Thu, 23 Apr 2026 11:07:16 -0400 Subject: [PATCH 7/7] devin changes --- apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx index 433d513500..211a5a4fd2 100644 --- a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx +++ b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx @@ -1,6 +1,5 @@ import { Form, useNavigation, useSearchParams } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { json } from "@remix-run/server-runtime"; import { useEffect, useState } from "react"; import { redirect, typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -159,7 +158,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const formData = await request.formData(); const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData)); if (!submission.success) { - return json( + return typedjson( { errors: submission.error.flatten().fieldErrors }, { status: 400 } ); @@ -180,7 +179,7 @@ export async function action({ request, params }: ActionFunctionArgs) { maxTokens: submission.data.maxTokens, }); if (!built.success) { - return json( + return typedjson( { errors: built.error.flatten().fieldErrors }, { status: 400 } );