198 lines
6.8 KiB
TypeScript
198 lines
6.8 KiB
TypeScript
import { useNavigation, useSearchParams } from "@remix-run/react";
|
|
import { useEffect } from "react";
|
|
import { z } from "zod";
|
|
import { redirect, typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson";
|
|
import {
|
|
API_RATE_LIMIT_INTENT,
|
|
API_RATE_LIMIT_SAVED_VALUE,
|
|
ApiRateLimitSection,
|
|
} from "~/components/admin/backOffice/ApiRateLimitSection";
|
|
import {
|
|
handleApiRateLimitAction,
|
|
resolveEffectiveApiRateLimit,
|
|
} from "~/components/admin/backOffice/ApiRateLimitSection.server";
|
|
import {
|
|
BATCH_RATE_LIMIT_INTENT,
|
|
BATCH_RATE_LIMIT_SAVED_VALUE,
|
|
BatchRateLimitSection,
|
|
} from "~/components/admin/backOffice/BatchRateLimitSection";
|
|
import {
|
|
handleBatchRateLimitAction,
|
|
resolveEffectiveBatchRateLimit,
|
|
} from "~/components/admin/backOffice/BatchRateLimitSection.server";
|
|
import {
|
|
MAX_PROJECTS_INTENT,
|
|
MAX_PROJECTS_SAVED_VALUE,
|
|
MaxProjectsSection,
|
|
} from "~/components/admin/backOffice/MaxProjectsSection";
|
|
import { handleMaxProjectsAction } from "~/components/admin/backOffice/MaxProjectsSection.server";
|
|
import { LinkButton } from "~/components/primitives/Buttons";
|
|
import { CopyableText } from "~/components/primitives/CopyableText";
|
|
import { Header1 } from "~/components/primitives/Headers";
|
|
import { Paragraph } from "~/components/primitives/Paragraph";
|
|
import { prisma } from "~/db.server";
|
|
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
|
|
|
const SAVED_QUERY_KEY = "saved";
|
|
|
|
const ParamsSchema = z.object({
|
|
orgId: z.string(),
|
|
});
|
|
|
|
export const loader = dashboardLoader(
|
|
{ authorization: { requireSuper: true }, params: ParamsSchema },
|
|
async ({ params }) => {
|
|
const orgId = params.orgId;
|
|
|
|
const org = await prisma.organization.findFirst({
|
|
where: { id: orgId },
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
title: true,
|
|
createdAt: true,
|
|
apiRateLimiterConfig: true,
|
|
batchRateLimitConfig: true,
|
|
maximumProjectCount: true,
|
|
},
|
|
});
|
|
|
|
if (!org) {
|
|
throw new Response(null, { status: 404 });
|
|
}
|
|
|
|
const apiEffective = resolveEffectiveApiRateLimit(org.apiRateLimiterConfig);
|
|
const batchEffective = resolveEffectiveBatchRateLimit(org.batchRateLimitConfig);
|
|
|
|
return typedjson({ org, apiEffective, batchEffective });
|
|
}
|
|
);
|
|
|
|
export const action = dashboardAction(
|
|
{ authorization: { requireSuper: true }, params: ParamsSchema },
|
|
async ({ user, params, request }) => {
|
|
const orgId = params.orgId;
|
|
|
|
const formData = await request.formData();
|
|
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}`
|
|
);
|
|
}
|
|
|
|
if (intent === API_RATE_LIMIT_INTENT) {
|
|
const result = await handleApiRateLimitAction(formData, orgId, user.id);
|
|
if (!result.ok) {
|
|
return typedjson(
|
|
{ section: API_RATE_LIMIT_SAVED_VALUE, errors: result.errors },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
return redirect(
|
|
`/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${API_RATE_LIMIT_SAVED_VALUE}`
|
|
);
|
|
}
|
|
|
|
if (intent === BATCH_RATE_LIMIT_INTENT) {
|
|
const result = await handleBatchRateLimitAction(formData, orgId, user.id);
|
|
if (!result.ok) {
|
|
return typedjson(
|
|
{ section: BATCH_RATE_LIMIT_SAVED_VALUE, errors: result.errors },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
return redirect(
|
|
`/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${BATCH_RATE_LIMIT_SAVED_VALUE}`
|
|
);
|
|
}
|
|
|
|
return typedjson({ section: null, errors: { intent: ["Unknown intent."] } }, { status: 400 });
|
|
}
|
|
);
|
|
|
|
export default function BackOfficeOrgPage() {
|
|
const { org, apiEffective, batchEffective } = useTypedLoaderData<typeof loader>();
|
|
const actionData = useTypedActionData<typeof action>();
|
|
const navigation = useNavigation();
|
|
const submittingIntent = navigation.formData?.get("intent");
|
|
const isSubmittingApi = navigation.state !== "idle" && submittingIntent === API_RATE_LIMIT_INTENT;
|
|
const isSubmittingBatch =
|
|
navigation.state !== "idle" && submittingIntent === BATCH_RATE_LIMIT_INTENT;
|
|
const isSubmittingMaxProjects =
|
|
navigation.state !== "idle" && submittingIntent === MAX_PROJECTS_INTENT;
|
|
|
|
const errorSection = actionData && "section" in actionData ? actionData.section : null;
|
|
const errors =
|
|
actionData && "errors" in actionData
|
|
? (actionData.errors as Record<string, string[] | undefined>)
|
|
: null;
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
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(() => {
|
|
if (!savedSection) return;
|
|
const t = setTimeout(() => {
|
|
setSearchParams(
|
|
(prev) => {
|
|
prev.delete(SAVED_QUERY_KEY);
|
|
return prev;
|
|
},
|
|
{ replace: true, preventScrollReset: true }
|
|
);
|
|
}, 3000);
|
|
return () => clearTimeout(t);
|
|
}, [savedSection, setSearchParams]);
|
|
|
|
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>
|
|
|
|
<ApiRateLimitSection
|
|
effective={apiEffective}
|
|
errors={errorSection === API_RATE_LIMIT_SAVED_VALUE ? errors : null}
|
|
savedJustNow={savedSection === API_RATE_LIMIT_SAVED_VALUE}
|
|
isSubmitting={isSubmittingApi}
|
|
/>
|
|
|
|
<BatchRateLimitSection
|
|
effective={batchEffective}
|
|
errors={errorSection === BATCH_RATE_LIMIT_SAVED_VALUE ? errors : null}
|
|
savedJustNow={savedSection === BATCH_RATE_LIMIT_SAVED_VALUE}
|
|
isSubmitting={isSubmittingBatch}
|
|
/>
|
|
|
|
<MaxProjectsSection
|
|
maximumProjectCount={org.maximumProjectCount}
|
|
errors={errorSection === MAX_PROJECTS_SAVED_VALUE ? errors : null}
|
|
savedJustNow={savedSection === MAX_PROJECTS_SAVED_VALUE}
|
|
isSubmitting={isSubmittingMaxProjects}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|