diff --git a/messages/en-US.json b/messages/en-US.json index 7d1b54102..ba9b1e5a0 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -270,6 +270,8 @@ "accessUserManage": "Manage User", "accessUsersDescription": "Invite and manage users with access to this organization", "accessUsersSearch": "Search users...", + "accessUsersRoleFilterCount": "{count, plural, one {# role} other {# roles}}", + "accessUsersRoleFilterClear": "Clear role filters", "accessUserCreate": "Create User", "accessUserRemove": "Remove User", "username": "Username", diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index eae9e79f9..42a62636d 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,15 +1,23 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idpOidcConfig } from "@server/db"; -import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db"; +import { + idp, + idpOrg, + roles, + userOrgRoles, + userOrgs, + users +} 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, inArray, like, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, exists, inArray, like, or, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import type { PaginatedResponse } from "@server/types/Pagination"; +import { UserType } from "@server/types/UserTypes"; const listUsersParamsSchema = z.strictObject({ orgId: z.string() @@ -60,6 +68,41 @@ const listUsersSchema = z.strictObject({ enum: ["asc", "desc"], default: "asc", description: "Sort order" + }), + idp_id: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (val === "internal") { + return "internal"; + } + if (typeof val === "string" && /^\d+$/.test(val)) { + return parseInt(val, 10); + } + return undefined; + }, z.union([z.literal("internal"), z.number().int().positive()]).optional()) + .openapi({ + description: + 'Filter by identity provider id, or "internal" for internal users' + }), + role_id: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) ? val : [val]; + const nums = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + const unique = [...new Set(nums)]; + return unique.length ? unique : undefined; + }, z.array(z.number().int().positive()).max(50).optional()) + .openapi({ + description: + "Filter users who have any of these role ids in the organization (repeat query param)" }) }); @@ -125,7 +168,9 @@ export async function listUsers( ) ); } - const { page, pageSize, sort_by, order, query } = parsedQuery.data; + const { page, pageSize, sort_by, order, query, idp_id, role_id } = + parsedQuery.data; + const roleIds = role_id ?? []; const parsedParams = listUsersParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -139,6 +184,41 @@ export async function listUsers( const { orgId } = parsedParams.data; + if (typeof idp_id === "number") { + const idpOk = await db + .select({ one: sql`1` }) + .from(idpOrg) + .where( + and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idp_id)) + ) + .limit(1); + if (idpOk.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "idp_id is not linked to this organization" + ) + ); + } + } + + if (roleIds.length > 0) { + const validRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and(eq(roles.orgId, orgId), inArray(roles.roleId, roleIds)) + ); + if (validRoles.length !== roleIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more role_id values are not valid for this organization" + ) + ); + } + } + const conditions = [and(eq(userOrgs.orgId, orgId))]; if (query) { @@ -160,6 +240,29 @@ export async function listUsers( ); } + if (idp_id === "internal") { + conditions.push(eq(users.type, UserType.Internal)); + } else if (typeof idp_id === "number") { + conditions.push(eq(users.idpId, idp_id)); + } + + if (roleIds.length > 0) { + conditions.push( + exists( + db + .select() + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, users.userId), + eq(userOrgRoles.orgId, orgId), + inArray(userOrgRoles.roleId, roleIds) + ) + ) + ) + ); + } + const countQuery = db.$count( queryUsersBase() .where(and(...conditions)) diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index bdf8531a2..462122a95 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -1,8 +1,9 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import type { ListOrgIdpsResponse } from "@server/routers/orgIdp/types"; +import type { ListRolesResponse } from "@server/routers/role/listRoles"; import { ListUsersResponse } from "@server/routers/user"; -import { AxiosResponse } from "axios"; import UsersTable, { UserRow } from "@app/components/UsersTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; @@ -29,7 +30,6 @@ export default async function UsersPage(props: UsersPageProps) { const searchParams = new URLSearchParams(await props.searchParams); const user = await verifySession(); - const t = await getTranslations(); let users: ListUsersResponse["users"] = []; let pagination: ListUsersResponse["pagination"] = { @@ -39,23 +39,54 @@ export default async function UsersPage(props: UsersPageProps) { }; let hasInvitations = false; - const res = await internal - .get< - AxiosResponse - >(`/org/${params.orgId}/users?${searchParams.toString()}`, await authCookieHeader()) - .catch((e) => {}); + const cookieHeader = await authCookieHeader(); - if (res && res.status === 200) { - users = res.data.data.users; - pagination = res.data.data.pagination; + const [usersRes, idpsRes, rolesRes] = await Promise.all([ + internal + .get( + `/org/${params.orgId}/users?${searchParams.toString()}`, + cookieHeader + ) + .catch(() => {}), + internal + .get(`/org/${params.orgId}/idp?limit=500&offset=0`, cookieHeader) + .catch(() => {}), + internal + .get(`/org/${params.orgId}/roles?pageSize=500&page=1`, cookieHeader) + .catch(() => {}) + ]); + + if (usersRes && usersRes.status === 200) { + const list = usersRes.data.data as ListUsersResponse; + users = list.users; + pagination = list.pagination; } + const t = await getTranslations(); + + const orgIdps = + idpsRes && idpsRes.status === 200 ? (idpsRes.data.data.idps ?? []) : []; + const idpFilterOptions = [ + { value: "internal", label: t("idpNameInternal") }, + ...orgIdps.map((i: ListOrgIdpsResponse["idps"][number]) => ({ + value: String(i.idpId), + label: i.name + })) + ]; + + const orgRoles = + rolesRes && rolesRes.status === 200 + ? (rolesRes.data.data.roles ?? []) + : []; + const roleFilterOptions = orgRoles.map( + (r: ListRolesResponse["roles"][number]) => ({ + value: String(r.roleId), + label: r.name + }) + ); + const invitationsRes = await internal - .get< - AxiosResponse<{ - pagination: { total: number }; - }> - >( + .get( `/org/${params.orgId}/invitations?limit=1&offset=0`, await authCookieHeader() ) @@ -68,9 +99,7 @@ export default async function UsersPage(props: UsersPageProps) { let org: GetOrgResponse | null = null; const getOrg = cache(async () => internal - .get< - AxiosResponse - >(`/org/${params.orgId}`, await authCookieHeader()) + .get(`/org/${params.orgId}`, await authCookieHeader()) .catch((e) => { console.error(e); }) @@ -124,6 +153,8 @@ export default async function UsersPage(props: UsersPageProps) { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} + idpFilterOptions={idpFilterOptions} + roleFilterOptions={roleFilterOptions} /> diff --git a/src/components/ColumnMultiFilterButton.tsx b/src/components/ColumnMultiFilterButton.tsx new file mode 100644 index 000000000..ee386461d --- /dev/null +++ b/src/components/ColumnMultiFilterButton.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@app/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { CheckIcon, Funnel } from "lucide-react"; +import { cn } from "@app/lib/cn"; +import { Badge } from "./ui/badge"; + +type FilterOption = { + value: string; + label: string; +}; + +type ColumnMultiFilterButtonProps = { + options: FilterOption[]; + selectedValues: string[]; + onSelectedValuesChange: (values: string[]) => void; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; + label: string; +}; + +export function ColumnMultiFilterButton({ + options, + selectedValues, + onSelectedValuesChange, + searchPlaceholder = "Search...", + emptyMessage = "No options found", + className, + label +}: ColumnMultiFilterButtonProps) { + const [open, setOpen] = useState(false); + const t = useTranslations(); + + const selectedSet = useMemo( + () => new Set(selectedValues), + [selectedValues] + ); + + const summary = useMemo(() => { + if (selectedValues.length === 0) { + return null; + } + if (selectedValues.length === 1) { + return ( + options.find((o) => o.value === selectedValues[0])?.label ?? + selectedValues[0] + ); + } + return t("accessUsersRoleFilterCount", { + count: selectedValues.length + }); + }, [selectedValues, options, t]); + + function toggle(value: string) { + const next = selectedSet.has(value) + ? selectedValues.filter((v) => v !== value) + : [...selectedValues, value]; + onSelectedValuesChange(next); + } + + return ( + + + + + + + + + {emptyMessage} + + {selectedValues.length > 0 && ( + { + onSelectedValuesChange([]); + setOpen(false); + }} + className="text-muted-foreground" + > + {t("accessUsersRoleFilterClear")} + + )} + {options.map((option) => ( + { + toggle(option.value); + }} + > + + {option.label} + + ))} + + + + + + ); +} diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 60816ee9e..979c59425 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -2,7 +2,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -28,10 +27,16 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton"; import IdpTypeBadge from "./IdpTypeBadge"; -import { ControlledDataTable } from "./ui/controlled-data-table"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "./ui/controlled-data-table"; import UserRoleBadges from "./UserRoleBadges"; export type UserRow = { @@ -49,16 +54,22 @@ export type UserRow = { isOwner: boolean; }; +type FilterOption = { value: string; label: string }; + type UsersTableProps = { users: UserRow[]; pagination: PaginationState; rowCount: number; + idpFilterOptions: FilterOption[]; + roleFilterOptions: FilterOption[]; }; export default function UsersTable({ users, pagination, - rowCount + rowCount, + idpFilterOptions, + roleFilterOptions }: UsersTableProps) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); @@ -72,9 +83,48 @@ export default function UsersTable({ const { navigate: filter, isNavigating: isFiltering, - searchParams + searchParams, + pathname } = useNavigationContext(); + const idpIdParamSchema = z + .union([z.literal("internal"), z.string().regex(/^\d+$/)]) + .optional() + .catch(undefined); + + const roleIdsFromSearchParams = useMemo(() => { + const sp = new URLSearchParams(searchParams); + return [ + ...new Set(sp.getAll("role_id").filter((id) => /^\d+$/.test(id))) + ]; + }, [searchParams.toString()]); + + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } + + function handleRoleIdsChange(values: string[]) { + const sp = new URLSearchParams(searchParams); + sp.delete("role_id"); + sp.delete("page"); + for (const id of values) { + if (/^\d+$/.test(id)) { + sp.append("role_id", id); + } + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } + const refreshData = async () => { startTransition(async () => { try { @@ -118,8 +168,22 @@ export default function UsersTable({ { accessorKey: "idpName", friendlyName: t("identityProvider"), - header: ({ column }) => { - return {t("identityProvider")}; + header: () => { + return ( + + handleFilterChange("idp_id", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("identityProvider")} + className="p-3" + /> + ); }, cell: ({ row }) => { const userRow = row.original; @@ -136,8 +200,18 @@ export default function UsersTable({ id: "role", accessorFn: (row) => row.roleLabels.join(", "), friendlyName: t("role"), - header: ({ column }) => { - return {t("role")}; + header: () => { + return ( + + ); }, cell: ({ row }) => { return ;