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:
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,11 @@ export default function Page() {
|
||||
label: "Notifications",
|
||||
to: "/admin/notifications",
|
||||
},
|
||||
{
|
||||
label: "Back office",
|
||||
to: "/admin/back-office",
|
||||
end: false,
|
||||
},
|
||||
]}
|
||||
layoutId={"admin"}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user