feat(webapp): admin Back Office tab with org API rate limit editor (#3434)

## Summary
- New **Back office** tab at `/admin`, per-org detail page at
`/admin/back-office/orgs/:orgId` designed to host future per-org admin
actions (project count, delete account, YC deals).
- First action: edit an organization's API rate limit — tokenBucket
override (refill rate, interval, max tokens), with a live plain-English
preview (e.g. *"1,500 requests per minute · 750 request burst
allowance"*). Writes are audit-logged via the server logger.
- Cleanup: removed unused `v2?` / `v3?` columns from the admin orgs list
(display only — Prisma select untouched).

## Test plan
- [ ] Back office tab visible in admin nav and highlighted when on a
sub-route
- [ ] `/admin/orgs` shows a Back office "Open" link per row; no v2/v3
columns
- [ ] Empty state at `/admin/back-office` links back to `/admin/orgs`
- [ ] Detail page renders the effective rate limit in view mode; Edit
reveals the form
- [ ] Save writes `Organization.apiRateLimiterConfig`, returns to view
mode, shows "Rate limit saved." banner
- [ ] Invalid values surface inline field errors and keep edit mode
- [ ] Non-admins hitting any new route are redirected to `/`
- [ ] Server logs show `admin.backOffice.rateLimit` info line per
mutation
This commit is contained in:
Iss
2026-04-23 12:51:49 -04:00
committed by GitHub
parent cbb1f35ef0
commit 41434b536b
7 changed files with 538 additions and 9 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Add a "Back office" tab to `/admin` and a per-organization detail page at `/admin/back-office/orgs/:orgId`. The first action available on that page is editing the org's API rate limit: admins can save a `tokenBucket` override (refill rate, interval, max tokens) and see a plain-English preview of the resulting sustained rate and burst allowance. Writes are audit-logged via the server logger.
+13 -4
View File
@@ -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 (
<TabContainer className={className} variant={variant}>
{tabs.map((tab, index) => (
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
<TabLink
key={index}
to={tab.to}
layoutId={layoutId}
variant={variant}
end={tab.end ?? true}
>
{tab.label}
</TabLink>
))}
@@ -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 (
<NavLink
to={to}
className="group relative flex h-full grow items-center justify-center focus-custom"
end
end={end}
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
@@ -110,7 +119,7 @@ export function TabLink({
<NavLink
to={to}
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
end
end={end}
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
@@ -131,7 +140,7 @@ export function TabLink({
// underline variant (default)
return (
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end={end}>
{({ isActive, isPending }) => {
return (
<>
@@ -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 (
<div className="flex flex-col items-start gap-3 py-6">
<Header2>Back office</Header2>
<Paragraph variant="base" className="max-w-prose">
Back-office actions are applied to a single organization. Pick an org from the
Organizations tab to open its detail page.
</Paragraph>
<LinkButton to="/admin/orgs" variant="primary/medium">
Pick an organization
</LinkButton>
</div>
);
}
@@ -0,0 +1,452 @@
import { Form, 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 { 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,
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);
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);
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) {
return redirect("/");
}
const orgId = params.orgId;
if (!orgId) {
throw new Response(null, { status: 404 });
}
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 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 }
);
}
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}`
);
}
export default function BackOfficeOrgPage() {
const { org, effective } = useTypedLoaderData<typeof loader>();
const actionData = useTypedActionData<typeof action>();
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<string, string[] | undefined>)[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_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]);
// Auto-dismiss the "saved" banner after a few seconds.
useEffect(() => {
if (!savedJustNow) return;
const t = setTimeout(() => {
setSearchParams(
(prev) => {
prev.delete(SAVED_QUERY_KEY);
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 (
<div className="flex flex-col gap-6 py-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<Header1>{org.title}</Header1>
<Paragraph variant="small" className="text-text-dimmed">
<CopyableText value={org.slug} /> · <CopyableText value={org.id} />
</Paragraph>
</div>
<LinkButton to="/admin/orgs" variant="tertiary/small">
Back to organizations
</LinkButton>
</div>
<section className="flex flex-col gap-3 rounded-md border border-charcoal-700 bg-charcoal-800 p-4">
<div className="flex items-center justify-between">
<Header2>API rate limit</Header2>
{!isEditing && (
<Button
variant="tertiary/small"
onClick={() => setIsEditing(true)}
disabled={isSubmitting || effective.config.type !== "tokenBucket"}
>
Edit
</Button>
)}
</div>
{savedJustNow && (
<div className="rounded-md border border-green-600/40 bg-green-600/10 px-3 py-2">
<Paragraph variant="small" className="text-green-500">
Rate limit saved.
</Paragraph>
</div>
)}
<Paragraph variant="small">
Status:{" "}
{effective.source === "override"
? "Custom override active."
: "Using system default."}
</Paragraph>
{!isEditing ? (
<>
<Property.Table>
{effective.config.type === "tokenBucket" ? (
currentDescription ? (
<>
<Property.Item>
<Property.Label>Sustained rate</Property.Label>
<Property.Value>{currentDescription.sustained}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Burst allowance</Property.Label>
<Property.Value>{currentDescription.burst}</Property.Value>
</Property.Item>
</>
) : (
<Property.Item>
<Property.Value>
Invalid interval on the stored config.
</Property.Value>
</Property.Item>
)
) : (
<>
<Property.Item>
<Property.Label>Type</Property.Label>
<Property.Value>{effective.config.type}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Window</Property.Label>
<Property.Value>{String(effective.config.window)}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Tokens</Property.Label>
<Property.Value>
{effective.config.tokens.toLocaleString()}
</Property.Value>
</Property.Item>
</>
)}
</Property.Table>
{effective.config.type !== "tokenBucket" && (
<Paragraph variant="small" className="text-amber-500">
This override is a {effective.config.type} limit and can't be
edited from this form. Change it in the database directly.
</Paragraph>
)}
</>
) : (
<Form method="post" className="flex flex-col gap-3 pt-2">
<input type="hidden" name="intent" value="set-rate-limit" />
<div className="flex flex-col gap-1">
<Label>Refill rate (tokens per interval)</Label>
<Input
name="refillRate"
type="number"
min={1}
value={refillRate}
onChange={(e) => setRefillRate(e.target.value)}
required
/>
<FormError>{fieldError("refillRate")}</FormError>
</div>
<div className="flex flex-col gap-1">
<Label>Interval (e.g. 10s, 1m)</Label>
<Input
name="interval"
type="text"
value={intervalStr}
onChange={(e) => setIntervalStr(e.target.value)}
required
/>
<FormError>{fieldError("interval")}</FormError>
</div>
<div className="flex flex-col gap-1">
<Label>Max tokens (burst allowance)</Label>
<Input
name="maxTokens"
type="number"
min={1}
value={maxTokens}
onChange={(e) => setMaxTokens(e.target.value)}
required
/>
<FormError>{fieldError("maxTokens")}</FormError>
</div>
<Paragraph variant="small" className="text-text-dimmed">
{previewDescription
? `Preview: ${previewDescription.sustained} · ${previewDescription.burst}.`
: "Preview: enter valid values to see the effective limit."}
</Paragraph>
<div className="flex items-center gap-2">
<Button
type="submit"
variant="primary/medium"
disabled={
isSubmitting ||
!refillRate.trim() ||
!intervalStr.trim() ||
!maxTokens.trim()
}
>
Save
</Button>
<Button
type="button"
variant="tertiary/medium"
onClick={cancelEdit}
disabled={isSubmitting}
>
Cancel
</Button>
</div>
</Form>
)}
</section>
</div>
);
}
@@ -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 (
<main
aria-labelledby="primary-heading"
className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 lg:order-last"
>
<Outlet />
</main>
);
}
+10 -5
View File
@@ -85,15 +85,14 @@ export default function AdminDashboardRoute() {
<TableHeaderCell>Slug</TableHeaderCell>
<TableHeaderCell>Members</TableHeaderCell>
<TableHeaderCell>id</TableHeaderCell>
<TableHeaderCell>v2?</TableHeaderCell>
<TableHeaderCell>v3?</TableHeaderCell>
<TableHeaderCell>Deleted?</TableHeaderCell>
<TableHeaderCell>Back office</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{organizations.length === 0 ? (
<TableBlankRow colSpan={9}>
<TableBlankRow colSpan={7}>
<Paragraph>No orgs found for search</Paragraph>
</TableBlankRow>
) : (
@@ -120,9 +119,15 @@ export default function AdminDashboardRoute() {
<TableCell>
<CopyableText value={org.id} />
</TableCell>
<TableCell>{org.v2Enabled ? "✅" : ""}</TableCell>
<TableCell>{org.v3Enabled ? "✅" : ""}</TableCell>
<TableCell>{org.deletedAt ? "☠️" : ""}</TableCell>
<TableCell>
<LinkButton
to={`/admin/back-office/orgs/${org.id}`}
variant="tertiary/small"
>
Open
</LinkButton>
</TableCell>
<TableCell isSticky={true}>
<div className="flex items-center gap-2">
<Button
+5
View File
@@ -44,6 +44,11 @@ export default function Page() {
label: "Notifications",
to: "/admin/notifications",
},
{
label: "Back office",
to: "/admin/back-office",
end: false,
},
]}
layoutId={"admin"}
/>