From a3c82e49f5471b936983a09daa3f72265b70b0ab Mon Sep 17 00:00:00 2001 From: isshaddad Date: Wed, 29 Apr 2026 16:05:54 -0400 Subject: [PATCH 1/7] refactor(webapp): split admin back-office org page into per-section components --- .../backOffice/RateLimitSection.server.ts | 97 ++++ .../admin/backOffice/RateLimitSection.tsx | 297 ++++++++++++ .../routes/admin.back-office.orgs.$orgId.tsx | 426 +++--------------- 3 files changed, 467 insertions(+), 353 deletions(-) create mode 100644 apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts create mode 100644 apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx diff --git a/apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts b/apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts new file mode 100644 index 0000000000..05ffc571f7 --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { + RateLimitTokenBucketConfig, + RateLimiterConfig, +} from "~/services/authorizationRateLimitMiddleware.server"; +import { logger } from "~/services/logger.server"; +import { type Duration } from "~/services/rateLimiter.server"; +import { + parseDurationToMs, + RATE_LIMIT_INTENT, + type EffectiveRateLimit, +} from "./RateLimitSection"; + +function systemDefaultRateLimit() { + return { + type: "tokenBucket" as const, + refillRate: env.API_RATE_LIMIT_REFILL_RATE, + interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration, + maxTokens: env.API_RATE_LIMIT_MAX, + }; +} + +export function resolveEffectiveRateLimit( + override: unknown +): EffectiveRateLimit { + if (override == null) { + return { source: "default", config: systemDefaultRateLimit() }; + } + const parsed = RateLimiterConfig.safeParse(override); + if (parsed.success) { + return { source: "override", config: parsed.data }; + } + // Column holds malformed JSON — fall back silently. Admin must investigate + // at the DB level; this UI can't recover it. + return { source: "default", config: systemDefaultRateLimit() }; +} + +const SetRateLimitSchema = z.object({ + intent: z.literal(RATE_LIMIT_INTENT), + refillRate: z.coerce.number().int().min(1), + interval: z + .string() + .trim() + .refine((v) => parseDurationToMs(v) > 0, { + message: "Must be a duration like 10s, 1m, 500ms.", + }), + maxTokens: z.coerce.number().int().min(1), +}); + +export type RateLimitActionResult = + | { ok: true } + | { ok: false; errors: Record }; + +export async function handleRateLimitAction( + formData: FormData, + orgId: string, + adminUserId: string +): Promise { + const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData)); + if (!submission.success) { + return { ok: false, errors: submission.error.flatten().fieldErrors }; + } + + const existing = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { apiRateLimiterConfig: true }, + }); + if (!existing) { + throw new Response(null, { status: 404 }); + } + + const built = RateLimitTokenBucketConfig.safeParse({ + type: "tokenBucket", + refillRate: submission.data.refillRate, + interval: submission.data.interval, + maxTokens: submission.data.maxTokens, + }); + if (!built.success) { + return { ok: false, errors: built.error.flatten().fieldErrors }; + } + + await prisma.organization.update({ + where: { id: orgId }, + data: { apiRateLimiterConfig: built.data as any }, + }); + + logger.info("admin.backOffice.rateLimit", { + adminUserId, + orgId, + previous: existing.apiRateLimiterConfig, + next: built.data, + }); + + return { ok: true }; +} diff --git a/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx new file mode 100644 index 0000000000..52c8bda3f9 --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx @@ -0,0 +1,297 @@ +import { Form } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { FormError } from "~/components/primitives/FormError"; +import { 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"; + +export const RATE_LIMIT_INTENT = "set-rate-limit"; +export const RATE_LIMIT_SAVED_VALUE = "rate-limit"; + +// Local shape mirrors the server-side discriminated union just enough for this +// view. Decoupled from the .server module so the component stays client-safe. +export type RateLimitConfig = + | { + type: "tokenBucket"; + refillRate: number; + interval: string | number; + maxTokens: number; + } + | { + type: "fixedWindow" | "slidingWindow"; + window: string | number; + tokens: number; + }; + +export type EffectiveRateLimit = { + source: "override" | "default"; + config: RateLimitConfig; +}; + +type FieldErrors = Record | null; + +type Props = { + effective: EffectiveRateLimit; + errors: FieldErrors; + savedJustNow: boolean; + isSubmitting: boolean; +}; + +export function RateLimitSection({ + effective, + errors, + savedJustNow, + isSubmitting, +}: Props) { + const hasFieldErrors = !!errors && Object.keys(errors).length > 0; + const fieldError = (field: string) => + errors && field in errors ? errors[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) : "" + ); + + useEffect(() => { + if (hasFieldErrors) setIsEditing(true); + }, [hasFieldErrors]); + + useEffect(() => { + if (savedJustNow) setIsEditing(false); + }, [savedJustNow]); + + 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 ( +
+
+ API rate limit + {!isEditing && ( + + )} +
+ + {savedJustNow && ( +
+ + Saved. + +
+ )} + + + Status:{" "} + {effective.source === "override" + ? "Custom override active." + : "Using system default."} + + + {!isEditing ? ( + <> + + {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. + + )} + + ) : ( +
+ + +
+ + 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."} + + +
+ + +
+
+ )} +
+ ); +} + +export 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 = (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, + burst: `${maxTokens.toLocaleString()} request burst allowance`, + }; +} 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 211a5a4fd2..b1643bcbd4 100644 --- a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx +++ b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx @@ -1,102 +1,35 @@ -import { Form, useNavigation, useSearchParams } from "@remix-run/react"; +import { useNavigation, useSearchParams } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; -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 { useEffect } from "react"; +import { + redirect, + typedjson, + useTypedActionData, + useTypedLoaderData, +} from "remix-typedjson"; +import { + MAX_PROJECTS_INTENT, + MAX_PROJECTS_SAVED_VALUE, + MaxProjectsSection, +} from "~/components/admin/backOffice/MaxProjectsSection"; +import { handleMaxProjectsAction } from "~/components/admin/backOffice/MaxProjectsSection.server"; +import { + RATE_LIMIT_INTENT, + RATE_LIMIT_SAVED_VALUE, + RateLimitSection, +} from "~/components/admin/backOffice/RateLimitSection"; +import { + handleRateLimitAction, + resolveEffectiveRateLimit, +} from "~/components/admin/backOffice/RateLimitSection.server"; +import { 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 { Header1 } from "~/components/primitives/Headers"; 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, - RateLimiterConfig, -} from "~/services/authorizationRateLimitMiddleware.server"; -import { logger } from "~/services/logger.server"; -import { type Duration } from "~/services/rateLimiter.server"; import { requireUser } from "~/services/session.server"; const SAVED_QUERY_KEY = "saved"; -const SAVED_QUERY_VALUE = "1"; - -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 = RateLimiterConfig.safeParse(override); - if (parsed.success) { - return { source: "override", config: parsed.data }; - } - // Column holds malformed JSON — fall back silently. Admin must investigate - // at the DB level; this UI can't recover it. - 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 = (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, - burst: `${maxTokens.toLocaleString()} request burst allowance`, - }; -} export async function loader({ request, params }: LoaderFunctionArgs) { const user = await requireUser(request); @@ -117,6 +50,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { title: true, createdAt: true, apiRateLimiterConfig: true, + maximumProjectCount: true, }, }); @@ -126,24 +60,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const effective = resolveEffectiveRateLimit(org.apiRateLimiterConfig); - return typedjson({ - org, - effective, - }); + return typedjson({ org, effective }); } -const SetRateLimitSchema = z.object({ - intent: z.literal("set-rate-limit"), - refillRate: z.coerce.number().int().min(1), - interval: z - .string() - .trim() - .refine((v) => parseDurationToMs(v) > 0, { - message: "Must be a duration like 10s, 1m, 500ms.", - }), - maxTokens: z.coerce.number().int().min(1), -}); - export async function action({ request, params }: ActionFunctionArgs) { const user = await requireUser(request); if (!user.admin) { @@ -156,50 +75,37 @@ export async function action({ request, params }: ActionFunctionArgs) { } const formData = await request.formData(); - const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData)); - if (!submission.success) { - return typedjson( - { errors: submission.error.flatten().fieldErrors }, - { status: 400 } + const intent = formData.get("intent"); + + if (intent === MAX_PROJECTS_INTENT) { + const result = await handleMaxProjectsAction(formData, orgId, user.id); + if (!result.ok) { + return typedjson( + { section: MAX_PROJECTS_SAVED_VALUE, errors: result.errors }, + { status: 400 } + ); + } + return redirect( + `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${MAX_PROJECTS_SAVED_VALUE}` ); } - const existing = await prisma.organization.findFirst({ - where: { id: orgId }, - select: { apiRateLimiterConfig: true }, - }); - if (!existing) { - throw new Response(null, { status: 404 }); - } - - const built = RateLimitTokenBucketConfig.safeParse({ - type: "tokenBucket", - refillRate: submission.data.refillRate, - interval: submission.data.interval, - maxTokens: submission.data.maxTokens, - }); - if (!built.success) { - return typedjson( - { errors: built.error.flatten().fieldErrors }, - { status: 400 } + if (intent === RATE_LIMIT_INTENT) { + const result = await handleRateLimitAction(formData, orgId, user.id); + if (!result.ok) { + return typedjson( + { section: RATE_LIMIT_SAVED_VALUE, errors: result.errors }, + { status: 400 } + ); + } + return redirect( + `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${RATE_LIMIT_SAVED_VALUE}` ); } - const next = built.data; - - await prisma.organization.update({ - where: { id: orgId }, - data: { apiRateLimiterConfig: next as any }, - }); - - logger.info("admin.backOffice.rateLimit", { - adminUserId: user.id, - orgId, - previous: existing.apiRateLimiterConfig, - next, - }); - return redirect( - `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${SAVED_QUERY_VALUE}` + return typedjson( + { section: null, errors: { intent: ["Unknown intent."] } }, + { status: 400 } ); } @@ -209,47 +115,19 @@ export default function BackOfficeOrgPage() { const navigation = useNavigation(); const isSubmitting = navigation.state !== "idle"; + const errorSection = + actionData && "section" in actionData ? actionData.section : null; 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) : "" - ); + actionData && "errors" in actionData + ? (actionData.errors as Record) + : null; const [searchParams, setSearchParams] = useSearchParams(); - const savedJustNow = searchParams.get(SAVED_QUERY_KEY) === SAVED_QUERY_VALUE; - - // 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]); + const savedSection = searchParams.get(SAVED_QUERY_KEY); // Auto-dismiss the "saved" banner after a few seconds. useEffect(() => { - if (!savedJustNow) return; + if (!savedSection) return; const t = setTimeout(() => { setSearchParams( (prev) => { @@ -260,28 +138,7 @@ export default function BackOfficeOrgPage() { ); }, 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); - }; + }, [savedSection, setSearchParams]); return (
@@ -297,156 +154,19 @@ export default function BackOfficeOrgPage() {
-
-
- API rate limit - {!isEditing && ( - - )} -
- - {savedJustNow && ( -
- - Rate limit saved. - -
- )} - - - Status:{" "} - {effective.source === "override" - ? "Custom override active." - : "Using system default."} - - - {!isEditing ? ( - <> - - {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. - - )} - - ) : ( -
- - -
- - 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."} - - -
- - -
-
- )} -
+ + + ); } From 90cf9d029f71ed2779a0241ebfe92b8ae28b4925 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Wed, 29 Apr 2026 16:06:15 -0400 Subject: [PATCH 2/7] feat(webapp): admin editor for org maximum project count --- .../admin-back-office-max-projects.md | 6 + .../backOffice/MaxProjectsSection.server.ts | 46 +++++++ .../admin/backOffice/MaxProjectsSection.tsx | 115 ++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 .server-changes/admin-back-office-max-projects.md create mode 100644 apps/webapp/app/components/admin/backOffice/MaxProjectsSection.server.ts create mode 100644 apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx diff --git a/.server-changes/admin-back-office-max-projects.md b/.server-changes/admin-back-office-max-projects.md new file mode 100644 index 0000000000..698fdfe12f --- /dev/null +++ b/.server-changes/admin-back-office-max-projects.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Admin back office: edit an organization's `maximumProjectCount` from the org page, beneath the API rate limit editor. diff --git a/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.server.ts b/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.server.ts new file mode 100644 index 0000000000..a2a113f7ee --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.server.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { MAX_PROJECTS_INTENT } from "./MaxProjectsSection"; + +const SetMaxProjectsSchema = z.object({ + intent: z.literal(MAX_PROJECTS_INTENT), + maximumProjectCount: z.coerce.number().int().min(1), +}); + +export type MaxProjectsActionResult = + | { ok: true } + | { ok: false; errors: Record }; + +export async function handleMaxProjectsAction( + formData: FormData, + orgId: string, + adminUserId: string +): Promise { + const submission = SetMaxProjectsSchema.safeParse(Object.fromEntries(formData)); + if (!submission.success) { + return { ok: false, errors: submission.error.flatten().fieldErrors }; + } + + const existing = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { maximumProjectCount: true }, + }); + if (!existing) { + throw new Response(null, { status: 404 }); + } + + await prisma.organization.update({ + where: { id: orgId }, + data: { maximumProjectCount: submission.data.maximumProjectCount }, + }); + + logger.info("admin.backOffice.maxProjects", { + adminUserId, + orgId, + previous: existing.maximumProjectCount, + next: submission.data.maximumProjectCount, + }); + + return { ok: true }; +} diff --git a/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx b/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx new file mode 100644 index 0000000000..9601b648df --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx @@ -0,0 +1,115 @@ +import { Form } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { FormError } from "~/components/primitives/FormError"; +import { 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"; + +export const MAX_PROJECTS_INTENT = "set-max-projects"; +export const MAX_PROJECTS_SAVED_VALUE = "max-projects"; + +type FieldErrors = Record | null; + +type Props = { + maximumProjectCount: number; + errors: FieldErrors; + savedJustNow: boolean; + isSubmitting: boolean; +}; + +export function MaxProjectsSection({ + maximumProjectCount, + errors, + savedJustNow, + isSubmitting, +}: Props) { + const hasFieldErrors = !!errors && Object.keys(errors).length > 0; + const fieldError = (field: string) => + errors && field in errors ? errors[field]?.[0] : undefined; + + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(String(maximumProjectCount)); + + useEffect(() => { + if (hasFieldErrors) setIsEditing(true); + }, [hasFieldErrors]); + + useEffect(() => { + if (savedJustNow) setIsEditing(false); + }, [savedJustNow]); + + return ( +
+
+ Maximum projects + {!isEditing && ( + + )} +
+ + {savedJustNow && ( +
+ + Saved. + +
+ )} + + {!isEditing ? ( + + + Limit + + {maximumProjectCount.toLocaleString()} + + + + ) : ( +
+ +
+ + setValue(e.target.value)} + required + /> + {fieldError("maximumProjectCount")} +
+
+ + +
+
+ )} +
+ ); +} From 3cc72481a4a5e10c29c2ebb5d6fc739637fd7fc3 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Wed, 29 Apr 2026 16:37:38 -0400 Subject: [PATCH 3/7] refactor(webapp): generalize admin rate-limit section for reuse --- .../backOffice/ApiRateLimitSection.server.ts | 55 +++++++++++++ .../admin/backOffice/ApiRateLimitSection.tsx | 26 ++++++ .../backOffice/RateLimitSection.server.ts | 79 +++++++------------ .../admin/backOffice/RateLimitSection.tsx | 11 +-- 4 files changed, 116 insertions(+), 55 deletions(-) create mode 100644 apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts create mode 100644 apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.tsx diff --git a/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts b/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts new file mode 100644 index 0000000000..4855c4c246 --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts @@ -0,0 +1,55 @@ +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { type Duration } from "~/services/rateLimiter.server"; +import { API_RATE_LIMIT_INTENT } from "./ApiRateLimitSection"; +import { + handleRateLimitAction, + resolveEffectiveRateLimit, + type RateLimitActionResult, + type RateLimitDomain, +} from "./RateLimitSection.server"; +import type { EffectiveRateLimit } from "./RateLimitSection"; + +export const apiRateLimitDomain: RateLimitDomain = { + intent: API_RATE_LIMIT_INTENT, + systemDefault: () => ({ + type: "tokenBucket", + refillRate: env.API_RATE_LIMIT_REFILL_RATE, + interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration, + maxTokens: env.API_RATE_LIMIT_MAX, + }), + apply: async (orgId, next, adminUserId) => { + const existing = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { apiRateLimiterConfig: true }, + }); + if (!existing) { + throw new Response(null, { status: 404 }); + } + await prisma.organization.update({ + where: { id: orgId }, + data: { apiRateLimiterConfig: next as any }, + }); + logger.info("admin.backOffice.apiRateLimit", { + adminUserId, + orgId, + previous: existing.apiRateLimiterConfig, + next, + }); + }, +}; + +export function resolveEffectiveApiRateLimit( + override: unknown +): EffectiveRateLimit { + return resolveEffectiveRateLimit(override, apiRateLimitDomain); +} + +export function handleApiRateLimitAction( + formData: FormData, + orgId: string, + adminUserId: string +): Promise { + return handleRateLimitAction(formData, orgId, adminUserId, apiRateLimitDomain); +} diff --git a/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.tsx b/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.tsx new file mode 100644 index 0000000000..18ece8a733 --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.tsx @@ -0,0 +1,26 @@ +import { + RateLimitSection, + type EffectiveRateLimit, +} from "./RateLimitSection"; + +export const API_RATE_LIMIT_INTENT = "set-rate-limit"; +export const API_RATE_LIMIT_SAVED_VALUE = "rate-limit"; + +type FieldErrors = Record | null; + +type Props = { + effective: EffectiveRateLimit; + errors: FieldErrors; + savedJustNow: boolean; + isSubmitting: boolean; +}; + +export function ApiRateLimitSection(props: Props) { + return ( + + ); +} diff --git a/apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts b/apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts index 05ffc571f7..799fc3605d 100644 --- a/apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts +++ b/apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts @@ -1,32 +1,29 @@ import { z } from "zod"; -import { prisma } from "~/db.server"; -import { env } from "~/env.server"; import { RateLimitTokenBucketConfig, RateLimiterConfig, } from "~/services/authorizationRateLimitMiddleware.server"; -import { logger } from "~/services/logger.server"; -import { type Duration } from "~/services/rateLimiter.server"; import { parseDurationToMs, - RATE_LIMIT_INTENT, type EffectiveRateLimit, } from "./RateLimitSection"; -function systemDefaultRateLimit() { - return { - type: "tokenBucket" as const, - refillRate: env.API_RATE_LIMIT_REFILL_RATE, - interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration, - maxTokens: env.API_RATE_LIMIT_MAX, - }; -} +export type RateLimitDomain = { + intent: string; + systemDefault: () => RateLimiterConfig; + apply: ( + orgId: string, + next: RateLimitTokenBucketConfig, + adminUserId: string + ) => Promise; +}; export function resolveEffectiveRateLimit( - override: unknown + override: unknown, + domain: RateLimitDomain ): EffectiveRateLimit { if (override == null) { - return { source: "default", config: systemDefaultRateLimit() }; + return { source: "default", config: domain.systemDefault() }; } const parsed = RateLimiterConfig.safeParse(override); if (parsed.success) { @@ -34,21 +31,9 @@ export function resolveEffectiveRateLimit( } // Column holds malformed JSON — fall back silently. Admin must investigate // at the DB level; this UI can't recover it. - return { source: "default", config: systemDefaultRateLimit() }; + return { source: "default", config: domain.systemDefault() }; } -const SetRateLimitSchema = z.object({ - intent: z.literal(RATE_LIMIT_INTENT), - refillRate: z.coerce.number().int().min(1), - interval: z - .string() - .trim() - .refine((v) => parseDurationToMs(v) > 0, { - message: "Must be a duration like 10s, 1m, 500ms.", - }), - maxTokens: z.coerce.number().int().min(1), -}); - export type RateLimitActionResult = | { ok: true } | { ok: false; errors: Record }; @@ -56,21 +41,26 @@ export type RateLimitActionResult = export async function handleRateLimitAction( formData: FormData, orgId: string, - adminUserId: string + adminUserId: string, + domain: RateLimitDomain ): Promise { - const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData)); + const schema = z.object({ + intent: z.literal(domain.intent), + refillRate: z.coerce.number().int().min(1), + interval: z + .string() + .trim() + .refine((v) => parseDurationToMs(v) > 0, { + message: "Must be a duration like 10s, 1m, 500ms.", + }), + maxTokens: z.coerce.number().int().min(1), + }); + + const submission = schema.safeParse(Object.fromEntries(formData)); if (!submission.success) { return { ok: false, errors: submission.error.flatten().fieldErrors }; } - const existing = await prisma.organization.findFirst({ - where: { id: orgId }, - select: { apiRateLimiterConfig: true }, - }); - if (!existing) { - throw new Response(null, { status: 404 }); - } - const built = RateLimitTokenBucketConfig.safeParse({ type: "tokenBucket", refillRate: submission.data.refillRate, @@ -81,17 +71,6 @@ export async function handleRateLimitAction( return { ok: false, errors: built.error.flatten().fieldErrors }; } - await prisma.organization.update({ - where: { id: orgId }, - data: { apiRateLimiterConfig: built.data as any }, - }); - - logger.info("admin.backOffice.rateLimit", { - adminUserId, - orgId, - previous: existing.apiRateLimiterConfig, - next: built.data, - }); - + await domain.apply(orgId, built.data, adminUserId); return { ok: true }; } diff --git a/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx index 52c8bda3f9..ec7973d0f7 100644 --- a/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx +++ b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx @@ -8,9 +8,6 @@ import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; -export const RATE_LIMIT_INTENT = "set-rate-limit"; -export const RATE_LIMIT_SAVED_VALUE = "rate-limit"; - // Local shape mirrors the server-side discriminated union just enough for this // view. Decoupled from the .server module so the component stays client-safe. export type RateLimitConfig = @@ -34,6 +31,8 @@ export type EffectiveRateLimit = { type FieldErrors = Record | null; type Props = { + title: string; + intent: string; effective: EffectiveRateLimit; errors: FieldErrors; savedJustNow: boolean; @@ -41,6 +40,8 @@ type Props = { }; export function RateLimitSection({ + title, + intent, effective, errors, savedJustNow, @@ -96,7 +97,7 @@ export function RateLimitSection({ return (
- API rate limit + {title} {!isEditing && (
); From a325bd5bfadad09f276318be1d5c3d8e4a60cf1a Mon Sep 17 00:00:00 2001 From: isshaddad Date: Wed, 29 Apr 2026 17:29:44 -0400 Subject: [PATCH 6/7] more coderabbit fixes --- .../components/admin/backOffice/RateLimitSection.tsx | 12 +++++++----- .../app/routes/admin.back-office.orgs.$orgId.tsx | 7 ++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx index c17658976f..6edd300879 100644 --- a/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx +++ b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx @@ -283,16 +283,14 @@ function describeRateLimit( const perMin = (refillRate * 60_000) / intervalMs; let sustained: string; if (perMin >= 1) { - sustained = `${Math.round(perMin).toLocaleString()} requests per minute`; + sustained = `${formatRateValue(perMin)} requests per minute`; } else { const perHour = perMin * 60; if (perHour >= 1) { - sustained = `${Math.round(perHour).toLocaleString()} requests per hour`; + sustained = `${formatRateValue(perHour)} requests per hour`; } else { const perDay = perHour * 24; - const formatted = - perDay >= 10 ? Math.round(perDay).toLocaleString() : perDay.toFixed(1); - sustained = `${formatted} requests per day`; + sustained = `${formatRateValue(perDay)} requests per day`; } } return { @@ -300,3 +298,7 @@ function describeRateLimit( burst: `${maxTokens.toLocaleString()} request burst allowance`, }; } + +function formatRateValue(value: number): string { + return value >= 10 ? Math.round(value).toLocaleString() : value.toFixed(1); +} 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 d203a8bdaf..260fef32d9 100644 --- a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx +++ b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx @@ -156,7 +156,12 @@ export default function BackOfficeOrgPage() { : null; const [searchParams, setSearchParams] = useSearchParams(); - const savedSection = searchParams.get(SAVED_QUERY_KEY); + const savedSectionRaw = searchParams.get(SAVED_QUERY_KEY); + // If the action just returned errors for the same section, hide the + // "Saved." banner so it doesn't render alongside field errors. Suppressing + // here propagates to every read site (auto-dismiss + JSX comparisons). + const savedSection = + errors && errorSection === savedSectionRaw ? null : savedSectionRaw; // Auto-dismiss the "saved" banner after a few seconds. useEffect(() => { From 8c01a06ac7a446202fc27b0c1442beb10ca8d55b Mon Sep 17 00:00:00 2001 From: isshaddad Date: Wed, 29 Apr 2026 17:50:12 -0400 Subject: [PATCH 7/7] refactor(webapp): tighten admin rate-limit duration types --- .../app/components/admin/backOffice/RateLimitSection.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx index 6edd300879..1af8abab3d 100644 --- a/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx +++ b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx @@ -10,16 +10,18 @@ import * as Property from "~/components/primitives/PropertyTable"; // Local shape mirrors the server-side discriminated union just enough for this // view. Decoupled from the .server module so the component stays client-safe. +// Duration fields are always suffixed strings — the server's DurationSchema +// rejects anything else, so non-string overrides fall back to the default. export type RateLimitConfig = | { type: "tokenBucket"; refillRate: number; - interval: string | number; + interval: string; maxTokens: number; } | { type: "fixedWindow" | "slidingWindow"; - window: string | number; + window: string; tokens: number; };