From b648aa605c21b83fc701bc3b0264699914bc7634 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 18:50:09 +0100 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=94=A7=20un=20comment=20volumes=20i?= =?UTF-8?q?n=20docker=20compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.pgr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.pgr.yml b/docker-compose.pgr.yml index 9e6b2c5af..764c09150 100644 --- a/docker-compose.pgr.yml +++ b/docker-compose.pgr.yml @@ -7,8 +7,8 @@ services: POSTGRES_DB: postgres # Default database name POSTGRES_USER: postgres # Default user POSTGRES_PASSWORD: password # Default password (change for production!) - # volumes: - # - ./config/postgres:/var/lib/postgresql/data + volumes: + - ./config/postgres:/var/lib/postgresql/data ports: - "5432:5432" # Map host port 5432 to container port 5432 restart: no From 6d0e10a4aa799b9531f0f427a1fbb01b22968ce0 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 20:02:53 +0100 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=9A=A7=20=20user=20table=20paginati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/user/listUsers.ts | 127 +++++++++---- .../[orgId]/settings/access/users/page.tsx | 16 +- src/components/UsersTable.tsx | 168 +++++++++++------- 3 files changed, 205 insertions(+), 106 deletions(-) diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 40ca7ef2f..86fc9b770 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -5,33 +5,67 @@ import { idp, roles, userOrgs, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, sql } from "drizzle-orm"; +import { and, asc, desc, like, or, sql, type SQL } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { eq } from "drizzle-orm"; +import type { PaginatedResponse } from "@server/types/Pagination"; const listUsersParamsSchema = z.strictObject({ orgId: z.string() }); const listUsersSchema = z.strictObject({ - limit: z - .string() + pageSize: z.coerce + .number() // 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() // 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(["username"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["username"], + 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 queryUsers(orgId: string, limit: number, offset: number) { - return await db +function queryUsersBase() { + return db .select({ id: users.userId, email: users.email, @@ -54,16 +88,12 @@ async function queryUsers(orgId: string, limit: number, offset: number) { .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) - .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) - .where(eq(userOrgs.orgId, orgId)) - .limit(limit) - .offset(offset); + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)); } -export type ListUsersResponse = { - users: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; -}; +export type ListUsersResponse = PaginatedResponse<{ + users: NonNullable>>; +}>; registry.registerPath({ method: "get", @@ -92,7 +122,7 @@ export async function listUsers( ) ); } - const { limit, offset } = parsedQuery.data; + const { page, pageSize, sort_by, order, query } = parsedQuery.data; const parsedParams = listUsersParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -106,24 +136,57 @@ export async function listUsers( const { orgId } = parsedParams.data; - const usersWithRoles = await queryUsers( - orgId.toString(), - limit, - offset + const conditions = [and(eq(userOrgs.orgId, orgId))]; + + if (query) { + conditions.push( + or( + like( + sql`LOWER(${users.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${users.username})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${users.email})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); + } + + const countQuery = db.$count( + queryUsersBase() + .where(and(...conditions)) + .as("filtered_users") ); - const [{ count }] = await db - .select({ count: sql`count(*)` }) - .from(userOrgs) - .where(eq(userOrgs.orgId, orgId)); + const userListQuery = queryUsersBase() + .where(and(...conditions)) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(users[sort_by]) + : desc(users[sort_by]) + : asc(users.name) + ); + + const [count, usersWithRoles] = await Promise.all([ + countQuery, + userListQuery + ]); return response(res, { data: { users: usersWithRoles, pagination: { total: count, - limit, - offset + page, + pageSize } }, success: true, diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index c10363734..5297e747e 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -3,40 +3,46 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { ListUsersResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; -import UsersTable, { UserRow } from "../../../../../components/UsersTable"; +import UsersTable, { UserRow } from "@app/components/UsersTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; -import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; type UsersPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function UsersPage(props: UsersPageProps) { const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); - const getUser = cache(verifySession); - const user = await getUser(); + const user = await verifySession(); const t = await getTranslations(); let users: ListUsersResponse["users"] = []; + let pagination: ListUsersResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; let hasInvitations = false; const res = await internal .get< AxiosResponse - >(`/org/${params.orgId}/users`, await authCookieHeader()) + >(`/org/${params.orgId}/users?${searchParams.toString()}`, await authCookieHeader()) .catch((e) => {}); if (res && res.status === 200) { users = res.data.data.users; + pagination = res.data.data.pagination; } const invitationsRes = await internal diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 9b1dfee68..c6a02ab69 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; +import { ColumnDef, type PaginationState } from "@tanstack/react-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, @@ -9,14 +9,22 @@ import { DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react"; +import { + ArrowDown01Icon, + ArrowRight, + ArrowUp10Icon, + ArrowUpDown, + ChevronsUpDownIcon, + Crown, + MoreHorizontal +} from "lucide-react"; import { UsersDataTable } from "@app/components/UsersDataTable"; -import { useState, useEffect } from "react"; +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 { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; @@ -24,6 +32,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; 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; @@ -42,39 +55,44 @@ export type UserRow = { type UsersTableProps = { users: UserRow[]; + pagination: PaginationState; + rowCount: number; }; -export default function UsersTable({ users: u }: UsersTableProps) { +export default function UsersTable({ + users, + pagination, + rowCount +}: UsersTableProps) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const [users, setUsers] = useState(u); const router = useRouter(); const api = createApiClient(useEnvContext()); const { user, updateUser } = useUserContext(); const { org } = useOrgContext(); const t = useTranslations(); - const [isRefreshing, setIsRefreshing] = useState(false); - - // Update local state when props change (e.g., after refresh) - useEffect(() => { - setUsers(u); - }, [u]); + const [isNavigatingToAddPage, startNavigation] = useTransition(); + const [isRefreshing, startTransition] = useTransition(); + const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } + startTransition(async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); }; const columns: ExtendedColumnDef[] = [ @@ -83,15 +101,21 @@ export default function UsersTable({ users: u }: UsersTableProps) { enableHiding: false, friendlyName: t("username"), header: ({ column }) => { + const nameOrder = getSortDirection("username", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -100,17 +124,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { accessorKey: "idpName", friendlyName: t("identityProvider"), header: ({ column }) => { - return ( - - ); + return {t("identityProvider")}; }, cell: ({ row }) => { const userRow = row.original; @@ -127,17 +141,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { accessorKey: "role", friendlyName: t("role"), header: ({ column }) => { - return ( - - ); + return {t("role")}; }, cell: ({ row }) => { const userRow = row.original; @@ -184,9 +188,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { isDisabled && e.preventDefault() } > - + {t("accessUsersManage")} @@ -218,10 +220,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { - @@ -256,15 +255,36 @@ export default function UsersTable({ users: u }: UsersTableProps) { email: selectedUser.email || "" }) }); - - setUsers((prev) => - prev.filter((u) => u.id !== selectedUser?.id) - ); } } + router.refresh(); setIsDeleteModalOpen(false); } + 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 ( <> } buttonText={t("userRemoveOrgConfirm")} - onConfirm={removeUser} + onConfirm={async () => startTransition(removeUser)} string={ selectedUser ? getUserDisplayName({ @@ -293,12 +313,22 @@ export default function UsersTable({ users: u }: UsersTableProps) { title={t("userRemoveOrg")} /> - { - router.push( - `/${org?.org.orgId}/settings/access/users/create` + pagination={pagination} + rowCount={rowCount} + isNavigatingToAddPage={isNavigatingToAddPage} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + onPaginationChange={handlePaginationChange} + rows={users} + searchPlaceholder={t("accessUsersSearch")} + tableId="users-table" + onAdd={() => { + startNavigation(() => + router.push( + `/${org?.org.orgId}/settings/access/users/create` + ) ); }} onRefresh={refreshData} From 0461b5a764f8919392dd226f812220d05bd6ae5e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 21:09:49 +0100 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=A8=20finish=20users=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/access/users/page.tsx | 9 ++++++++- src/components/UsersTable.tsx | 5 ++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 5297e747e..d44082b13 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -107,7 +107,14 @@ export default async function UsersPage(props: UsersPageProps) { /> - + diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index c6a02ab69..ee376f8ba 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -68,12 +68,11 @@ export default function UsersTable({ const [selectedUser, setSelectedUser] = useState(null); const router = useRouter(); const api = createApiClient(useEnvContext()); - const { user, updateUser } = useUserContext(); + const { user } = useUserContext(); const { org } = useOrgContext(); const t = useTranslations(); const [isNavigatingToAddPage, startNavigation] = useTransition(); const [isRefreshing, startTransition] = useTransition(); - const pathname = usePathname(); const { navigate: filter, isNavigating: isFiltering, @@ -332,7 +331,7 @@ export default function UsersTable({ ); }} onRefresh={refreshData} - isRefreshing={isRefreshing} + isRefreshing={isRefreshing || isFiltering} /> ); From 062bec23b68c3d38b01ee9e05799e975eae0c5bd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 21:11:12 +0100 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=8C=90=20update=20translation=20for?= =?UTF-8?q?=20single=20user=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + src/components/UsersTable.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index dc84ec405..7107a00fd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -262,6 +262,7 @@ "orgMissing": "Organization ID Missing", "orgMissingMessage": "Unable to regenerate invitation without an organization ID.", "accessUsersManage": "Manage Users", + "accessUserManage": "Manage User", "accessUsersDescription": "Invite and manage users with access to this organization", "accessUsersSearch": "Search users...", "accessUserCreate": "Create User", diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index ee376f8ba..9da0aa1ca 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -188,7 +188,7 @@ export default function UsersTable({ } > - {t("accessUsersManage")} + {t("accessUserManage")} {!isDisabled && ( From 294532ecbb4757a360fa84e775974abfb12496b1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 21:34:44 +0100 Subject: [PATCH 05/10] =?UTF-8?q?=E2=9C=A8=20roles=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/role/listRoles.ts | 121 +++++++++++++----- .../[orgId]/settings/access/roles/page.tsx | 19 ++- src/components/RolesDataTable.tsx | 41 ------ src/components/RolesTable.tsx | 82 ++++++++++-- src/components/UsersDataTable.tsx | 41 ------ src/components/UsersTable.tsx | 35 +++-- 6 files changed, 187 insertions(+), 152 deletions(-) delete mode 100644 src/components/RolesDataTable.tsx delete mode 100644 src/components/UsersDataTable.tsx diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index f1b057a11..ba46e40c4 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -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() // 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() // 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>>; - pagination: { - total: number; - limit: number; - offset: number; - }; -}; +export type ListRolesResponse = PaginatedResponse<{ + roles: NonNullable>>; +}>; 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`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, diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index 7165d9e6c..a2d415be4 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -11,24 +11,32 @@ import { getCachedOrg } from "@app/lib/api/getCachedOrg"; type RolesPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; 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 - >(`/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")} /> - + ); diff --git a/src/components/RolesDataTable.tsx b/src/components/RolesDataTable.tsx deleted file mode 100644 index 5a2d1cb4c..000000000 --- a/src/components/RolesDataTable.tsx +++ /dev/null @@ -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 { - columns: ColumnDef[]; - data: TData[]; - createRole?: () => void; - onRefresh?: () => void; - isRefreshing?: boolean; -} - -export function RolesDataTable({ - columns, - data, - createRole, - onRefresh, - isRefreshing -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx index bf17f63f7..75a484ab0 100644 --- a/src/components/RolesTable.tsx +++ b/src/components/RolesTable.tsx @@ -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(null); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const router = useRouter(); + const [isRefreshing, startTransition] = useTransition(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [roleToRemove, setRoleToRemove] = useState(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 ( - ); } @@ -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) { /> )} - { + 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)} diff --git a/src/components/UsersDataTable.tsx b/src/components/UsersDataTable.tsx deleted file mode 100644 index ececa4c17..000000000 --- a/src/components/UsersDataTable.tsx +++ /dev/null @@ -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 { - columns: ColumnDef[]; - data: TData[]; - inviteUser?: () => void; - onRefresh?: () => void; - isRefreshing?: boolean; -} - -export function UsersDataTable({ - columns, - data, - inviteUser, - onRefresh, - isRefreshing -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 9da0aa1ca..163274877 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -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; From efb2e78d9d50eec9f8dfa8f23b7d0fb33c05d057 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 21:34:58 +0100 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=90=9B=20fix=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RolesTable.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx index 75a484ab0..9eb3c7a12 100644 --- a/src/components/RolesTable.tsx +++ b/src/components/RolesTable.tsx @@ -2,33 +2,31 @@ import CreateRoleForm from "@app/components/CreateRoleForm"; import DeleteRoleForm from "@app/components/DeleteRoleForm"; -import { RolesDataTable } from "@app/components/RolesDataTable"; import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { toast } from "@app/hooks/useToast"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { Role } from "@server/db"; +import type { PaginationState } from "@tanstack/react-table"; import { ArrowDown01Icon, ArrowUp10Icon, - ArrowUpDown, ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import EditRoleForm from "./EditRoleForm"; +import { ControlledDataTable } from "./ui/controlled-data-table"; import { DropdownMenu, - DropdownMenuTrigger, DropdownMenuContent, - DropdownMenuItem + DropdownMenuItem, + DropdownMenuTrigger } 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; From a4d8789c20593e48f902fae9a751996f8c1b72d1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 31 Mar 2026 21:13:23 +0200 Subject: [PATCH 07/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20move=20from=20rea?= =?UTF-8?q?ct.forwardref=20to=20normal=20ref=20prop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/tags/tag-input.tsx | 1319 ++++++++++++++--------------- 1 file changed, 645 insertions(+), 674 deletions(-) diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx index e8cfa370a..36a173911 100644 --- a/src/components/tags/tag-input.tsx +++ b/src/components/tags/tag-input.tsx @@ -1,8 +1,8 @@ "use client"; -import React from "react"; -import { Input } from "../ui/input"; -import { Button } from "../ui/button"; +import * as React from "react"; +import { Input } from "@app/components/ui/input"; +import { Button } from "@app/components/ui/button"; import { type VariantProps } from "class-variance-authority"; // import { CommandInput } from '../ui/command'; import { TagPopover } from "./tag-popover"; @@ -103,201 +103,89 @@ export interface TagInputProps addOnPaste?: boolean; addTagsOnBlur?: boolean; generateTagId?: () => string; + ref?: React.Ref; } -const TagInput = React.forwardRef( - (props, ref) => { - const { - id, - placeholder, - tags, - setTags, - variant, - size, - shape, - enableAutocomplete, - autocompleteOptions, - maxTags, - delimiter = Delimiter.Comma, - onTagAdd, - onTagRemove, - allowDuplicates, - showCount, - validateTag, - placeholderWhenFull = "Max tags reached", - sortTags, - delimiterList, - truncate, - autocompleteFilter, - borderStyle, - textCase, - interaction, - animation, - textStyle, - minLength, - maxLength, - direction = "row", - onInputChange, - customTagRenderer, - onFocus, - onBlur, - onTagClick, - draggable = false, - inputFieldPosition = "bottom", - clearAll = false, - onClearAll, - usePopoverForTags = false, - inputProps = {}, - restrictTagsToAutocompleteOptions, - inlineTags = true, - addTagsOnBlur = false, - activeTagIndex, - setActiveTagIndex, - styleClasses = {}, - disabled = false, - usePortal = false, - addOnPaste = false, - generateTagId = uuid - } = props; +export function TagInput({ ref, ...props }: TagInputProps) { + const { + id, + placeholder, + tags, + setTags, + variant, + size, + shape, + enableAutocomplete, + autocompleteOptions, + maxTags, + delimiter = Delimiter.Comma, + onTagAdd, + onTagRemove, + allowDuplicates, + showCount, + validateTag, + placeholderWhenFull = "Max tags reached", + sortTags, + delimiterList, + truncate, + autocompleteFilter, + borderStyle, + textCase, + interaction, + animation, + textStyle, + minLength, + maxLength, + direction = "row", + onInputChange, + customTagRenderer, + onFocus, + onBlur, + onTagClick, + draggable = false, + inputFieldPosition = "bottom", + clearAll = false, + onClearAll, + usePopoverForTags = false, + inputProps = {}, + restrictTagsToAutocompleteOptions, + inlineTags = true, + addTagsOnBlur = false, + activeTagIndex, + setActiveTagIndex, + styleClasses = {}, + disabled = false, + usePortal = false, + addOnPaste = false, + generateTagId = uuid + } = props; - const [inputValue, setInputValue] = React.useState(""); - const [tagCount, setTagCount] = React.useState( - Math.max(0, tags.length) - ); - const inputRef = React.useRef(null); + const [inputValue, setInputValue] = React.useState(""); + const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length)); + const inputRef = React.useRef(null); - const t = useTranslations(); + const t = useTranslations(); - if ( - (maxTags !== undefined && maxTags < 0) || - (props.minTags !== undefined && props.minTags < 0) - ) { - console.warn(t("tagsWarnCannotBeLessThanZero")); - // error - return null; - } + if ( + (maxTags !== undefined && maxTags < 0) || + (props.minTags !== undefined && props.minTags < 0) + ) { + console.warn(t("tagsWarnCannotBeLessThanZero")); + // error + return null; + } - const handleInputChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - if (addOnPaste && newValue.includes(delimiter)) { - const splitValues = newValue - .split(delimiter) - .map((v) => v.trim()) - .filter((v) => v); - splitValues.forEach((value) => { - if (!value) return; // Skip empty strings from split + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + if (addOnPaste && newValue.includes(delimiter)) { + const splitValues = newValue + .split(delimiter) + .map((v) => v.trim()) + .filter((v) => v); + splitValues.forEach((value) => { + if (!value) return; // Skip empty strings from split - const newTagText = value.trim(); - - // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true - if ( - restrictTagsToAutocompleteOptions && - !autocompleteOptions?.some( - (option) => option.text === newTagText - ) - ) { - console.warn( - t("tagsWarnNotAllowedAutocompleteOptions") - ); - return; - } - - if (validateTag && !validateTag(newTagText)) { - console.warn(t("tagsWarnInvalid")); - return; - } - - if (minLength && newTagText.length < minLength) { - console.warn( - t("tagWarnTooShort", { tagText: newTagText }) - ); - return; - } - - if (maxLength && newTagText.length > maxLength) { - console.warn( - t("tagWarnTooLong", { tagText: newTagText }) - ); - return; - } - - const newTagId = generateTagId(); - - // Add tag if duplicates are allowed or tag does not already exist - if ( - allowDuplicates || - !tags.some((tag) => tag.text === newTagText) - ) { - if (maxTags === undefined || tags.length < maxTags) { - // Check for maxTags limit - const newTag = { id: newTagId, text: newTagText }; - setTags((prevTags) => [...prevTags, newTag]); - onTagAdd?.(newTagText); - } else { - console.warn(t("tagsWarnReachedMaxNumber")); - } - } else { - console.warn( - t("tagWarnDuplicate", { tagText: newTagText }) - ); - } - }); - setInputValue(""); - } else { - setInputValue(newValue); - } - onInputChange?.(newValue); - }; - - const handleInputFocus = ( - event: React.FocusEvent - ) => { - setActiveTagIndex(null); // Reset active tag index when the input field gains focus - onFocus?.(event); - }; - - const handleInputBlur = (event: React.FocusEvent) => { - if (addTagsOnBlur && inputValue.trim()) { - const newTagText = inputValue.trim(); - - if (validateTag && !validateTag(newTagText)) { - return; - } - - if (minLength && newTagText.length < minLength) { - console.warn(t("tagWarnTooShort")); - return; - } - - if (maxLength && newTagText.length > maxLength) { - console.warn(t("tagWarnTooLong")); - return; - } - - if ( - (allowDuplicates || - !tags.some((tag) => tag.text === newTagText)) && - (maxTags === undefined || tags.length < maxTags) - ) { - const newTagId = generateTagId(); - setTags([...tags, { id: newTagId, text: newTagText }]); - onTagAdd?.(newTagText); - setTagCount((prevTagCount) => prevTagCount + 1); - setInputValue(""); - } - } - - onBlur?.(event); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if ( - delimiterList - ? delimiterList.includes(e.key) - : e.key === delimiter || e.key === Delimiter.Enter - ) { - e.preventDefault(); - const newTagText = inputValue.trim(); + const newTagText = value.trim(); // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true if ( @@ -306,189 +194,442 @@ const TagInput = React.forwardRef( (option) => option.text === newTagText ) ) { - // error + console.warn(t("tagsWarnNotAllowedAutocompleteOptions")); return; } if (validateTag && !validateTag(newTagText)) { + console.warn(t("tagsWarnInvalid")); return; } if (minLength && newTagText.length < minLength) { - console.warn(t("tagWarnTooShort")); - // error + console.warn(t("tagWarnTooShort", { tagText: newTagText })); return; } - // Validate maxLength if (maxLength && newTagText.length > maxLength) { - // error - console.warn(t("tagWarnTooLong")); + console.warn(t("tagWarnTooLong", { tagText: newTagText })); return; } const newTagId = generateTagId(); + // Add tag if duplicates are allowed or tag does not already exist if ( - newTagText && - (allowDuplicates || - !tags.some((tag) => tag.text === newTagText)) && - (maxTags === undefined || tags.length < maxTags) + allowDuplicates || + !tags.some((tag) => tag.text === newTagText) ) { - setTags([...tags, { id: newTagId, text: newTagText }]); - onTagAdd?.(newTagText); - setTagCount((prevTagCount) => prevTagCount + 1); + if (maxTags === undefined || tags.length < maxTags) { + // Check for maxTags limit + const newTag = { id: newTagId, text: newTagText }; + setTags((prevTags) => [...prevTags, newTag]); + onTagAdd?.(newTagText); + } else { + console.warn(t("tagsWarnReachedMaxNumber")); + } + } else { + console.warn( + t("tagWarnDuplicate", { tagText: newTagText }) + ); } - setInputValue(""); - } else { - switch (e.key) { - case "Delete": - if (activeTagIndex !== null) { - e.preventDefault(); - const newTags = [...tags]; - newTags.splice(activeTagIndex, 1); - setTags(newTags); - setActiveTagIndex((prev) => - newTags.length === 0 - ? null - : prev! >= newTags.length - ? newTags.length - 1 - : prev - ); - setTagCount((prevTagCount) => prevTagCount - 1); - onTagRemove?.(tags[activeTagIndex].text); - } - break; - case "Backspace": - if (activeTagIndex !== null) { - e.preventDefault(); - const newTags = [...tags]; - newTags.splice(activeTagIndex, 1); - setTags(newTags); - setActiveTagIndex((prev) => - prev! === 0 ? null : prev! - 1 - ); - setTagCount((prevTagCount) => prevTagCount - 1); - onTagRemove?.(tags[activeTagIndex].text); - } - break; - case "ArrowRight": - e.preventDefault(); - if (activeTagIndex === null) { - setActiveTagIndex(0); - } else { - setActiveTagIndex((prev) => - prev! + 1 >= tags.length ? 0 : prev! + 1 - ); - } - break; - case "ArrowLeft": - e.preventDefault(); - if (activeTagIndex === null) { - setActiveTagIndex(tags.length - 1); - } else { - setActiveTagIndex((prev) => - prev! === 0 ? tags.length - 1 : prev! - 1 - ); - } - break; - case "Home": - e.preventDefault(); - setActiveTagIndex(0); - break; - case "End": - e.preventDefault(); - setActiveTagIndex(tags.length - 1); - break; - } - } - }; - - const removeTag = (idToRemove: string) => { - setTags(tags.filter((tag) => tag.id !== idToRemove)); - onTagRemove?.( - tags.find((tag) => tag.id === idToRemove)?.text || "" - ); - setTagCount((prevTagCount) => prevTagCount - 1); - }; - - const onSortEnd = (oldIndex: number, newIndex: number) => { - setTags((currentTags) => { - const newTags = [...currentTags]; - const [removedTag] = newTags.splice(oldIndex, 1); - newTags.splice(newIndex, 0, removedTag); - - return newTags; }); - }; + setInputValue(""); + } else { + setInputValue(newValue); + } + onInputChange?.(newValue); + }; - const handleClearAll = () => { - if (!onClearAll) { - setActiveTagIndex(-1); - setTags([]); + const handleInputFocus = (event: React.FocusEvent) => { + setActiveTagIndex(null); // Reset active tag index when the input field gains focus + onFocus?.(event); + }; + + const handleInputBlur = (event: React.FocusEvent) => { + if (addTagsOnBlur && inputValue.trim()) { + const newTagText = inputValue.trim(); + + if (validateTag && !validateTag(newTagText)) { return; } - onClearAll?.(); - }; - // const filteredAutocompleteOptions = autocompleteFilter - // ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text)) - // : autocompleteOptions; - const displayedTags = sortTags ? [...tags].sort() : tags; + if (minLength && newTagText.length < minLength) { + console.warn(t("tagWarnTooShort")); + return; + } - const truncatedTags = truncate - ? tags.map((tag) => ({ - id: tag.id, - text: - tag.text?.length > truncate - ? `${tag.text.substring(0, truncate)}...` - : tag.text - })) - : displayedTags; + if (maxLength && newTagText.length > maxLength) { + console.warn(t("tagWarnTooLong")); + return; + } - return ( -
0 ? "gap-3" : ""} ${ - inputFieldPosition === "bottom" - ? "flex-col" - : inputFieldPosition === "top" - ? "flex-col-reverse" - : "flex-row" - }`} - > - {!usePopoverForTags && - (!inlineTags ? ( - - ) : ( - !enableAutocomplete && ( -
+ if ( + (allowDuplicates || + !tags.some((tag) => tag.text === newTagText)) && + (maxTags === undefined || tags.length < maxTags) + ) { + const newTagId = generateTagId(); + setTags([...tags, { id: newTagId, text: newTagText }]); + onTagAdd?.(newTagText); + setTagCount((prevTagCount) => prevTagCount + 1); + setInputValue(""); + } + } + + onBlur?.(event); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + delimiterList + ? delimiterList.includes(e.key) + : e.key === delimiter || e.key === Delimiter.Enter + ) { + e.preventDefault(); + const newTagText = inputValue.trim(); + + // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true + if ( + restrictTagsToAutocompleteOptions && + !autocompleteOptions?.some( + (option) => option.text === newTagText + ) + ) { + // error + return; + } + + if (validateTag && !validateTag(newTagText)) { + return; + } + + if (minLength && newTagText.length < minLength) { + console.warn(t("tagWarnTooShort")); + // error + return; + } + + // Validate maxLength + if (maxLength && newTagText.length > maxLength) { + // error + console.warn(t("tagWarnTooLong")); + return; + } + + const newTagId = generateTagId(); + + if ( + newTagText && + (allowDuplicates || + !tags.some((tag) => tag.text === newTagText)) && + (maxTags === undefined || tags.length < maxTags) + ) { + setTags([...tags, { id: newTagId, text: newTagText }]); + onTagAdd?.(newTagText); + setTagCount((prevTagCount) => prevTagCount + 1); + } + setInputValue(""); + } else { + switch (e.key) { + case "Delete": + if (activeTagIndex !== null) { + e.preventDefault(); + const newTags = [...tags]; + newTags.splice(activeTagIndex, 1); + setTags(newTags); + setActiveTagIndex((prev) => + newTags.length === 0 + ? null + : prev! >= newTags.length + ? newTags.length - 1 + : prev + ); + setTagCount((prevTagCount) => prevTagCount - 1); + onTagRemove?.(tags[activeTagIndex].text); + } + break; + case "Backspace": + if (activeTagIndex !== null) { + e.preventDefault(); + const newTags = [...tags]; + newTags.splice(activeTagIndex, 1); + setTags(newTags); + setActiveTagIndex((prev) => + prev! === 0 ? null : prev! - 1 + ); + setTagCount((prevTagCount) => prevTagCount - 1); + onTagRemove?.(tags[activeTagIndex].text); + } + break; + case "ArrowRight": + e.preventDefault(); + if (activeTagIndex === null) { + setActiveTagIndex(0); + } else { + setActiveTagIndex((prev) => + prev! + 1 >= tags.length ? 0 : prev! + 1 + ); + } + break; + case "ArrowLeft": + e.preventDefault(); + if (activeTagIndex === null) { + setActiveTagIndex(tags.length - 1); + } else { + setActiveTagIndex((prev) => + prev! === 0 ? tags.length - 1 : prev! - 1 + ); + } + break; + case "Home": + e.preventDefault(); + setActiveTagIndex(0); + break; + case "End": + e.preventDefault(); + setActiveTagIndex(tags.length - 1); + break; + } + } + }; + + const removeTag = (idToRemove: string) => { + setTags(tags.filter((tag) => tag.id !== idToRemove)); + onTagRemove?.(tags.find((tag) => tag.id === idToRemove)?.text || ""); + setTagCount((prevTagCount) => prevTagCount - 1); + }; + + const onSortEnd = (oldIndex: number, newIndex: number) => { + setTags((currentTags) => { + const newTags = [...currentTags]; + const [removedTag] = newTags.splice(oldIndex, 1); + newTags.splice(newIndex, 0, removedTag); + + return newTags; + }); + }; + + const handleClearAll = () => { + if (!onClearAll) { + setActiveTagIndex(-1); + setTags([]); + return; + } + onClearAll?.(); + }; + + // const filteredAutocompleteOptions = autocompleteFilter + // ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text)) + // : autocompleteOptions; + const displayedTags = sortTags ? [...tags].sort() : tags; + + const truncatedTags = truncate + ? tags.map((tag) => ({ + id: tag.id, + text: + tag.text?.length > truncate + ? `${tag.text.substring(0, truncate)}...` + : tag.text + })) + : displayedTags; + + return ( +
0 ? "gap-3" : ""} ${ + inputFieldPosition === "bottom" + ? "flex-col" + : inputFieldPosition === "top" + ? "flex-col-reverse" + : "flex-row" + }`} + > + {!usePopoverForTags && + (!inlineTags ? ( + + ) : ( + !enableAutocomplete && ( +
+
+ + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + className={cn( + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + // className, + styleClasses?.input + )} + autoComplete={ + enableAutocomplete ? "on" : "off" + } + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + /> +
+
+ ) + ))} + {enableAutocomplete ? ( +
+ + {!usePopoverForTags ? ( + !inlineTags ? ( + // = maxTags ? placeholderWhenFull : placeholder} + // ref={inputRef} + // value={inputValue} + // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} + // onChangeCapture={handleInputChange} + // onKeyDown={handleKeyDown} + // onFocus={handleInputFocus} + // onBlur={handleInputBlur} + // className={cn( + // 'w-full', + // // className, + // styleClasses?.input, + // )} + // /> + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + className={cn( + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + // className, + styleClasses?.input + )} + autoComplete={ + enableAutocomplete ? "on" : "off" + } + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + /> + ) : (
@@ -518,6 +659,22 @@ const TagInput = React.forwardRef( }} disabled={disabled} /> + {/* = maxTags ? placeholderWhenFull : placeholder} + ref={inputRef} + value={inputValue} + disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} + onChangeCapture={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + inlineTags={inlineTags} + className={cn( + 'border-0 flex-1 w-fit h-5', + // className, + styleClasses?.input, + )} + /> */} ( } />
-
- ) - ))} - {enableAutocomplete ? ( -
- - {!usePopoverForTags ? ( - !inlineTags ? ( - // = maxTags ? placeholderWhenFull : placeholder} - // ref={inputRef} - // value={inputValue} - // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} - // onChangeCapture={handleInputChange} - // onKeyDown={handleKeyDown} - // onFocus={handleInputFocus} - // onBlur={handleInputBlur} - // className={cn( - // 'w-full', - // // className, - // styleClasses?.input, - // )} - // /> - = maxTags - ? placeholderWhenFull - : placeholder - } - value={inputValue} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - {...inputProps} - className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", - // className, - styleClasses?.input - )} - autoComplete={ - enableAutocomplete ? "on" : "off" - } - list={ - enableAutocomplete - ? "autocomplete-options" - : undefined - } - disabled={ - disabled || - (maxTags !== undefined && - tags.length >= maxTags) - } - /> - ) : ( -
- - {/* = maxTags ? placeholderWhenFull : placeholder} - ref={inputRef} - value={inputValue} - disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} - onChangeCapture={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - inlineTags={inlineTags} - className={cn( - 'border-0 flex-1 w-fit h-5', - // className, - styleClasses?.input, - )} - /> */} - = maxTags - ? placeholderWhenFull - : placeholder - } - value={inputValue} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - {...inputProps} - className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", - // className, - styleClasses?.input - )} - autoComplete={ - enableAutocomplete - ? "on" - : "off" - } - list={ - enableAutocomplete - ? "autocomplete-options" - : undefined - } - disabled={ - disabled || - (maxTags !== undefined && - tags.length >= maxTags) - } - /> -
- ) - ) : ( - - {/* = maxTags ? placeholderWhenFull : placeholder} - ref={inputRef} - value={inputValue} - disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} - onChangeCapture={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - className={cn( - 'w-full', - // className, - styleClasses?.input, - )} - /> */} - = maxTags - ? placeholderWhenFull - : placeholder - } - value={inputValue} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - {...inputProps} - className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", - // className, - styleClasses?.input - )} - autoComplete={ - enableAutocomplete ? "on" : "off" - } - list={ - enableAutocomplete - ? "autocomplete-options" - : undefined - } - disabled={ - disabled || - (maxTags !== undefined && - tags.length >= maxTags) - } - /> - - )} -
-
- ) : ( -
- {!usePopoverForTags ? ( - !inlineTags ? ( - = maxTags - ? placeholderWhenFull - : placeholder - } - value={inputValue} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - {...inputProps} - className={cn( - styleClasses?.input, - "shadow-none inset-shadow-none" - // className - )} - autoComplete={ - enableAutocomplete ? "on" : "off" - } - list={ - enableAutocomplete - ? "autocomplete-options" - : undefined - } - disabled={ - disabled || - (maxTags !== undefined && - tags.length >= maxTags) - } - /> - ) : null + ) ) : ( ( }} disabled={disabled} > + {/* = maxTags ? placeholderWhenFull : placeholder} + ref={inputRef} + value={inputValue} + disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} + onChangeCapture={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + className={cn( + 'w-full', + // className, + styleClasses?.input, + )} + /> */} ( onFocus={handleInputFocus} onBlur={handleInputBlur} {...inputProps} + className={cn( + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + // className, + styleClasses?.input + )} autoComplete={ enableAutocomplete ? "on" : "off" } @@ -907,42 +787,133 @@ const TagInput = React.forwardRef( (maxTags !== undefined && tags.length >= maxTags) } - className={cn( - "border-0 w-full shadow-none inset-shadow-none", - styleClasses?.input - // className - )} /> )} -
- )} + +
+ ) : ( +
+ {!usePopoverForTags ? ( + !inlineTags ? ( + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + className={cn( + styleClasses?.input, + "shadow-none inset-shadow-none" + // className + )} + autoComplete={enableAutocomplete ? "on" : "off"} + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + /> + ) : null + ) : ( + + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + autoComplete={enableAutocomplete ? "on" : "off"} + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + className={cn( + "border-0 w-full shadow-none inset-shadow-none", + styleClasses?.input + // className + )} + /> + + )} +
+ )} - {showCount && maxTags && ( -
- - {`${tagCount}`}/{`${maxTags}`} - -
- )} - {clearAll && ( - - )} -
- ); - } -); - -TagInput.displayName = "TagInput"; + {showCount && maxTags && ( +
+ + {`${tagCount}`}/{`${maxTags}`} + +
+ )} + {clearAll && ( + + )} +
+ ); +} export function uuid() { return crypto.getRandomValues(new Uint32Array(1))[0].toString(); } - -export { TagInput }; From 543542713b0b17c93ee120d664c13225c7e427e4 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 31 Mar 2026 22:44:18 +0200 Subject: [PATCH 08/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/InternalResourceForm.tsx | 2 +- src/components/machines-selector.tsx | 120 +++++++++++++----------- src/components/multi-select-tags.tsx | 77 +++++++++++++++ src/components/tags/tag-input.tsx | 6 +- 4 files changed, 146 insertions(+), 59 deletions(-) create mode 100644 src/components/multi-select-tags.tsx diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 2de907079..3f83205a7 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -1063,7 +1063,7 @@ export function InternalResourceForm({ ] ) } - enableAutocomplete={true} + enableAutocomplete autocompleteOptions={ allRoles } diff --git a/src/components/machines-selector.tsx b/src/components/machines-selector.tsx index 9c31a0bd3..99515135e 100644 --- a/src/components/machines-selector.tsx +++ b/src/components/machines-selector.tsx @@ -3,18 +3,9 @@ import type { ListClientsResponse } from "@server/routers/client"; import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "./ui/command"; -import { cn } from "@app/lib/cn"; -import { CheckIcon } from "lucide-react"; import { useTranslations } from "next-intl"; +import { MultiSelectTags } from "./multi-select-tags"; export type SelectedMachine = Pick< ListClientsResponse["clients"][number], @@ -57,52 +48,71 @@ export function MachinesSelector({ return allMachines; }, [machines, selectedMachines, debouncedValue]); - const selectedMachinesIds = new Set( - selectedMachines.map((m) => m.clientId) - ); + // const selectedMachinesIds = new Set( + // selectedMachines.map((m) => m.clientId) + // ); return ( - - - - {t("machineNotFound")} - - {machinesShown.map((m) => ( - { - let newMachineClients = []; - if (selectedMachinesIds.has(m.clientId)) { - newMachineClients = selectedMachines.filter( - (mc) => mc.clientId !== m.clientId - ); - } else { - newMachineClients = [ - ...selectedMachines, - m - ]; - } - onSelectMachines(newMachineClients); - }} - > - - {`${m.name}`} - - ))} - - - + ({ + ...m, + text: m.name, + id: m.clientId.toString() + }))} + onChange={(values) => { + onSelectMachines(values); + }} + options={machinesShown.map((m) => ({ + ...m, + id: m.clientId.toString(), + text: m.name + }))} + onSearch={setMachineSearchQuery} + searchQuery={machineSearchQuery} + /> + // + // + // + // {t("machineNotFound")} + // + // {machinesShown.map((m) => ( + // { + // let newMachineClients = []; + // if (selectedMachinesIds.has(m.clientId)) { + // newMachineClients = selectedMachines.filter( + // (mc) => mc.clientId !== m.clientId + // ); + // } else { + // newMachineClients = [ + // ...selectedMachines, + // m + // ]; + // } + // onSelectMachines(newMachineClients); + // }} + // > + // + // {`${m.name}`} + // + // ))} + // + // + // ); } diff --git a/src/components/multi-select-tags.tsx b/src/components/multi-select-tags.tsx new file mode 100644 index 000000000..2fb9b097d --- /dev/null +++ b/src/components/multi-select-tags.tsx @@ -0,0 +1,77 @@ +import type { Ref } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { cn } from "@app/lib/cn"; +import { CheckIcon } from "lucide-react"; + +export type TagValue = { text: string; id: string }; + +export type MultiSelectTagsProps = { + emptyPlaceholder: string; + searchPlaceholder: string; + searchQuery?: string; + options: Array; + value: Array; + onChange: (newValue: Array) => void; + onSearch: (query: string) => void; + ref?: Ref; +}; + +export function MultiSelectTags({ + emptyPlaceholder, + searchPlaceholder, + searchQuery, + value, + options, + onSearch, + onChange +}: MultiSelectTagsProps) { + const selectedValues = new Set(value.map((v) => v.id)); + return ( + + + + {emptyPlaceholder} + + {options.map((option) => ( + { + let newValues = []; + if (selectedValues.has(option.id)) { + newValues = value.filter( + (v) => v.id !== option.id + ); + } else { + newValues = [...value, option]; + } + onChange(newValues); + }} + > + + {`${option.text}`} + + ))} + + + + ); +} diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx index 36a173911..fafd2144f 100644 --- a/src/components/tags/tag-input.tsx +++ b/src/components/tags/tag-input.tsx @@ -522,7 +522,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + "border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -692,7 +692,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + "border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -770,7 +770,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + "border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} From 6f06f98cc1aca797d4bcdc3380fa627b73434855 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 20 Apr 2026 21:51:53 -0700 Subject: [PATCH 09/10] add filter by idp and role in users table --- messages/en-US.json | 2 + server/routers/user/listUsers.ts | 109 ++++++++++++- .../[orgId]/settings/access/users/page.tsx | 67 +++++--- src/components/ColumnMultiFilterButton.tsx | 146 ++++++++++++++++++ src/components/UsersTable.tsx | 92 +++++++++-- 5 files changed, 386 insertions(+), 30 deletions(-) create mode 100644 src/components/ColumnMultiFilterButton.tsx 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 ; From 85f7c1e87b0c6bcecca4205d232e84d44ef969db Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 20 Apr 2026 22:05:29 -0700 Subject: [PATCH 10/10] support server side table for admin users table --- server/routers/user/adminListUsers.ts | 226 +++++++++++++--- src/app/admin/users/AdminUsersTable.tsx | 264 ------------------- src/app/admin/users/page.tsx | 73 +++++- src/components/AdminUsersDataTable.tsx | 37 --- src/components/AdminUsersTable.tsx | 325 +++++++++++++++--------- 5 files changed, 464 insertions(+), 461 deletions(-) delete mode 100644 src/app/admin/users/AdminUsersTable.tsx delete mode 100644 src/components/AdminUsersDataTable.tsx diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index 3a965259c..3d7bac4b3 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -1,31 +1,98 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, idp, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq } from "drizzle-orm"; +import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; import logger from "@server/logger"; -import { idp, users } from "@server/db"; 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 listUsersSchema = z.strictObject({ - limit: z - .string() + pageSize: z.coerce + .number() + .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() + .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(["username", "email", "name"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["username", "email", "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" + }), + 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' + }), + two_factor: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined) + .openapi({ + type: "boolean", + description: + "Filter by 2FA state matching: enabled if twoFactorEnabled or twoFactorSetupRequested" + }) }); -async function queryUsers(limit: number, offset: number) { - return await db +function queryUsersBase() { + return db .select({ id: users.userId, email: users.email, @@ -40,17 +107,39 @@ async function queryUsers(limit: number, offset: number) { twoFactorSetupRequested: users.twoFactorSetupRequested }) .from(users) - .leftJoin(idp, eq(users.idpId, idp.idpId)) - .where(eq(users.serverAdmin, false)) - .limit(limit) - .offset(offset); + .leftJoin(idp, eq(users.idpId, idp.idpId)); } -export type AdminListUsersResponse = { - users: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; +/** Row shape returned by `queryUsersBase()` (matches selected columns + join). */ +export type AdminListUserRow = { + id: string; + email: string | null; + username: string; + name: string | null; + dateCreated: string; + serverAdmin: boolean; + type: string; + idpName: string | null; + idpId: number | null; + twoFactorEnabled: boolean; + twoFactorSetupRequested: boolean | null; }; +export type AdminListUsersResponse = PaginatedResponse<{ + users: AdminListUserRow[]; +}>; + +registry.registerPath({ + method: "get", + path: "/users", + description: "List non–server-admin users (server admin).", + tags: [OpenAPITags.User], + request: { + query: listUsersSchema + }, + responses: {} +}); + export async function adminListUsers( req: Request, res: Response, @@ -66,21 +155,96 @@ export async function adminListUsers( ) ); } - const { limit, offset } = parsedQuery.data; + const { + page, + pageSize, + query, + sort_by, + order, + idp_id, + two_factor: twoFactorFilter + } = parsedQuery.data; - const allUsers = await queryUsers(limit, offset); + if (typeof idp_id === "number") { + const idpOk = await db + .select({ one: sql`1` }) + .from(idp) + .where(eq(idp.idpId, idp_id)) + .limit(1); + if (idpOk.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "idp_id does not exist" + ) + ); + } + } - const [{ count }] = await db - .select({ count: sql`count(*)` }) - .from(users); + const conditions = [eq(users.serverAdmin, false)]; + + if (query) { + const q = "%" + query.toLowerCase() + "%"; + conditions.push( + or( + like(sql`LOWER(${users.username})`, q), + like(sql`LOWER(${users.email})`, q), + like(sql`LOWER(${users.name})`, q) + )! + ); + } + + 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 (typeof twoFactorFilter === "boolean") { + if (twoFactorFilter) { + conditions.push( + or( + eq(users.twoFactorEnabled, true), + eq(users.twoFactorSetupRequested, true) + )! + ); + } else { + conditions.push( + and( + eq(users.twoFactorEnabled, false), + eq(users.twoFactorSetupRequested, false) + )! + ); + } + } + + const whereClause = and(...conditions); + + const countQuery = db.$count( + queryUsersBase().where(whereClause).as("filtered_admin_users") + ); + + const userListQuery = queryUsersBase() + .where(whereClause) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(users[sort_by]) + : desc(users[sort_by]) + : asc(users.username) + ); + + const [total, rows] = await Promise.all([countQuery, userListQuery]); return response(res, { data: { - users: allUsers, + users: rows, pagination: { - total: count, - limit, - offset + total, + page, + pageSize } }, success: true, diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx deleted file mode 100644 index 1c7d1b7fd..000000000 --- a/src/app/admin/users/AdminUsersTable.tsx +++ /dev/null @@ -1,264 +0,0 @@ -"use client"; - -import { UsersDataTable } from "@app/components/AdminUsersDataTable"; -import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useTranslations } from "next-intl"; -import { - DropdownMenu, - DropdownMenuItem, - DropdownMenuContent, - DropdownMenuTrigger -} from "@app/components/ui/dropdown-menu"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; - -export type GlobalUserRow = { - id: string; - name: string | null; - username: string; - email: string | null; - type: string; - idpId: number | null; - idpName: string; - dateCreated: string; - twoFactorEnabled: boolean | null; - twoFactorSetupRequested: boolean | null; -}; - -type Props = { - users: GlobalUserRow[]; -}; - -export default function UsersTable({ users }: Props) { - const router = useRouter(); - const t = useTranslations(); - - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selected, setSelected] = useState(null); - const [rows, setRows] = useState(users); - - const api = createApiClient(useEnvContext()); - - const deleteUser = (id: string) => { - api.delete(`/user/${id}`) - .catch((e) => { - console.error(t("userErrorDelete"), e); - toast({ - variant: "destructive", - title: t("userErrorDelete"), - description: formatAxiosError(e, t("userErrorDelete")) - }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== id); - - setRows(newRows); - }); - }; - - const columns: ExtendedColumnDef[] = [ - { - accessorKey: "id", - friendlyName: "ID", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "username", - friendlyName: t("username"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "email", - friendlyName: t("email"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "name", - friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "idpName", - friendlyName: t("identityProvider"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "twoFactorEnabled", - friendlyName: t("twoFactor"), - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const userRow = row.original; - - return ( -
- - {userRow.twoFactorEnabled || - userRow.twoFactorSetupRequested ? ( - - {t("enabled")} - - ) : ( - {t("disabled")} - )} - -
- ); - } - }, - { - id: "actions", - header: () => {t("actions")}, - cell: ({ row }) => { - const r = row.original; - return ( - <> -
- - - - - - - { - setSelected(r); - setIsDeleteModalOpen(true); - }} - > - {t("delete")} - - - -
- - ); - } - } - ]; - - return ( - <> - {selected && ( - { - setIsDeleteModalOpen(val); - setSelected(null); - }} - dialog={ -
-

{t("userQuestionRemove")}

- -

{t("userMessageRemove")}

-
- } - buttonText={t("userDeleteConfirm")} - onConfirm={async () => deleteUser(selected!.id)} - string={ - selected.email || selected.name || selected.username - } - title={t("userDeleteServer")} - /> - )} - - - - ); -} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 2a000b34b..0cfaaf3b0 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,33 +1,70 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { AdminListUsersResponse } from "@server/routers/user/adminListUsers"; +import type { AdminListUsersResponse } from "@server/routers/user/adminListUsers"; +import type { ListIdpsResponse } from "@server/routers/idp/listIdps"; import UsersTable, { GlobalUserRow } from "@app/components/AdminUsersTable"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -type PageProps = { - params: Promise<{ orgId: string }>; +/** API JSON body shape for `response()` handlers (see `server/lib/response.ts`). */ +type ApiPayload = { + data: T; + success: boolean; + error: boolean; + message: string; + status: number; +}; + +type AdminUsersPageProps = { + searchParams: Promise>; }; export const dynamic = "force-dynamic"; -export default async function UsersPage(props: PageProps) { +export default async function UsersPage(props: AdminUsersPageProps) { + const searchParams = new URLSearchParams(await props.searchParams); + const cookieHeader = await authCookieHeader(); + let rows: AdminListUsersResponse["users"] = []; - try { - const res = await internal.get>( - `/users`, - await authCookieHeader() - ); - rows = res.data.data.users; - } catch (e) { - console.error(e); + let pagination: AdminListUsersResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + const [usersRes, idpsRes] = await Promise.all([ + internal + .get< + ApiPayload + >(`/users?${searchParams.toString()}`, cookieHeader) + .catch(() => {}), + internal + .get< + ApiPayload + >(`/idp?limit=500&offset=0`, cookieHeader) + .catch(() => {}) + ]); + + if (usersRes && usersRes.status === 200) { + const list = usersRes.data.data; + rows = list.users; + pagination = list.pagination; } const t = await getTranslations(); + const globalIdps = + idpsRes && idpsRes.status === 200 ? (idpsRes.data.data.idps ?? []) : []; + const idpFilterOptions = [ + { value: "internal", label: t("idpNameInternal") }, + ...globalIdps.map((i: ListIdpsResponse["idps"][number]) => ({ + value: String(i.idpId), + label: i.name + })) + ]; + const userRows: GlobalUserRow[] = rows.map((row) => { return { id: row.id, @@ -59,7 +96,15 @@ export default async function UsersPage(props: PageProps) { {t("userAbountDescription")} - + ); } diff --git a/src/components/AdminUsersDataTable.tsx b/src/components/AdminUsersDataTable.tsx deleted file mode 100644 index afa473e86..000000000 --- a/src/components/AdminUsersDataTable.tsx +++ /dev/null @@ -1,37 +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 { - columns: ColumnDef[]; - data: TData[]; - onRefresh?: () => void; - isRefreshing?: boolean; -} - -export function UsersDataTable({ - columns, - data, - onRefresh, - isRefreshing -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx index 09797a2e2..eabb6b468 100644 --- a/src/components/AdminUsersTable.tsx +++ b/src/components/AdminUsersTable.tsx @@ -1,19 +1,31 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; -import { UsersDataTable } from "@app/components/AdminUsersDataTable"; -import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState, useEffect } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; +import { Button } from "@app/components/ui/button"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +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, + ChevronsUpDownIcon, + MoreHorizontal +} from "lucide-react"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; import { DropdownMenu, DropdownMenuItem, @@ -31,7 +43,6 @@ import { CredenzaClose } from "@app/components/Credenza"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import { AxiosResponse } from "axios"; export type GlobalUserRow = { id: string; @@ -44,10 +55,16 @@ export type GlobalUserRow = { dateCreated: string; twoFactorEnabled: boolean | null; twoFactorSetupRequested: boolean | null; + serverAdmin?: boolean; }; +type FilterOption = { value: string; label: string }; + type Props = { users: GlobalUserRow[]; + pagination: PaginationState; + rowCount: number; + idpFilterOptions: FilterOption[]; }; type AdminGeneratePasswordResetCodeResponse = { @@ -56,74 +73,103 @@ type AdminGeneratePasswordResetCodeResponse = { url: string; }; -export default function UsersTable({ users }: Props) { +export default function UsersTable({ + users, + pagination, + rowCount, + idpFilterOptions +}: Props) { const router = useRouter(); const t = useTranslations(); + const api = createApiClient(useEnvContext()); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selected, setSelected] = useState(null); - const [rows, setRows] = useState(users); - - const api = createApiClient(useEnvContext()); - - const [isRefreshing, setIsRefreshing] = useState(false); const [isPasswordResetCodeDialogOpen, setIsPasswordResetCodeDialogOpen] = useState(false); const [passwordResetCodeData, setPasswordResetCodeData] = useState(null); const [isGeneratingCode, setIsGeneratingCode] = useState(false); - // Update local state when props change (e.g., after refresh) - useEffect(() => { - setRows(users); - }, [users]); + const [isRefreshing, startTransition] = useTransition(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams, + pathname + } = useNavigationContext(); + + const idpIdParamSchema = z + .union([z.literal("internal"), z.string().regex(/^\d+$/)]) + .optional() + .catch(undefined); + + const twoFactorFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + + 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()}`)); + } const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } + startTransition(async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); }; const deleteUser = (id: string) => { - api.delete(`/user/${id}`) - .catch((e) => { - console.error(t("userErrorDelete"), e); - toast({ - variant: "destructive", - title: t("userErrorDelete"), - description: formatAxiosError(e, t("userErrorDelete")) + startTransition(() => { + void api + .delete(`/user/${id}`) + .catch((e) => { + console.error(t("userErrorDelete"), e); + toast({ + variant: "destructive", + title: t("userErrorDelete"), + description: formatAxiosError(e, t("userErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + setSelected(null); }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== id); - - setRows(newRows); - }); + }); }; const generatePasswordResetCode = async (userId: string) => { setIsGeneratingCode(true); try { - const res = await api.post< - AxiosResponse - >(`/user/${userId}/generate-password-reset-code`); + const res = await api.post( + `/user/${userId}/generate-password-reset-code` + ); - if (res.data?.data) { - setPasswordResetCodeData(res.data.data); + const envelope = res.data as { + data?: AdminGeneratePasswordResetCodeResponse; + }; + if (envelope?.data) { + setPasswordResetCodeData(envelope.data); setIsPasswordResetCodeDialogOpen(true); } } catch (e) { @@ -138,37 +184,55 @@ export default function UsersTable({ users }: Props) { } }; + 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); + const columns: ExtendedColumnDef[] = [ { accessorKey: "id", friendlyName: "ID", - header: ({ column }) => { - return ( - - ); - } + header: () => ID }, { accessorKey: "username", enableHiding: false, friendlyName: t("username"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("username", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -176,16 +240,22 @@ export default function UsersTable({ users }: Props) { { accessorKey: "email", friendlyName: t("email"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("email", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -193,16 +263,22 @@ export default function UsersTable({ users }: Props) { { accessorKey: "name", friendlyName: t("name"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("name", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -210,39 +286,45 @@ export default function UsersTable({ users }: Props) { { accessorKey: "idpName", friendlyName: t("identityProvider"), - header: ({ column }) => { - return ( - - ); - } + header: () => ( + + handleFilterChange("idp_id", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("identityProvider")} + className="p-3" + /> + ) }, { accessorKey: "twoFactorEnabled", friendlyName: t("twoFactor"), - header: ({ column }) => { - return ( - - ); - }, + header: () => ( + + handleFilterChange("two_factor", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("twoFactor")} + className="p-3" + /> + ), cell: ({ row }) => { const userRow = row.original; - return (
@@ -277,8 +359,11 @@ export default function UsersTable({ users }: Props) { {r.type === "internal" && ( { - generatePasswordResetCode(r.id); + void generatePasswordResetCode( + r.id + ); }} > {t("generatePasswordResetCode")} @@ -350,11 +435,21 @@ export default function UsersTable({ users }: Props) { /> )} -