roles table

This commit is contained in:
Fred KISSIE
2026-03-23 21:34:44 +01:00
parent 062bec23b6
commit 294532ecbb
6 changed files with 187 additions and 152 deletions

View File

@@ -3,34 +3,68 @@ import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, like, sql } from "drizzle-orm";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { object, z } from "zod"; import { object, z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import type { PaginatedResponse } from "@server/types/Pagination";
const listRolesParamsSchema = z.strictObject({ const listRolesParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
const listRolesSchema = z.object({ const listRolesSchema = z.object({
limit: z pageSize: z.coerce
.string() .number<string>() // for prettier formatting
.int()
.positive()
.optional() .optional()
.default("1000") .catch(20)
.transform(Number) .default(20)
.pipe(z.int().nonnegative()), .openapi({
offset: z type: "integer",
.string() default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional() .optional()
.default("0") .catch(1)
.transform(Number) .default(1)
.pipe(z.int().nonnegative()) .openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["name"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name"],
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"
})
}); });
async function queryRoles(orgId: string, limit: number, offset: number) { function queryRolesBase() {
return await db return db
.select({ .select({
roleId: roles.roleId, roleId: roles.roleId,
orgId: roles.orgId, orgId: roles.orgId,
@@ -45,20 +79,15 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
sshUnixGroups: roles.sshUnixGroups sshUnixGroups: roles.sshUnixGroups
}) })
.from(roles) .from(roles)
.leftJoin(orgs, eq(roles.orgId, orgs.orgId)) .leftJoin(orgs, eq(roles.orgId, orgs.orgId));
.where(eq(roles.orgId, orgId)) // .where(eq(roles.orgId, orgId))
.limit(limit) // .limit(limit)
.offset(offset); // .offset(offset);
} }
export type ListRolesResponse = { export type ListRolesResponse = PaginatedResponse<{
roles: NonNullable<Awaited<ReturnType<typeof queryRoles>>>; roles: NonNullable<Awaited<ReturnType<typeof queryRolesBase>>>;
pagination: { }>;
total: number;
limit: number;
offset: number;
};
};
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
@@ -88,7 +117,7 @@ export async function listRoles(
); );
} }
const { limit, offset } = parsedQuery.data; const { page, pageSize, query, sort_by, order } = parsedQuery.data;
const parsedParams = listRolesParamsSchema.safeParse(req.params); const parsedParams = listRolesParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -102,14 +131,36 @@ export async function listRoles(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const countQuery: any = db const conditions = [and(eq(roles.orgId, orgId))];
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(roles)
.where(eq(roles.orgId, orgId));
const rolesList = await queryRoles(orgId, limit, offset); if (query) {
const totalCountResult = await countQuery; conditions.push(
const totalCount = totalCountResult[0].count; like(sql`LOWER(${roles.name})`, "%" + query.toLowerCase() + "%")
);
}
const countQuery = db.$count(
queryRolesBase()
.where(and(...conditions))
.as("filtered_roles")
);
const rolesListQuery = queryRolesBase()
.where(and(...conditions))
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
? order === "asc"
? asc(roles[sort_by])
: desc(roles[sort_by])
: asc(roles.name)
);
const [totalCount, rolesList] = await Promise.all([
countQuery,
rolesListQuery
]);
let rolesWithAllowSsh = rolesList; let rolesWithAllowSsh = rolesList;
if (rolesList.length > 0) { if (rolesList.length > 0) {
@@ -135,8 +186,8 @@ export async function listRoles(
roles: rolesWithAllowSsh, roles: rolesWithAllowSsh,
pagination: { pagination: {
total: totalCount, total: totalCount,
limit, page,
offset pageSize
} }
}, },
success: true, success: true,

View File

@@ -11,24 +11,32 @@ import { getCachedOrg } from "@app/lib/api/getCachedOrg";
type RolesPageProps = { type RolesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
}; };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function RolesPage(props: RolesPageProps) { export default async function RolesPage(props: RolesPageProps) {
const params = await props.params; const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
let roles: ListRolesResponse["roles"] = []; let roles: ListRolesResponse["roles"] = [];
let pagination: ListRolesResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
let hasInvitations = false; let hasInvitations = false;
const res = await internal const res = await internal
.get< .get<
AxiosResponse<ListRolesResponse> AxiosResponse<ListRolesResponse>
>(`/org/${params.orgId}/roles`, await authCookieHeader()) >(`/org/${params.orgId}/roles?${searchParams.toString()}`, await authCookieHeader())
.catch((e) => {}); .catch((e) => {});
if (res && res.status === 200) { if (res && res.status === 200) {
roles = res.data.data.roles; roles = res.data.data.roles;
pagination = res.data.data.pagination;
} }
const invitationsRes = await internal const invitationsRes = await internal
@@ -63,7 +71,14 @@ export default async function RolesPage(props: RolesPageProps) {
description={t("accessRolesDescription")} description={t("accessRolesDescription")}
/> />
<OrgProvider org={org}> <OrgProvider org={org}>
<RolesTable roles={roleRows} /> <RolesTable
roles={roleRows}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</OrgProvider> </OrgProvider>
</> </>
); );

View File

@@ -1,41 +0,0 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createRole?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function RolesDataTable<TData, TValue>({
columns,
data,
createRole,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="roles-table"
title={t("roles")}
searchPlaceholder={t("accessRolesSearch")}
searchColumn="name"
onAdd={createRole}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t("accessRolesAdd")}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -7,7 +7,13 @@ import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { Role } from "@server/db"; import { Role } from "@server/db";
import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpDown,
ChevronsUpDownIcon,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
@@ -18,24 +24,40 @@ import {
DropdownMenuItem DropdownMenuItem
} from "./ui/dropdown-menu"; } from "./ui/dropdown-menu";
import EditRoleForm from "./EditRoleForm"; import EditRoleForm from "./EditRoleForm";
import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { useDebouncedCallback } from "use-debounce";
export type RoleRow = Role; export type RoleRow = Role;
type RolesTableProps = { type RolesTableProps = {
roles: RoleRow[]; roles: RoleRow[];
pagination: PaginationState;
rowCount: number;
}; };
export default function UsersTable({ roles }: RolesTableProps) { export default function UsersTable({
roles,
pagination,
rowCount
}: RolesTableProps) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [editingRole, setEditingRole] = useState<RoleRow | null>(null); const [editingRole, setEditingRole] = useState<RoleRow | null>(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const [isRefreshing, startTransition] = useTransition();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [roleToRemove, setRoleToRemove] = useState<RoleRow | null>(null); const [roleToRemove, setRoleToRemove] = useState<RoleRow | null>(null);
const t = useTranslations(); const t = useTranslations();
const [isRefreshing, startTransition] = useTransition();
const refreshData = async () => { const refreshData = async () => {
console.log("Data refreshed"); console.log("Data refreshed");
@@ -56,15 +78,17 @@ export default function UsersTable({ roles }: RolesTableProps) {
enableHiding: false, enableHiding: false,
friendlyName: t("name"), friendlyName: t("name"),
header: ({ column }) => { header: ({ column }) => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return ( return (
<Button <Button variant="ghost" onClick={() => toggleSort("name")}>
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")} {t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} }
@@ -148,6 +172,30 @@ export default function UsersTable({ roles }: RolesTableProps) {
} }
]; ];
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
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 ( return (
<> <>
{editingRole && ( {editingRole && (
@@ -191,10 +239,18 @@ export default function UsersTable({ roles }: RolesTableProps) {
/> />
)} )}
<RolesDataTable <ControlledDataTable
columns={columns} columns={columns}
data={roles} rows={roles}
createRole={() => { tableId="roles-table"
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
searchPlaceholder={t("accessRolesSearch")}
addButtonText={t("accessRolesAdd")}
rowCount={rowCount}
pagination={pagination}
onAdd={() => {
setIsCreateModalOpen(true); setIsCreateModalOpen(true);
}} }}
onRefresh={() => startTransition(refreshData)} onRefresh={() => startTransition(refreshData)}

View File

@@ -1,41 +0,0 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
inviteUser?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function UsersDataTable<TData, TValue>({
columns,
data,
inviteUser,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="users-table"
title={t("users")}
searchPlaceholder={t("accessUsersSearch")}
searchColumn="email"
onAdd={inviteUser}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t("accessUserCreate")}
enableColumnVisibility={true}
stickyLeftColumn="displayUsername"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { ColumnDef, type PaginationState } from "@tanstack/react-table"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { import {
DropdownMenu, DropdownMenu,
@@ -8,35 +9,29 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { useUserContext } from "@app/hooks/useUserContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { type PaginationState } from "@tanstack/react-table";
import { import {
ArrowDown01Icon, ArrowDown01Icon,
ArrowRight, ArrowRight,
ArrowUp10Icon, ArrowUp10Icon,
ArrowUpDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
Crown,
MoreHorizontal MoreHorizontal
} from "lucide-react"; } from "lucide-react";
import { UsersDataTable } from "@app/components/UsersDataTable";
import { useState, useEffect, useTransition } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import IdpTypeBadge from "./IdpTypeBadge"; import IdpTypeBadge from "./IdpTypeBadge";
import { ControlledDataTable } from "./ui/controlled-data-table"; import { ControlledDataTable } from "./ui/controlled-data-table";
import type { filter } from "d3";
import { useDebouncedCallback } from "use-debounce";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
export type UserRow = { export type UserRow = {
id: string; id: string;