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 { OpenAPITags, registry } from "@server/openApi";
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 { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { object, z } from "zod";
import { fromError } from "zod-validation-error";
import type { PaginatedResponse } from "@server/types/Pagination";
const listRolesParamsSchema = z.strictObject({
orgId: z.string()
});
const listRolesSchema = z.object({
limit: z
.string()
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.default("1000")
.transform(Number)
.pipe(z.int().nonnegative()),
offset: z
.string()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.catch(1)
.default(1)
.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) {
return await db
function queryRolesBase() {
return db
.select({
roleId: roles.roleId,
orgId: roles.orgId,
@@ -45,20 +79,15 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
sshUnixGroups: roles.sshUnixGroups
})
.from(roles)
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
.where(eq(roles.orgId, orgId))
.limit(limit)
.offset(offset);
.leftJoin(orgs, eq(roles.orgId, orgs.orgId));
// .where(eq(roles.orgId, orgId))
// .limit(limit)
// .offset(offset);
}
export type ListRolesResponse = {
roles: NonNullable<Awaited<ReturnType<typeof queryRoles>>>;
pagination: {
total: number;
limit: number;
offset: number;
};
};
export type ListRolesResponse = PaginatedResponse<{
roles: NonNullable<Awaited<ReturnType<typeof queryRolesBase>>>;
}>;
registry.registerPath({
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);
if (!parsedParams.success) {
@@ -102,14 +131,36 @@ export async function listRoles(
const { orgId } = parsedParams.data;
const countQuery: any = db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(roles)
.where(eq(roles.orgId, orgId));
const conditions = [and(eq(roles.orgId, orgId))];
const rolesList = await queryRoles(orgId, limit, offset);
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
if (query) {
conditions.push(
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;
if (rolesList.length > 0) {
@@ -135,8 +186,8 @@ export async function listRoles(
roles: rolesWithAllowSsh,
pagination: {
total: totalCount,
limit,
offset
page,
pageSize
}
},
success: true,

View File

@@ -11,24 +11,32 @@ import { getCachedOrg } from "@app/lib/api/getCachedOrg";
type RolesPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export const dynamic = "force-dynamic";
export default async function RolesPage(props: RolesPageProps) {
const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
let roles: ListRolesResponse["roles"] = [];
let pagination: ListRolesResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
let hasInvitations = false;
const res = await internal
.get<
AxiosResponse<ListRolesResponse>
>(`/org/${params.orgId}/roles`, await authCookieHeader())
>(`/org/${params.orgId}/roles?${searchParams.toString()}`, await authCookieHeader())
.catch((e) => {});
if (res && res.status === 200) {
roles = res.data.data.roles;
pagination = res.data.data.pagination;
}
const invitationsRes = await internal
@@ -63,7 +71,14 @@ export default async function RolesPage(props: RolesPageProps) {
description={t("accessRolesDescription")}
/>
<OrgProvider org={org}>
<RolesTable roles={roleRows} />
<RolesTable
roles={roleRows}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</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 { toast } from "@app/hooks/useToast";
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 { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
@@ -18,24 +24,40 @@ import {
DropdownMenuItem
} from "./ui/dropdown-menu";
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;
type RolesTableProps = {
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 [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [editingRole, setEditingRole] = useState<RoleRow | null>(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const router = useRouter();
const [isRefreshing, startTransition] = useTransition();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [roleToRemove, setRoleToRemove] = useState<RoleRow | null>(null);
const t = useTranslations();
const [isRefreshing, startTransition] = useTransition();
const refreshData = async () => {
console.log("Data refreshed");
@@ -56,15 +78,17 @@ export default function UsersTable({ roles }: RolesTableProps) {
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
<Button variant="ghost" onClick={() => toggleSort("name")}>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
<Icon className="ml-2 h-4 w-4" />
</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 (
<>
{editingRole && (
@@ -191,10 +239,18 @@ export default function UsersTable({ roles }: RolesTableProps) {
/>
)}
<RolesDataTable
<ControlledDataTable
columns={columns}
data={roles}
createRole={() => {
rows={roles}
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);
}}
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";
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 {
DropdownMenu,
@@ -8,35 +9,29 @@ import {
DropdownMenuItem,
DropdownMenuTrigger
} 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 {
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
ArrowUpDown,
ChevronsUpDownIcon,
Crown,
MoreHorizontal
} 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 Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import IdpTypeBadge from "./IdpTypeBadge";
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 = {
id: string;