From cb84c2954be755f3fcb51d1f49beff9cbcc33021 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 27 Aug 2026 00:03:56 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 +++ src/app/admin/organizations/page.tsx | 17 +++++++++++++++++ src/app/navigation.tsx | 8 +++++++- 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/app/admin/organizations/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 62ed7f30a..1b0927561 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -465,6 +465,8 @@ "apiKeysDelete": "Delete API Key", "apiKeysManage": "Manage API Keys", "apiKeysDescription": "API keys are used to authenticate with the integration API", + "orgsManage": "Manage Organizations", + "orgsDescription": "View and manage all organizations on this instance", "provisioningKeysTitle": "Provisioning Key", "provisioningKeysManage": "Manage Provisioning Keys", "provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", @@ -2089,6 +2091,7 @@ "resourceBudgetSettings": "Budget", "resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits", "sidebarApiKeys": "API Keys", + "sidebarOrgs": "Organizations", "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", "sidebarAllUsers": "All Users", diff --git a/src/app/admin/organizations/page.tsx b/src/app/admin/organizations/page.tsx new file mode 100644 index 000000000..215f42a60 --- /dev/null +++ b/src/app/admin/organizations/page.tsx @@ -0,0 +1,17 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { getTranslations } from "next-intl/server"; + +export interface OrganizationsPageProps {} + +export default async function OrganizationsPage(props: OrganizationsPageProps) { + const t = await getTranslations(); + + return ( + <> + + + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 724da9cc2..db9ca47bf 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -6,6 +6,7 @@ import { Bot, Boxes, Building2, + Building2Icon, Cable, ChartLine, Coins, @@ -377,6 +378,11 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [ href: "/admin/api-keys", icon: }, + { + title: "sidebarOrgs", + href: "/admin/organizations", + icon: + }, ...(build === "oss" || env?.app.identityProviderMode === "global" || env?.app.identityProviderMode === undefined @@ -388,7 +394,7 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [ } ] : []), - ...(build == "enterprise" + ...(build === "enterprise" ? [ { title: "sidebarLicense", From a904c915d8f6e6e2e2e1bcc09e202cb45bf9602b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 28 Aug 2026 21:42:20 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20List=20orgs=20in=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/external.ts | 1 + server/routers/org/adminListOrgs.ts | 236 +++++++++++++++++++++++++++ server/routers/org/index.ts | 1 + src/app/admin/organizations/page.tsx | 54 +++++- src/components/OrgsTable.tsx | 228 ++++++++++++++++++++++++++ 5 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 server/routers/org/adminListOrgs.ts create mode 100644 src/components/OrgsTable.tsx diff --git a/server/routers/external.ts b/server/routers/external.ts index d2935ff91..9bd3a3f4f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -87,6 +87,7 @@ authenticated.get("/org/checkId", org.checkId); authenticated.put("/org", getUserOrgs, org.createOrg); authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); +authenticated.get("/admin/orgs", verifyUserIsServerAdmin, org.adminListOrgs); authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); authenticated.get( diff --git a/server/routers/org/adminListOrgs.ts b/server/routers/org/adminListOrgs.ts new file mode 100644 index 000000000..61a9e525d --- /dev/null +++ b/server/routers/org/adminListOrgs.ts @@ -0,0 +1,236 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, users } from "@server/db"; +import { orgs, resources, sites, userOrgs } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; +import type { PaginatedResponse } from "@server/types/Pagination"; + +const adminListOrgsSchema = z.strictObject({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .positive() + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["name", "createdAt"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["name", "createdAt"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }) +}); + +export type AdminOrgRow = { + orgId: string; + name: string; + subnet: string | null; + createdAt: string | null; + userCount: number; + siteCount: number; + resourceCount: number; +}; + +export type AdminListOrgsResponse = PaginatedResponse<{ + orgs: AdminOrgRow[]; +}>; + +const AdminListOrgsResponseDataSchema = z.object({ + orgs: z.array( + z.object({ + orgId: z.string(), + name: z.string(), + subnet: z.string().nullable(), + createdAt: z.string().nullable(), + userCount: z.number(), + siteCount: z.number(), + resourceCount: z.number() + }) + ), + pagination: z.object({ + total: z.number(), + page: z.number(), + pageSize: z.number() + }) +}); + +registry.registerPath({ + method: "get", + path: "/admin/orgs", + description: + "List all organizations in the system with usage counts (server admin).", + tags: [OpenAPITags.Org], + request: { + query: adminListOrgsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: createApiResponseSchema( + AdminListOrgsResponseDataSchema + ) + } + } + } + } +}); + +export async function adminListOrgs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = adminListOrgsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const { pageSize, page, query, sort_by, order } = parsedQuery.data; + + let conditions: (SQL | undefined)[] = []; + if (query) { + const q = "%" + query.toLowerCase() + "%"; + conditions.push( + or( + like(sql`LOWER(${orgs.name})`, q), + like(sql`LOWER(${orgs.orgId})`, q), + like(sql`LOWER(${orgs.subnet})`, q) + ) + ); + } + + const sortColumns = { + name: orgs.name, + createdAt: orgs.createdAt + } as const; + + const orderBy = sort_by + ? order === "asc" + ? asc(sortColumns[sort_by]) + : desc(sortColumns[sort_by]) + : asc(orgs.name); + + // Drizzle renders bare column references in the select list without their + // table prefix, which would make a correlated subquery compare a column to + // itself, so the outer `orgs` side is qualified explicitly. + const orgIdRef = sql`${sql.identifier("orgs")}.${sql.identifier("orgId")}`; + + const [countRows, rows] = await Promise.all([ + db + .select({ count: sql`count(*)` }) + .from(orgs) + .where(and(...conditions)), + db + .selectDistinct({ + orgId: orgs.orgId, + name: orgs.name, + subnet: orgs.subnet, + createdAt: orgs.createdAt, + userCount: sql`( + SELECT COUNT(*) + FROM ${userOrgs} + WHERE ${userOrgs.orgId} = ${orgIdRef} + )`.as("userCount"), + siteCount: sql`( + SELECT COUNT(*) + FROM ${sites} + WHERE ${sites.orgId} = ${orgIdRef} + )`.as("siteCount"), + resourceCount: sql`( + SELECT COUNT(*) + FROM ${resources} + WHERE ${resources.orgId} = ${orgIdRef} + )`.as("resourceCount"), + owner: { + id: users.userId, + name: users.name, + username: users.username + } + }) + .from(orgs) + .where(and(...conditions, eq(userOrgs.isOwner, true))) + .leftJoin(userOrgs, eq(userOrgs.orgId, orgs.orgId)) + .leftJoin(users, eq(userOrgs.userId, users.userId)) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(orderBy) + ]); + + const totalCount = Number(countRows[0]?.count ?? 0); + + return response(res, { + data: { + orgs: rows.map((row) => ({ + ...row, + userCount: Number(row.userCount ?? 0), + siteCount: Number(row.siteCount ?? 0), + resourceCount: Number(row.resourceCount ?? 0) + })), + pagination: { + total: totalCount, + page, + pageSize + } + }, + success: true, + error: false, + message: "Organizations retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } +} diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index c1aee7b33..afda206a1 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -9,3 +9,4 @@ export * from "./listOrgs"; export * from "./pickOrgDefaults"; export * from "./checkOrgUserAccess"; export * from "./resetOrgBandwidth"; +export * from "./adminListOrgs"; diff --git a/src/app/admin/organizations/page.tsx b/src/app/admin/organizations/page.tsx index 215f42a60..f01cc3636 100644 --- a/src/app/admin/organizations/page.tsx +++ b/src/app/admin/organizations/page.tsx @@ -1,17 +1,69 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import OrgsTable, { type OrgRow } from "@app/components/OrgsTable"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { AdminListOrgsResponse } from "@server/routers/org"; +import type { AxiosResponse } from "axios"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; -export interface OrganizationsPageProps {} +export const metadata: Metadata = { + title: "Organizations" +}; + +export const dynamic = "force-dynamic"; + +type OrganizationsPageProps = { + searchParams: Promise>; +}; export default async function OrganizationsPage(props: OrganizationsPageProps) { + const searchParams = new URLSearchParams(await props.searchParams); + + let orgs: AdminListOrgsResponse["orgs"] = []; + let pagination: AdminListOrgsResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const res = await internal.get>( + `/admin/orgs?${searchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + orgs = responseData.orgs; + pagination = responseData.pagination; + } catch (e) {} + const t = await getTranslations(); + const orgRows: OrgRow[] = orgs.map((org) => ({ + orgId: org.orgId, + name: org.name, + subnet: org.subnet, + createdAt: org.createdAt, + userCount: org.userCount, + siteCount: org.siteCount, + resourceCount: org.resourceCount + })); + return ( <> + + ); } diff --git a/src/components/OrgsTable.tsx b/src/components/OrgsTable.tsx new file mode 100644 index 000000000..9a2e57e00 --- /dev/null +++ b/src/components/OrgsTable.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { type PaginationState } from "@tanstack/react-table"; +import { + ArrowDown01Icon, + ArrowRight, + ArrowUp10Icon, + ChevronsUpDownIcon, + MoreHorizontal +} from "lucide-react"; +import moment from "moment"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; + +export type OrgRow = { + orgId: string; + name: string; + subnet: string | null; + createdAt: string | null; + userCount: number; + siteCount: number; + resourceCount: number; +}; + +type OrgTableProps = { + orgs: OrgRow[]; + pagination: PaginationState; + rowCount: number; +}; + +export default function OrgsTable({ + orgs, + pagination, + rowCount +}: OrgTableProps) { + const router = useRouter(); + const t = useTranslations(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [isRefreshing, startTransition] = useTransition(); + + function refreshData() { + startTransition(async () => { + try { + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + function sortableHeader(column: string, label: string) { + const sortOrder = getSortDirection(column, searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + + return ( + + ); + } + + const columns = useMemo[]>(() => { + return [ + { + accessorKey: "name", + friendlyName: t("name"), + enableHiding: false, + header: () => sortableHeader("name", t("name")) + }, + { + accessorKey: "createdAt", + friendlyName: t("createdAt"), + header: () => sortableHeader("createdAt", t("createdAt")), + cell: ({ row }) => { + const createdAt = row.original.createdAt; + return ( + + {createdAt ? moment(createdAt).format("lll") : "-"} + + ); + } + }, + { + accessorKey: "orgId", + friendlyName: t("orgId"), + header: () => {t("orgId")} + }, + { + accessorKey: "subnet", + friendlyName: t("subnet"), + header: () => {t("subnet")}, + cell: ({ row }) => {row.original.subnet || "-"} + }, + { + accessorKey: "userCount", + friendlyName: t("users"), + header: () => {t("users")}, + cell: ({ row }) => ( + + {row.original.userCount} + + ) + }, + { + accessorKey: "siteCount", + friendlyName: t("sites"), + header: () => {t("sites")}, + cell: ({ row }) => ( + + {row.original.siteCount} + + ) + }, + { + accessorKey: "resourceCount", + friendlyName: t("resources"), + header: () => {t("resources")}, + cell: ({ row }) => ( + + {row.original.resourceCount} + + ) + }, + + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const orgRow = row.original; + return ( +
+ +
+ ); + } + } + ]; + }, [t, searchParams]); + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + + return ( + + ); +} From e9f7678b9003e9493064b166dbe13268c0313725 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 28 Aug 2026 22:00:06 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20List=20of=20orgs,=20with=20all?= =?UTF-8?q?=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/org/adminListOrgs.ts | 9 ++++- src/app/admin/organizations/page.tsx | 14 +------ src/components/OrgsTable.tsx | 60 ++++++++++++++++++---------- 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/server/routers/org/adminListOrgs.ts b/server/routers/org/adminListOrgs.ts index 61a9e525d..d17757211 100644 --- a/server/routers/org/adminListOrgs.ts +++ b/server/routers/org/adminListOrgs.ts @@ -64,10 +64,16 @@ export type AdminOrgRow = { orgId: string; name: string; subnet: string | null; + utilitySubnet: string | null; createdAt: string | null; userCount: number; siteCount: number; resourceCount: number; + owner: { + userId: string; + name: string | null; + username: string; + } | null; }; export type AdminListOrgsResponse = PaginatedResponse<{ @@ -172,6 +178,7 @@ export async function adminListOrgs( orgId: orgs.orgId, name: orgs.name, subnet: orgs.subnet, + utilitySubnet: orgs.utilitySubnet, createdAt: orgs.createdAt, userCount: sql`( SELECT COUNT(*) @@ -189,7 +196,7 @@ export async function adminListOrgs( WHERE ${resources.orgId} = ${orgIdRef} )`.as("resourceCount"), owner: { - id: users.userId, + userId: users.userId, name: users.name, username: users.username } diff --git a/src/app/admin/organizations/page.tsx b/src/app/admin/organizations/page.tsx index f01cc3636..0f0619096 100644 --- a/src/app/admin/organizations/page.tsx +++ b/src/app/admin/organizations/page.tsx @@ -1,5 +1,5 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import OrgsTable, { type OrgRow } from "@app/components/OrgsTable"; +import OrgsTable from "@app/components/OrgsTable"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import type { AdminListOrgsResponse } from "@server/routers/org"; @@ -39,16 +39,6 @@ export default async function OrganizationsPage(props: OrganizationsPageProps) { const t = await getTranslations(); - const orgRows: OrgRow[] = orgs.map((org) => ({ - orgId: org.orgId, - name: org.name, - subnet: org.subnet, - createdAt: org.createdAt, - userCount: org.userCount, - siteCount: org.siteCount, - resourceCount: org.resourceCount - })); - return ( <> []>(() => { + const columns = useMemo[]>(() => { return [ { accessorKey: "name", @@ -124,6 +108,31 @@ export default function OrgsTable({ ); } }, + { + accessorKey: "owner", + friendlyName: t("accessRoleOwner"), + header: () => ( + {t("accessRoleOwner")} + ), + cell: ({ row }) => { + const owner = row.original.owner; + return owner ? ( + + ) : ( + - + ); + } + }, { accessorKey: "orgId", friendlyName: t("orgId"), @@ -135,6 +144,14 @@ export default function OrgsTable({ header: () => {t("subnet")}, cell: ({ row }) => {row.original.subnet || "-"} }, + { + accessorKey: "utilitySubnet", + friendlyName: t("utilitySubnet"), + header: () => {t("utilitySubnet")}, + cell: ({ row }) => ( + {row.original.utilitySubnet || "-"} + ) + }, { accessorKey: "userCount", friendlyName: t("users"), @@ -218,6 +235,7 @@ export default function OrgsTable({ rowCount={rowCount} columnVisibility={{ subnet: false, + utilitySubnet: false, orgId: false }} enableColumnVisibility From a4d9365563143784b453f5985dde067fc266cc9a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 28 Aug 2026 22:09:01 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Only=20show=20the=20us?= =?UTF-8?q?ername=20instead=20of=20the=20`name+username`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/org/adminListOrgs.ts | 2 -- src/components/OrgsTable.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/server/routers/org/adminListOrgs.ts b/server/routers/org/adminListOrgs.ts index d17757211..7ec29eae4 100644 --- a/server/routers/org/adminListOrgs.ts +++ b/server/routers/org/adminListOrgs.ts @@ -71,7 +71,6 @@ export type AdminOrgRow = { resourceCount: number; owner: { userId: string; - name: string | null; username: string; } | null; }; @@ -197,7 +196,6 @@ export async function adminListOrgs( )`.as("resourceCount"), owner: { userId: users.userId, - name: users.name, username: users.username } }) diff --git a/src/components/OrgsTable.tsx b/src/components/OrgsTable.tsx index 5c546f778..167c11d57 100644 --- a/src/components/OrgsTable.tsx +++ b/src/components/OrgsTable.tsx @@ -124,7 +124,7 @@ export default function OrgsTable({ size="sm" > - {owner.name || owner.username} + {owner.username} From c411a1a5b950195c711e994202bcd0d4bb1e76ae Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 28 Aug 2026 22:49:51 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/OrgsTable.tsx | 105 ++++++++++++++++++------ src/components/PublicResourcesTable.tsx | 4 +- src/components/SitesTable.tsx | 1 - 3 files changed, 83 insertions(+), 27 deletions(-) diff --git a/src/components/OrgsTable.tsx b/src/components/OrgsTable.tsx index 167c11d57..112c19987 100644 --- a/src/components/OrgsTable.tsx +++ b/src/components/OrgsTable.tsx @@ -8,7 +8,8 @@ import { import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { toast } from "@app/hooks/useToast"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; -import type { AdminOrgRow } from "@server/routers/org"; +import type { AdminOrgRow, DeleteOrgResponse } from "@server/routers/org"; + import { type PaginationState } from "@tanstack/react-table"; import { ArrowDown01Icon, @@ -20,8 +21,13 @@ import moment from "moment"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useMemo, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; +import ConfirmDeleteDialog from "./ConfirmDeleteDialog"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import type { AxiosResponse } from "axios"; +import api from "gpt-tokenizer"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type OrgTableProps = { orgs: AdminOrgRow[]; @@ -44,6 +50,10 @@ export default function OrgsTable({ const [isRefreshing, startTransition] = useTransition(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedOrg, setSelectedOrg] = useState(); + const api = createApiClient(useEnvContext()); + function refreshData() { startTransition(async () => { try { @@ -192,6 +202,10 @@ export default function OrgsTable({ return (