"use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; 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, DropdownMenuContent, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { Credenza, CredenzaContent, CredenzaDescription, CredenzaHeader, CredenzaTitle, CredenzaBody, CredenzaFooter, CredenzaClose } from "@app/components/Credenza"; import CopyToClipboard from "@app/components/CopyToClipboard"; 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; serverAdmin?: boolean; }; type FilterOption = { value: string; label: string }; type Props = { users: GlobalUserRow[]; pagination: PaginationState; rowCount: number; idpFilterOptions: FilterOption[]; }; type AdminGeneratePasswordResetCodeResponse = { token: string; email: string; url: string; }; 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 [isPasswordResetCodeDialogOpen, setIsPasswordResetCodeDialogOpen] = useState(false); const [passwordResetCodeData, setPasswordResetCodeData] = useState(null); const [isGeneratingCode, setIsGeneratingCode] = useState(false); 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 () => { 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) => { 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); }); }); }; const generatePasswordResetCode = async (userId: string) => { setIsGeneratingCode(true); try { const res = await api.post( `/user/${userId}/generate-password-reset-code` ); const envelope = res.data as { data?: AdminGeneratePasswordResetCodeResponse; }; if (envelope?.data) { setPasswordResetCodeData(envelope.data); setIsPasswordResetCodeDialogOpen(true); } } catch (e) { console.error("Failed to generate password reset code", e); toast({ variant: "destructive", title: t("error"), description: formatAxiosError(e, t("errorOccurred")) }); } finally { setIsGeneratingCode(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); const columns: ExtendedColumnDef[] = [ { accessorKey: "id", friendlyName: "ID", header: () => ID }, { accessorKey: "username", enableHiding: false, friendlyName: t("username"), header: () => { const sortOrder = getSortDirection("username", searchParams); const Icon = sortOrder === "asc" ? ArrowDown01Icon : sortOrder === "desc" ? ArrowUp10Icon : ChevronsUpDownIcon; return ( ); } }, { accessorKey: "email", friendlyName: t("email"), header: () => { const sortOrder = getSortDirection("email", searchParams); const Icon = sortOrder === "asc" ? ArrowDown01Icon : sortOrder === "desc" ? ArrowUp10Icon : ChevronsUpDownIcon; return ( ); } }, { accessorKey: "name", friendlyName: t("name"), header: () => { const sortOrder = getSortDirection("name", searchParams); const Icon = sortOrder === "asc" ? ArrowDown01Icon : sortOrder === "desc" ? ArrowUp10Icon : ChevronsUpDownIcon; return ( ); } }, { accessorKey: "idpName", friendlyName: t("identityProvider"), header: () => ( handleFilterChange("idp_id", value) } searchPlaceholder={t("searchPlaceholder")} emptyMessage={t("emptySearchOptions")} label={t("identityProvider")} className="p-3" /> ) }, { accessorKey: "twoFactorEnabled", friendlyName: t("twoFactor"), header: () => ( handleFilterChange("two_factor", value) } searchPlaceholder={t("searchPlaceholder")} emptyMessage={t("emptySearchOptions")} label={t("twoFactor")} className="p-3" /> ), cell: ({ row }) => { const userRow = row.original; return (
{userRow.twoFactorEnabled || userRow.twoFactorSetupRequested ? ( {t("enabled")} ) : ( {t("disabled")} )}
); } }, { id: "actions", enableHiding: false, header: () => , cell: ({ row }) => { const r = row.original; return (
{r.type === "internal" && ( { void generatePasswordResetCode( r.id ); }} > {t("generatePasswordResetCode")} )} { setSelected(r); setIsDeleteModalOpen(true); }} > {t("delete")}
); } } ]; return ( <> {selected && ( { setIsDeleteModalOpen(val); setSelected(null); }} dialog={

{t("userQuestionRemove", { selectedUser: selected ? getUserDisplayName({ email: selected.email, name: selected.name, username: selected.username }) : "" })}

{t("userMessageRemove")}

{t("userMessageConfirm")}

} buttonText={t("userDeleteConfirm")} onConfirm={async () => deleteUser(selected!.id)} string={getUserDisplayName({ email: selected.email, name: selected.name, username: selected.username })} title={t("userDeleteServer")} /> )} {t("passwordResetCodeGenerated")} {t("passwordResetCodeGeneratedDescription")} {passwordResetCodeData && (
)}
); }