Skip to content

Commit a3c82e4

Browse files
committed
refactor(webapp): split admin back-office org page into per-section components
1 parent f173659 commit a3c82e4

3 files changed

Lines changed: 467 additions & 353 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { z } from "zod";
2+
import { prisma } from "~/db.server";
3+
import { env } from "~/env.server";
4+
import {
5+
RateLimitTokenBucketConfig,
6+
RateLimiterConfig,
7+
} from "~/services/authorizationRateLimitMiddleware.server";
8+
import { logger } from "~/services/logger.server";
9+
import { type Duration } from "~/services/rateLimiter.server";
10+
import {
11+
parseDurationToMs,
12+
RATE_LIMIT_INTENT,
13+
type EffectiveRateLimit,
14+
} from "./RateLimitSection";
15+
16+
function systemDefaultRateLimit() {
17+
return {
18+
type: "tokenBucket" as const,
19+
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
20+
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
21+
maxTokens: env.API_RATE_LIMIT_MAX,
22+
};
23+
}
24+
25+
export function resolveEffectiveRateLimit(
26+
override: unknown
27+
): EffectiveRateLimit {
28+
if (override == null) {
29+
return { source: "default", config: systemDefaultRateLimit() };
30+
}
31+
const parsed = RateLimiterConfig.safeParse(override);
32+
if (parsed.success) {
33+
return { source: "override", config: parsed.data };
34+
}
35+
// Column holds malformed JSON — fall back silently. Admin must investigate
36+
// at the DB level; this UI can't recover it.
37+
return { source: "default", config: systemDefaultRateLimit() };
38+
}
39+
40+
const SetRateLimitSchema = z.object({
41+
intent: z.literal(RATE_LIMIT_INTENT),
42+
refillRate: z.coerce.number().int().min(1),
43+
interval: z
44+
.string()
45+
.trim()
46+
.refine((v) => parseDurationToMs(v) > 0, {
47+
message: "Must be a duration like 10s, 1m, 500ms.",
48+
}),
49+
maxTokens: z.coerce.number().int().min(1),
50+
});
51+
52+
export type RateLimitActionResult =
53+
| { ok: true }
54+
| { ok: false; errors: Record<string, string[] | undefined> };
55+
56+
export async function handleRateLimitAction(
57+
formData: FormData,
58+
orgId: string,
59+
adminUserId: string
60+
): Promise<RateLimitActionResult> {
61+
const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData));
62+
if (!submission.success) {
63+
return { ok: false, errors: submission.error.flatten().fieldErrors };
64+
}
65+
66+
const existing = await prisma.organization.findFirst({
67+
where: { id: orgId },
68+
select: { apiRateLimiterConfig: true },
69+
});
70+
if (!existing) {
71+
throw new Response(null, { status: 404 });
72+
}
73+
74+
const built = RateLimitTokenBucketConfig.safeParse({
75+
type: "tokenBucket",
76+
refillRate: submission.data.refillRate,
77+
interval: submission.data.interval,
78+
maxTokens: submission.data.maxTokens,
79+
});
80+
if (!built.success) {
81+
return { ok: false, errors: built.error.flatten().fieldErrors };
82+
}
83+
84+
await prisma.organization.update({
85+
where: { id: orgId },
86+
data: { apiRateLimiterConfig: built.data as any },
87+
});
88+
89+
logger.info("admin.backOffice.rateLimit", {
90+
adminUserId,
91+
orgId,
92+
previous: existing.apiRateLimiterConfig,
93+
next: built.data,
94+
});
95+
96+
return { ok: true };
97+
}
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { Form } from "@remix-run/react";
2+
import { useEffect, useState } from "react";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import { FormError } from "~/components/primitives/FormError";
5+
import { Header2 } from "~/components/primitives/Headers";
6+
import { Input } from "~/components/primitives/Input";
7+
import { Label } from "~/components/primitives/Label";
8+
import { Paragraph } from "~/components/primitives/Paragraph";
9+
import * as Property from "~/components/primitives/PropertyTable";
10+
11+
export const RATE_LIMIT_INTENT = "set-rate-limit";
12+
export const RATE_LIMIT_SAVED_VALUE = "rate-limit";
13+
14+
// Local shape mirrors the server-side discriminated union just enough for this
15+
// view. Decoupled from the .server module so the component stays client-safe.
16+
export type RateLimitConfig =
17+
| {
18+
type: "tokenBucket";
19+
refillRate: number;
20+
interval: string | number;
21+
maxTokens: number;
22+
}
23+
| {
24+
type: "fixedWindow" | "slidingWindow";
25+
window: string | number;
26+
tokens: number;
27+
};
28+
29+
export type EffectiveRateLimit = {
30+
source: "override" | "default";
31+
config: RateLimitConfig;
32+
};
33+
34+
type FieldErrors = Record<string, string[] | undefined> | null;
35+
36+
type Props = {
37+
effective: EffectiveRateLimit;
38+
errors: FieldErrors;
39+
savedJustNow: boolean;
40+
isSubmitting: boolean;
41+
};
42+
43+
export function RateLimitSection({
44+
effective,
45+
errors,
46+
savedJustNow,
47+
isSubmitting,
48+
}: Props) {
49+
const hasFieldErrors = !!errors && Object.keys(errors).length > 0;
50+
const fieldError = (field: string) =>
51+
errors && field in errors ? errors[field]?.[0] : undefined;
52+
53+
const current =
54+
effective.config.type === "tokenBucket" ? effective.config : null;
55+
56+
const [isEditing, setIsEditing] = useState(false);
57+
const [refillRate, setRefillRate] = useState(
58+
current ? String(current.refillRate) : ""
59+
);
60+
const [intervalStr, setIntervalStr] = useState(
61+
current ? String(current.interval) : ""
62+
);
63+
const [maxTokens, setMaxTokens] = useState(
64+
current ? String(current.maxTokens) : ""
65+
);
66+
67+
useEffect(() => {
68+
if (hasFieldErrors) setIsEditing(true);
69+
}, [hasFieldErrors]);
70+
71+
useEffect(() => {
72+
if (savedJustNow) setIsEditing(false);
73+
}, [savedJustNow]);
74+
75+
const currentDescription = current
76+
? describeRateLimit(
77+
current.refillRate,
78+
parseDurationToMs(String(current.interval)),
79+
current.maxTokens
80+
)
81+
: null;
82+
83+
const previewDescription = describeRateLimit(
84+
Number(refillRate) || 0,
85+
parseDurationToMs(intervalStr),
86+
Number(maxTokens) || 0
87+
);
88+
89+
const cancelEdit = () => {
90+
setRefillRate(current ? String(current.refillRate) : "");
91+
setIntervalStr(current ? String(current.interval) : "");
92+
setMaxTokens(current ? String(current.maxTokens) : "");
93+
setIsEditing(false);
94+
};
95+
96+
return (
97+
<section className="flex flex-col gap-3 rounded-md border border-charcoal-700 bg-charcoal-800 p-4">
98+
<div className="flex items-center justify-between">
99+
<Header2>API rate limit</Header2>
100+
{!isEditing && (
101+
<Button
102+
variant="tertiary/small"
103+
onClick={() => setIsEditing(true)}
104+
disabled={isSubmitting || effective.config.type !== "tokenBucket"}
105+
>
106+
Edit
107+
</Button>
108+
)}
109+
</div>
110+
111+
{savedJustNow && (
112+
<div className="rounded-md border border-green-600/40 bg-green-600/10 px-3 py-2">
113+
<Paragraph variant="small" className="text-green-500">
114+
Saved.
115+
</Paragraph>
116+
</div>
117+
)}
118+
119+
<Paragraph variant="small">
120+
Status:{" "}
121+
{effective.source === "override"
122+
? "Custom override active."
123+
: "Using system default."}
124+
</Paragraph>
125+
126+
{!isEditing ? (
127+
<>
128+
<Property.Table>
129+
{effective.config.type === "tokenBucket" ? (
130+
currentDescription ? (
131+
<>
132+
<Property.Item>
133+
<Property.Label>Sustained rate</Property.Label>
134+
<Property.Value>
135+
{currentDescription.sustained}
136+
</Property.Value>
137+
</Property.Item>
138+
<Property.Item>
139+
<Property.Label>Burst allowance</Property.Label>
140+
<Property.Value>{currentDescription.burst}</Property.Value>
141+
</Property.Item>
142+
</>
143+
) : (
144+
<Property.Item>
145+
<Property.Value>
146+
Invalid interval on the stored config.
147+
</Property.Value>
148+
</Property.Item>
149+
)
150+
) : (
151+
<>
152+
<Property.Item>
153+
<Property.Label>Type</Property.Label>
154+
<Property.Value>{effective.config.type}</Property.Value>
155+
</Property.Item>
156+
<Property.Item>
157+
<Property.Label>Window</Property.Label>
158+
<Property.Value>{String(effective.config.window)}</Property.Value>
159+
</Property.Item>
160+
<Property.Item>
161+
<Property.Label>Tokens</Property.Label>
162+
<Property.Value>
163+
{effective.config.tokens.toLocaleString()}
164+
</Property.Value>
165+
</Property.Item>
166+
</>
167+
)}
168+
</Property.Table>
169+
{effective.config.type !== "tokenBucket" && (
170+
<Paragraph variant="small" className="text-amber-500">
171+
This override is a {effective.config.type} limit and can't be
172+
edited from this form. Change it in the database directly.
173+
</Paragraph>
174+
)}
175+
</>
176+
) : (
177+
<Form method="post" className="flex flex-col gap-3 pt-2">
178+
<input type="hidden" name="intent" value={RATE_LIMIT_INTENT} />
179+
180+
<div className="flex flex-col gap-1">
181+
<Label>Refill rate (tokens per interval)</Label>
182+
<Input
183+
name="refillRate"
184+
type="number"
185+
min={1}
186+
value={refillRate}
187+
onChange={(e) => setRefillRate(e.target.value)}
188+
required
189+
/>
190+
<FormError>{fieldError("refillRate")}</FormError>
191+
</div>
192+
193+
<div className="flex flex-col gap-1">
194+
<Label>Interval (e.g. 10s, 1m)</Label>
195+
<Input
196+
name="interval"
197+
type="text"
198+
value={intervalStr}
199+
onChange={(e) => setIntervalStr(e.target.value)}
200+
required
201+
/>
202+
<FormError>{fieldError("interval")}</FormError>
203+
</div>
204+
205+
<div className="flex flex-col gap-1">
206+
<Label>Max tokens (burst allowance)</Label>
207+
<Input
208+
name="maxTokens"
209+
type="number"
210+
min={1}
211+
value={maxTokens}
212+
onChange={(e) => setMaxTokens(e.target.value)}
213+
required
214+
/>
215+
<FormError>{fieldError("maxTokens")}</FormError>
216+
</div>
217+
218+
<Paragraph variant="small" className="text-text-dimmed">
219+
{previewDescription
220+
? `Preview: ${previewDescription.sustained} · ${previewDescription.burst}.`
221+
: "Preview: enter valid values to see the effective limit."}
222+
</Paragraph>
223+
224+
<div className="flex items-center gap-2">
225+
<Button
226+
type="submit"
227+
variant="primary/medium"
228+
disabled={
229+
isSubmitting ||
230+
!refillRate.trim() ||
231+
!intervalStr.trim() ||
232+
!maxTokens.trim()
233+
}
234+
>
235+
Save
236+
</Button>
237+
<Button
238+
type="button"
239+
variant="tertiary/medium"
240+
onClick={cancelEdit}
241+
disabled={isSubmitting}
242+
>
243+
Cancel
244+
</Button>
245+
</div>
246+
</Form>
247+
)}
248+
</section>
249+
);
250+
}
251+
252+
export function parseDurationToMs(duration: string): number {
253+
const match = duration.trim().match(/^(\d+)\s*(ms|s|m|h|d)$/);
254+
if (!match) return 0;
255+
const value = parseInt(match[1], 10);
256+
switch (match[2]) {
257+
case "ms":
258+
return value;
259+
case "s":
260+
return value * 1_000;
261+
case "m":
262+
return value * 60_000;
263+
case "h":
264+
return value * 3_600_000;
265+
case "d":
266+
return value * 86_400_000;
267+
default:
268+
return 0;
269+
}
270+
}
271+
272+
function describeRateLimit(
273+
refillRate: number,
274+
intervalMs: number,
275+
maxTokens: number
276+
): { sustained: string; burst: string } | null {
277+
if (refillRate <= 0 || intervalMs <= 0 || maxTokens <= 0) return null;
278+
const perMin = (refillRate * 60_000) / intervalMs;
279+
let sustained: string;
280+
if (perMin >= 1) {
281+
sustained = `${Math.round(perMin).toLocaleString()} requests per minute`;
282+
} else {
283+
const perHour = perMin * 60;
284+
if (perHour >= 1) {
285+
sustained = `${Math.round(perHour).toLocaleString()} requests per hour`;
286+
} else {
287+
const perDay = perHour * 24;
288+
const formatted =
289+
perDay >= 10 ? Math.round(perDay).toLocaleString() : perDay.toFixed(1);
290+
sustained = `${formatted} requests per day`;
291+
}
292+
}
293+
return {
294+
sustained,
295+
burst: `${maxTokens.toLocaleString()} request burst allowance`,
296+
};
297+
}

0 commit comments

Comments
 (0)