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 ( + + ); +}