diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx new file mode 100644 index 000000000..3b098c2fd --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useTransition } from 'react'; + +import Link from 'next/link'; + +import { Loader } from 'lucide-react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { FindResultSet } from '@documenso/lib/types/find-result-set'; +import { Document, User } from '@documenso/prisma/client'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; + +import { DocumentStatus } from '~/components/formatter/document-status'; +import { LocaleDate } from '~/components/formatter/locale-date'; + +export type DocumentsDataTableProps = { + results: FindResultSet< + Document & { + User: Pick; + } + >; +}; + +export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { + const [isPending, startTransition] = useTransition(); + + const updateSearchParams = useUpdateSearchParams(); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + return ( +
+ , + }, + { + header: 'Title', + accessorKey: 'title', + cell: ({ row }) => { + return
{row.original.title}
; + }, + }, + { + header: 'Owner', + accessorKey: 'owner', + cell: ({ row }) => { + return ( + + + + {row.original.User.name} + + + + ); + }, + }, + { + header: 'Last updated', + accessorKey: 'updatedAt', + cell: ({ row }) => , + }, + { + header: 'Status', + accessorKey: 'status', + cell: ({ row }) => , + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + > + {(table) => } +
+ + {isPending && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/page.tsx new file mode 100644 index 000000000..2fbbcd4dc --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/page.tsx @@ -0,0 +1,29 @@ +import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; + +import { DocumentsDataTable } from './data-table'; + +export type DocumentsPageProps = { + searchParams?: { + page?: string; + perPage?: string; + }; +}; + +export default async function Documents({ searchParams = {} }: DocumentsPageProps) { + const page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 20; + + const results = await findDocuments({ + page, + perPage, + }); + + return ( +
+

Manage documents

+
+ +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/nav.tsx b/apps/web/src/app/(dashboard)/admin/nav.tsx index 3b87a9b13..8050f867a 100644 --- a/apps/web/src/app/(dashboard)/admin/nav.tsx +++ b/apps/web/src/app/(dashboard)/admin/nav.tsx @@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { BarChart3, User2 } from 'lucide-react'; +import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -37,10 +37,40 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => { 'justify-start md:w-full', pathname?.startsWith('/admin/users') && 'bg-secondary', )} - disabled + asChild > - - Users (Coming Soon) + + + Users + + + + + + ); diff --git a/apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx b/apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx new file mode 100644 index 000000000..68ccf1ee4 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx @@ -0,0 +1,65 @@ +import Link from 'next/link'; + +import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; + +export default async function Subscriptions() { + const subscriptions = await findSubscriptions(); + + return ( +
+

Manage subscriptions

+
+ + + + ID + Status + Created At + Ends On + User ID + + + + {subscriptions.map((subscription, index) => ( + + {subscription.id} + {subscription.status} + + {subscription.createdAt + ? new Date(subscription.createdAt).toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'N/A'} + + + {subscription.periodEnd + ? new Date(subscription.periodEnd).toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'N/A'} + + + {subscription.userId} + + + ))} + +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx new file mode 100644 index 000000000..790177c8a --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Combobox } from '@documenso/ui/primitives/combobox'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { TUserFormSchema, ZUserFormSchema } from '~/providers/admin-user-profile-update.types'; + +export default function UserPage({ params }: { params: { id: number } }) { + const { toast } = useToast(); + const router = useRouter(); + + const { data: user } = trpc.profile.getUser.useQuery( + { + id: Number(params.id), + }, + { + enabled: !!params.id, + }, + ); + + const roles = user?.roles ?? []; + + const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZUserFormSchema), + values: { + name: user?.name ?? '', + email: user?.email ?? '', + roles: user?.roles ?? [], + }, + }); + + const onSubmit = async ({ name, email, roles }: TUserFormSchema) => { + try { + await updateUserMutation({ + id: Number(user?.id), + name, + email, + roles, + }); + + router.refresh(); + + toast({ + title: 'Profile updated', + description: 'Your profile has been updated.', + duration: 5000, + }); + } catch (e) { + toast({ + title: 'Error', + description: 'An error occurred while updating your profile.', + variant: 'destructive', + }); + } + }; + + return ( +
+

Manage {user?.name}'s profile

+
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + + ( + +
+ Roles + + onChange(values)} + /> + + +
+
+ )} + /> + +
+ +
+
+
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx new file mode 100644 index 000000000..1840f5a44 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useEffect, useState, useTransition } from 'react'; + +import Link from 'next/link'; + +import { Edit, Loader } from 'lucide-react'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { Document, Role, Subscription } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Input } from '@documenso/ui/primitives/input'; + +interface User { + id: number; + name: string | null; + email: string; + roles: Role[]; + Subscription: SubscriptionLite[]; + Document: DocumentLite[]; +} + +type SubscriptionLite = Pick< + Subscription, + 'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd' +>; + +type DocumentLite = Pick; + +type UsersDataTableProps = { + users: User[]; + totalPages: number; + perPage: number; + page: number; +}; + +export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => { + const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); + const [searchString, setSearchString] = useState(''); + const debouncedSearchString = useDebouncedValue(searchString, 1000); + + useEffect(() => { + startTransition(() => { + updateSearchParams({ + search: debouncedSearchString, + page: 1, + perPage, + }); + }); + }, [debouncedSearchString]); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + const handleChange = (e: React.ChangeEvent) => { + setSearchString(e.target.value); + }; + + return ( +
+ +
{row.original.id}
, + }, + { + header: 'Name', + accessorKey: 'name', + cell: ({ row }) =>
{row.original.name}
, + }, + { + header: 'Email', + accessorKey: 'email', + cell: ({ row }) =>
{row.original.email}
, + }, + { + header: 'Roles', + accessorKey: 'roles', + cell: ({ row }) => row.original.roles.join(', '), + }, + { + header: 'Subscription', + accessorKey: 'subscription', + cell: ({ row }) => { + if (row.original.Subscription && row.original.Subscription.length > 0) { + return ( + <> + {row.original.Subscription.map((subscription: SubscriptionLite, i: number) => { + return {subscription.status}; + })} + + ); + } else { + return NONE; + } + }, + }, + { + header: 'Documents', + accessorKey: 'documents', + cell: ({ row }) => { + return
{row.original.Document.length}
; + }, + }, + { + header: '', + accessorKey: 'edit', + cell: ({ row }) => { + return ( + + ); + }, + }, + ]} + data={users} + perPage={perPage} + currentPage={page} + totalPages={totalPages} + onPaginationChange={onPaginationChange} + > + {(table) => } +
+ + {isPending && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/users/fetch-users.actions.ts b/apps/web/src/app/(dashboard)/admin/users/fetch-users.actions.ts new file mode 100644 index 000000000..335f32e08 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/fetch-users.actions.ts @@ -0,0 +1,9 @@ +'use server'; + +import { findUsers } from '@documenso/lib/server-only/user/get-all-users'; + +export async function search(search: string, page: number, perPage: number) { + const results = await findUsers({ username: search, email: search, page, perPage }); + + return results; +} diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx new file mode 100644 index 000000000..686ce7669 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -0,0 +1,25 @@ +import { UsersDataTable } from './data-table-users'; +import { search } from './fetch-users.actions'; + +type AdminManageUsersProps = { + searchParams?: { + search?: string; + page?: number; + perPage?: number; + }; +}; + +export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) { + const page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 10; + const searchString = searchParams.search || ''; + + const { users, totalPages } = await search(searchString, page, perPage); + + return ( +
+

Manage users

+ +
+ ); +} diff --git a/apps/web/src/providers/admin-user-profile-update.types.ts b/apps/web/src/providers/admin-user-profile-update.types.ts new file mode 100644 index 000000000..49bda22fc --- /dev/null +++ b/apps/web/src/providers/admin-user-profile-update.types.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; + +export const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); +export type TUserFormSchema = z.infer; diff --git a/packages/lib/server-only/admin/get-all-documents.ts b/packages/lib/server-only/admin/get-all-documents.ts new file mode 100644 index 000000000..cca1935a3 --- /dev/null +++ b/packages/lib/server-only/admin/get-all-documents.ts @@ -0,0 +1,55 @@ +import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; + +export interface FindDocumentsOptions { + term?: string; + page?: number; + perPage?: number; +} + +export const findDocuments = async ({ term, page = 1, perPage = 10 }: FindDocumentsOptions) => { + const termFilters: Prisma.DocumentWhereInput | undefined = !term + ? undefined + : { + title: { + contains: term, + mode: 'insensitive', + }, + }; + + const [data, count] = await Promise.all([ + prisma.document.findMany({ + where: { + ...termFilters, + }, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + createdAt: 'desc', + }, + include: { + User: { + select: { + id: true, + name: true, + email: true, + }, + }, + Recipient: true, + }, + }), + prisma.document.count({ + where: { + ...termFilters, + }, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/lib/server-only/admin/get-all-subscriptions.ts b/packages/lib/server-only/admin/get-all-subscriptions.ts new file mode 100644 index 000000000..5080c4c22 --- /dev/null +++ b/packages/lib/server-only/admin/get-all-subscriptions.ts @@ -0,0 +1,13 @@ +import { prisma } from '@documenso/prisma'; + +export const findSubscriptions = async () => { + return prisma.subscription.findMany({ + select: { + id: true, + status: true, + createdAt: true, + periodEnd: true, + userId: true, + }, + }); +}; diff --git a/packages/lib/server-only/admin/update-user.ts b/packages/lib/server-only/admin/update-user.ts new file mode 100644 index 000000000..9013899a7 --- /dev/null +++ b/packages/lib/server-only/admin/update-user.ts @@ -0,0 +1,28 @@ +import { prisma } from '@documenso/prisma'; +import { Role } from '@documenso/prisma/client'; + +export type UpdateUserOptions = { + id: number; + name: string | null | undefined; + email: string | undefined; + roles: Role[] | undefined; +}; + +export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) => { + await prisma.user.findFirstOrThrow({ + where: { + id, + }, + }); + + return await prisma.user.update({ + where: { + id, + }, + data: { + name, + email, + roles, + }, + }); +}; diff --git a/packages/lib/server-only/user/get-all-users.ts b/packages/lib/server-only/user/get-all-users.ts new file mode 100644 index 000000000..71e670e7d --- /dev/null +++ b/packages/lib/server-only/user/get-all-users.ts @@ -0,0 +1,57 @@ +import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; + +type GetAllUsersProps = { + username: string; + email: string; + page: number; + perPage: number; +}; + +export const findUsers = async ({ + username = '', + email = '', + page = 1, + perPage = 10, +}: GetAllUsersProps) => { + const whereClause = Prisma.validator()({ + OR: [ + { + name: { + contains: username, + mode: 'insensitive', + }, + }, + { + email: { + contains: email, + mode: 'insensitive', + }, + }, + ], + }); + + const [users, count] = await Promise.all([ + await prisma.user.findMany({ + include: { + Subscription: true, + Document: { + select: { + id: true, + }, + }, + }, + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + }), + await prisma.user.count({ + where: whereClause, + }), + ]); + + return { + users, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts new file mode 100644 index 000000000..666e3f085 --- /dev/null +++ b/packages/trpc/server/admin-router/router.ts @@ -0,0 +1,23 @@ +import { TRPCError } from '@trpc/server'; + +import { updateUser } from '@documenso/lib/server-only/admin/update-user'; + +import { adminProcedure, router } from '../trpc'; +import { ZUpdateProfileMutationByAdminSchema } from './schema'; + +export const adminRouter = router({ + updateUser: adminProcedure + .input(ZUpdateProfileMutationByAdminSchema) + .mutation(async ({ input }) => { + const { id, name, email, roles } = input; + + try { + return await updateUser({ id, name, email, roles }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to retrieve the specified account. Please try again.', + }); + } + }), +}); diff --git a/packages/trpc/server/admin-router/schema.ts b/packages/trpc/server/admin-router/schema.ts new file mode 100644 index 000000000..a20d6f204 --- /dev/null +++ b/packages/trpc/server/admin-router/schema.ts @@ -0,0 +1,13 @@ +import { Role } from '@prisma/client'; +import z from 'zod'; + +export const ZUpdateProfileMutationByAdminSchema = z.object({ + id: z.number().min(1), + name: z.string().nullish(), + email: z.string().email().optional(), + roles: z.array(z.nativeEnum(Role)).optional(), +}); + +export type TUpdateProfileMutationByAdminSchema = z.infer< + typeof ZUpdateProfileMutationByAdminSchema +>; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 9da1b716e..0f6636650 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,19 +1,34 @@ import { TRPCError } from '@trpc/server'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; +import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { resetPassword } from '@documenso/lib/server-only/user/reset-password'; import { updatePassword } from '@documenso/lib/server-only/user/update-password'; import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; -import { authenticatedProcedure, procedure, router } from '../trpc'; +import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc'; import { ZForgotPasswordFormSchema, ZResetPasswordFormSchema, + ZRetrieveUserByIdQuerySchema, ZUpdatePasswordMutationSchema, ZUpdateProfileMutationSchema, } from './schema'; export const profileRouter = router({ + getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input }) => { + try { + const { id } = input; + + return await getUserById({ id }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to retrieve the specified account. Please try again.', + }); + } + }), + updateProfile: authenticatedProcedure .input(ZUpdateProfileMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 32a826ec0..44a8a451c 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; +export const ZRetrieveUserByIdQuerySchema = z.object({ + id: z.number().min(1), +}); + export const ZUpdateProfileMutationSchema = z.object({ name: z.string().min(1), signature: z.string(), @@ -19,6 +23,7 @@ export const ZResetPasswordFormSchema = z.object({ token: z.string().min(1), }); +export type TRetrieveUserByIdQuerySchema = z.infer; export type TUpdateProfileMutationSchema = z.infer; export type TUpdatePasswordMutationSchema = z.infer; export type TForgotPasswordFormSchema = z.infer; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index b4c65b1d4..519096da9 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -1,3 +1,4 @@ +import { adminRouter } from './admin-router/router'; import { authRouter } from './auth-router/router'; import { documentRouter } from './document-router/router'; import { fieldRouter } from './field-router/router'; @@ -13,6 +14,7 @@ export const appRouter = router({ profile: profileRouter, document: documentRouter, field: fieldRouter, + admin: adminRouter, shareLink: shareLinkRouter, }); diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 91d2a239f..a382e3511 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -1,6 +1,8 @@ import { TRPCError, initTRPC } from '@trpc/server'; import SuperJSON from 'superjson'; +import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; + import { TrpcContext } from './context'; const t = initTRPC.context().create({ @@ -28,9 +30,37 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => { }); }); +export const adminMiddleware = t.middleware(async ({ ctx, next }) => { + if (!ctx.session || !ctx.user) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You must be logged in to perform this action.', + }); + } + + const isUserAdmin = isAdmin(ctx.user); + + if (!isUserAdmin) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Not authorized to perform this action.', + }); + } + + return await next({ + ctx: { + ...ctx, + + user: ctx.user, + session: ctx.session, + }, + }); +}); + /** * Routers and Procedures */ export const router = t.router; export const procedure = t.procedure; export const authenticatedProcedure = t.procedure.use(authenticatedMiddleware); +export const adminProcedure = t.procedure.use(adminMiddleware); diff --git a/packages/ui/primitives/combobox.tsx b/packages/ui/primitives/combobox.tsx new file mode 100644 index 000000000..899ccd61d --- /dev/null +++ b/packages/ui/primitives/combobox.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; + +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Role } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@documenso/ui/primitives/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; + +type ComboboxProps = { + listValues: string[]; + onChange: (_values: string[]) => void; +}; + +const Combobox = ({ listValues, onChange }: ComboboxProps) => { + const [open, setOpen] = React.useState(false); + const [selectedValues, setSelectedValues] = React.useState([]); + const dbRoles = Object.values(Role); + + React.useEffect(() => { + setSelectedValues(listValues); + }, [listValues]); + + const allRoles = [...new Set([...dbRoles, ...selectedValues])]; + + const handleSelect = (currentValue: string) => { + let newSelectedValues; + if (selectedValues.includes(currentValue)) { + newSelectedValues = selectedValues.filter((value) => value !== currentValue); + } else { + newSelectedValues = [...selectedValues, currentValue]; + } + + setSelectedValues(newSelectedValues); + onChange(newSelectedValues); + setOpen(false); + }; + + return ( + + + + + + + + No value found. + + {allRoles.map((value: string, i: number) => ( + handleSelect(value)}> + + {value} + + ))} + + + + + ); +}; + +export { Combobox };