11import { Form , useNavigation , useSearchParams } from "@remix-run/react" ;
22import type { ActionFunctionArgs , LoaderFunctionArgs } from "@remix-run/server-runtime" ;
33import { json } from "@remix-run/server-runtime" ;
4- import { Prisma } from "@trigger.dev/database" ;
54import { useEffect , useState } from "react" ;
65import { redirect , typedjson , useTypedActionData , useTypedLoaderData } from "remix-typedjson" ;
76import { z } from "zod" ;
@@ -17,12 +16,15 @@ import { prisma } from "~/db.server";
1716import { env } from "~/env.server" ;
1817import {
1918 RateLimitTokenBucketConfig ,
20- type RateLimiterConfig ,
19+ RateLimiterConfig ,
2120} from "~/services/authorizationRateLimitMiddleware.server" ;
2221import { logger } from "~/services/logger.server" ;
2322import { type Duration } from "~/services/rateLimiter.server" ;
2423import { requireUser } from "~/services/session.server" ;
2524
25+ const SAVED_QUERY_KEY = "saved" ;
26+ const SAVED_QUERY_VALUE = "1" ;
27+
2628type EffectiveRateLimit = {
2729 source : "override" | "default" ;
2830 config : RateLimiterConfig ;
@@ -41,12 +43,12 @@ function resolveEffectiveRateLimit(override: unknown): EffectiveRateLimit {
4143 if ( override == null ) {
4244 return { source : "default" , config : systemDefaultRateLimit ( ) } ;
4345 }
44- const parsed = RateLimitTokenBucketConfig . safeParse ( override ) ;
46+ const parsed = RateLimiterConfig . safeParse ( override ) ;
4547 if ( parsed . success ) {
4648 return { source : "override" , config : parsed . data } ;
4749 }
48- // Override exists but isn't tokenBucket (fixedWindow/slidingWindow). We can't
49- // edit it from this UI — show the default and let the admin know .
50+ // Column holds malformed JSON — fall back silently. Admin must investigate
51+ // at the DB level; this UI can't recover it .
5052 return { source : "default" , config : systemDefaultRateLimit ( ) } ;
5153}
5254
@@ -110,32 +112,25 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
110112 }
111113
112114 const effective = resolveEffectiveRateLimit ( org . apiRateLimiterConfig ) ;
113- const overrideIsIncompatible =
114- org . apiRateLimiterConfig != null && effective . source === "default" ;
115115
116116 return typedjson ( {
117117 org,
118118 effective,
119- overrideIsIncompatible,
120119 } ) ;
121120}
122121
123122const SetRateLimitSchema = z . object ( {
124123 intent : z . literal ( "set-rate-limit" ) ,
125124 refillRate : z . coerce . number ( ) . int ( ) . min ( 1 ) ,
126- interval : z . string ( ) . min ( 1 ) ,
125+ interval : z
126+ . string ( )
127+ . trim ( )
128+ . refine ( ( v ) => parseDurationToMs ( v ) > 0 , {
129+ message : "Must be a duration like 10s, 1m, 500ms." ,
130+ } ) ,
127131 maxTokens : z . coerce . number ( ) . int ( ) . min ( 1 ) ,
128132} ) ;
129133
130- const ResetRateLimitSchema = z . object ( {
131- intent : z . literal ( "reset-rate-limit" ) ,
132- } ) ;
133-
134- const ActionSchema = z . discriminatedUnion ( "intent" , [
135- SetRateLimitSchema ,
136- ResetRateLimitSchema ,
137- ] ) ;
138-
139134export async function action ( { request, params } : ActionFunctionArgs ) {
140135 const user = await requireUser ( request ) ;
141136 if ( ! user . admin ) {
@@ -148,7 +143,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
148143 }
149144
150145 const formData = await request . formData ( ) ;
151- const submission = ActionSchema . safeParse ( Object . fromEntries ( formData ) ) ;
146+ const submission = SetRateLimitSchema . safeParse ( Object . fromEntries ( formData ) ) ;
152147 if ( ! submission . success ) {
153148 return json (
154149 { errors : submission . error . flatten ( ) . fieldErrors } ,
@@ -164,46 +159,39 @@ export async function action({ request, params }: ActionFunctionArgs) {
164159 throw new Response ( null , { status : 404 } ) ;
165160 }
166161
167- let next : RateLimiterConfig | null ;
168- if ( submission . data . intent === "set-rate-limit" ) {
169- const built = RateLimitTokenBucketConfig . safeParse ( {
170- type : "tokenBucket" ,
171- refillRate : submission . data . refillRate ,
172- interval : submission . data . interval ,
173- maxTokens : submission . data . maxTokens ,
174- } ) ;
175- if ( ! built . success ) {
176- return json (
177- { errors : built . error . flatten ( ) . fieldErrors } ,
178- { status : 400 }
179- ) ;
180- }
181- next = built . data ;
182- } else {
183- next = null ;
162+ const built = RateLimitTokenBucketConfig . safeParse ( {
163+ type : "tokenBucket" ,
164+ refillRate : submission . data . refillRate ,
165+ interval : submission . data . interval ,
166+ maxTokens : submission . data . maxTokens ,
167+ } ) ;
168+ if ( ! built . success ) {
169+ return json (
170+ { errors : built . error . flatten ( ) . fieldErrors } ,
171+ { status : 400 }
172+ ) ;
184173 }
174+ const next = built . data ;
185175
186176 await prisma . organization . update ( {
187177 where : { id : orgId } ,
188- data : {
189- apiRateLimiterConfig : next === null ? Prisma . JsonNull : ( next as any ) ,
190- } ,
178+ data : { apiRateLimiterConfig : next as any } ,
191179 } ) ;
192180
193181 logger . info ( "admin.backOffice.rateLimit" , {
194182 adminUserId : user . id ,
195183 orgId,
196- intent : submission . data . intent ,
197184 previous : existing . apiRateLimiterConfig ,
198185 next,
199186 } ) ;
200187
201- return redirect ( `/admin/back-office/orgs/${ orgId } ?saved=1` ) ;
188+ return redirect (
189+ `/admin/back-office/orgs/${ orgId } ?${ SAVED_QUERY_KEY } =${ SAVED_QUERY_VALUE } `
190+ ) ;
202191}
203192
204193export default function BackOfficeOrgPage ( ) {
205- const { org, effective, overrideIsIncompatible } =
206- useTypedLoaderData < typeof loader > ( ) ;
194+ const { org, effective } = useTypedLoaderData < typeof loader > ( ) ;
207195 const actionData = useTypedActionData < typeof action > ( ) ;
208196 const navigation = useNavigation ( ) ;
209197 const isSubmitting = navigation . state !== "idle" ;
@@ -232,7 +220,7 @@ export default function BackOfficeOrgPage() {
232220 ) ;
233221
234222 const [ searchParams , setSearchParams ] = useSearchParams ( ) ;
235- const savedJustNow = searchParams . get ( "saved" ) === "1" ;
223+ const savedJustNow = searchParams . get ( SAVED_QUERY_KEY ) === SAVED_QUERY_VALUE ;
236224
237225 // If a submit comes back with validation errors, re-open edit mode so the
238226 // admin can see and correct them without clicking Edit again.
@@ -252,7 +240,7 @@ export default function BackOfficeOrgPage() {
252240 const t = setTimeout ( ( ) => {
253241 setSearchParams (
254242 ( prev ) => {
255- prev . delete ( "saved" ) ;
243+ prev . delete ( SAVED_QUERY_KEY ) ;
256244 return prev ;
257245 } ,
258246 { replace : true , preventScrollReset : true }
@@ -303,7 +291,7 @@ export default function BackOfficeOrgPage() {
303291 < Button
304292 variant = "tertiary/small"
305293 onClick = { ( ) => setIsEditing ( true ) }
306- disabled = { isSubmitting }
294+ disabled = { isSubmitting || effective . config . type !== "tokenBucket" }
307295 >
308296 Edit
309297 </ Button >
@@ -323,34 +311,56 @@ export default function BackOfficeOrgPage() {
323311 { effective . source === "override"
324312 ? "Custom override active."
325313 : "Using system default." }
326- { overrideIsIncompatible && (
327- < span className = "ml-2 text-amber-500" >
328- (An override exists but is not a tokenBucket — not editable here.)
329- </ span >
330- ) }
331314 </ Paragraph >
332315
333316 { ! isEditing ? (
334- < Property . Table >
335- { currentDescription ? (
336- < >
337- < Property . Item >
338- < Property . Label > Sustained rate</ Property . Label >
339- < Property . Value > { currentDescription . sustained } </ Property . Value >
340- </ Property . Item >
341- < Property . Item >
342- < Property . Label > Burst allowance</ Property . Label >
343- < Property . Value > { currentDescription . burst } </ Property . Value >
344- </ Property . Item >
345- </ >
346- ) : (
347- < Property . Item >
348- < Property . Value >
349- No editable rate limit configured.
350- </ Property . Value >
351- </ Property . Item >
317+ < >
318+ < Property . Table >
319+ { effective . config . type === "tokenBucket" ? (
320+ currentDescription ? (
321+ < >
322+ < Property . Item >
323+ < Property . Label > Sustained rate</ Property . Label >
324+ < Property . Value > { currentDescription . sustained } </ Property . Value >
325+ </ Property . Item >
326+ < Property . Item >
327+ < Property . Label > Burst allowance</ Property . Label >
328+ < Property . Value > { currentDescription . burst } </ Property . Value >
329+ </ Property . Item >
330+ </ >
331+ ) : (
332+ < Property . Item >
333+ < Property . Value >
334+ Invalid interval on the stored config.
335+ </ Property . Value >
336+ </ Property . Item >
337+ )
338+ ) : (
339+ < >
340+ < Property . Item >
341+ < Property . Label > Type</ Property . Label >
342+ < Property . Value > { effective . config . type } </ Property . Value >
343+ </ Property . Item >
344+ < Property . Item >
345+ < Property . Label > Window</ Property . Label >
346+ < Property . Value > { String ( effective . config . window ) } </ Property . Value >
347+ </ Property . Item >
348+ < Property . Item >
349+ < Property . Label > Tokens</ Property . Label >
350+ < Property . Value >
351+ { effective . config . tokens . toLocaleString ( ) }
352+ </ Property . Value >
353+ </ Property . Item >
354+ </ >
355+ ) }
356+ </ Property . Table >
357+ { effective . config . type !== "tokenBucket" && (
358+ < Paragraph variant = "small" className = "text-amber-500" >
359+ This override is a { effective . config . type } limit and can't be
360+ edited from this form. Change it in the database directly.
361+ </ Paragraph >
352362 ) }
353- </ Property . Table >
363+ </ >
354364 ) : (
355365 < Form method = "post" className = "flex flex-col gap-3 pt-2" >
356366 < input type = "hidden" name = "intent" value = "set-rate-limit" />
0 commit comments