Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/admin-back-office-batch-rate-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Admin back office: edit an organization's batch rate limit (`batchRateLimitConfig`) from the org page, alongside the existing API rate limit editor. The rate-limit form UI is now shared between the API and batch sections.
6 changes: 6 additions & 0 deletions .server-changes/admin-back-office-max-projects.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<RateLimitActionResult> {
return handleRateLimitAction(formData, orgId, adminUserId, apiRateLimitDomain);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
RateLimitSection,
type RateLimitWrapperProps,
} from "./RateLimitSection";

export const API_RATE_LIMIT_INTENT = "set-rate-limit";
export const API_RATE_LIMIT_SAVED_VALUE = "rate-limit";

export function ApiRateLimitSection(props: RateLimitWrapperProps) {
return (
<RateLimitSection
title="API rate limit"
intent={API_RATE_LIMIT_INTENT}
{...props}
/>
);
}
Original file line number Diff line number Diff line change
@@ -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 { BATCH_RATE_LIMIT_INTENT } from "./BatchRateLimitSection";
import {
handleRateLimitAction,
resolveEffectiveRateLimit,
type RateLimitActionResult,
type RateLimitDomain,
} from "./RateLimitSection.server";
import type { EffectiveRateLimit } from "./RateLimitSection";

export const batchRateLimitDomain: RateLimitDomain = {
intent: BATCH_RATE_LIMIT_INTENT,
systemDefault: () => ({
type: "tokenBucket",
refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE,
interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration,
maxTokens: env.BATCH_RATE_LIMIT_MAX,
}),
apply: async (orgId, next, adminUserId) => {
const existing = await prisma.organization.findFirst({
where: { id: orgId },
select: { batchRateLimitConfig: true },
});
if (!existing) {
throw new Response(null, { status: 404 });
}
await prisma.organization.update({
where: { id: orgId },
data: { batchRateLimitConfig: next as any },
});
logger.info("admin.backOffice.batchRateLimit", {
adminUserId,
orgId,
previous: existing.batchRateLimitConfig,
next,
});
},
};

export function resolveEffectiveBatchRateLimit(
override: unknown
): EffectiveRateLimit {
return resolveEffectiveRateLimit(override, batchRateLimitDomain);
}

export function handleBatchRateLimitAction(
formData: FormData,
orgId: string,
adminUserId: string
): Promise<RateLimitActionResult> {
return handleRateLimitAction(formData, orgId, adminUserId, batchRateLimitDomain);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
RateLimitSection,
type RateLimitWrapperProps,
} from "./RateLimitSection";

export const BATCH_RATE_LIMIT_INTENT = "set-batch-rate-limit";
export const BATCH_RATE_LIMIT_SAVED_VALUE = "batch-rate-limit";

export function BatchRateLimitSection(props: RateLimitWrapperProps) {
return (
<RateLimitSection
title="Batch rate limit"
intent={BATCH_RATE_LIMIT_INTENT}
{...props}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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),
// Capped at PostgreSQL INTEGER max (Prisma Int) so oversized input fails
// validation cleanly instead of crashing the update.
maximumProjectCount: z.coerce.number().int().min(1).max(2_147_483_647),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export type MaxProjectsActionResult =
| { ok: true }
| { ok: false; errors: Record<string, string[] | undefined> };

export async function handleMaxProjectsAction(
formData: FormData,
orgId: string,
adminUserId: string
): Promise<MaxProjectsActionResult> {
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 };
}
115 changes: 115 additions & 0 deletions apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string[] | undefined> | 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(hasFieldErrors);
const [value, setValue] = useState(String(maximumProjectCount));

useEffect(() => {
if (hasFieldErrors) setIsEditing(true);
}, [hasFieldErrors]);

useEffect(() => {
if (savedJustNow && !hasFieldErrors) setIsEditing(false);
}, [savedJustNow, hasFieldErrors]);

return (
<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>Maximum projects</Header2>
{!isEditing && (
<Button
variant="tertiary/small"
onClick={() => setIsEditing(true)}
disabled={isSubmitting}
>
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">
Saved.
</Paragraph>
</div>
)}

{!isEditing ? (
<Property.Table>
<Property.Item>
<Property.Label>Limit</Property.Label>
<Property.Value>
{maximumProjectCount.toLocaleString()}
</Property.Value>
</Property.Item>
</Property.Table>
) : (
<Form method="post" className="flex flex-col gap-3 pt-2">
<input type="hidden" name="intent" value={MAX_PROJECTS_INTENT} />
<div className="flex flex-col gap-1">
<Label>Maximum projects</Label>
<Input
name="maximumProjectCount"
type="number"
min={1}
value={value}
onChange={(e) => setValue(e.target.value)}
required
/>
<FormError>{fieldError("maximumProjectCount")}</FormError>
</div>
<div className="flex items-center gap-2">
<Button
type="submit"
variant="primary/medium"
disabled={isSubmitting || !value.trim()}
>
Save
</Button>
<Button
type="button"
variant="tertiary/medium"
onClick={() => {
setValue(String(maximumProjectCount));
setIsEditing(false);
}}
disabled={isSubmitting}
>
Cancel
</Button>
</div>
</Form>
)}
</section>
);
}
Loading
Loading