diff --git a/apps/remix/app/components/tables/admin-user-teams-table.tsx b/apps/remix/app/components/tables/admin-user-teams-table.tsx new file mode 100644 index 000000000..39eaaa5b4 --- /dev/null +++ b/apps/remix/app/components/tables/admin-user-teams-table.tsx @@ -0,0 +1,146 @@ +import { useMemo } from 'react'; + +import { useLingui } from '@lingui/react'; +import { useLingui as useLinguiMacro } from '@lingui/react/macro'; +import { Link, useSearchParams } from 'react-router'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; +import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import type { TeamMemberRole } from '@documenso/prisma/generated/types'; +import { trpc } from '@documenso/trpc/react'; +import { Badge } from '@documenso/ui/primitives/badge'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@documenso/ui/primitives/hover-card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +type AdminUserTeamsTableProps = { + userId: number; +}; + +export const AdminUserTeamsTable = ({ userId }: AdminUserTeamsTableProps) => { + const { i18n } = useLingui(); + const { t } = useLinguiMacro(); + + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); + + const { data, isLoading, isLoadingError } = trpc.admin.user.findTeams.useQuery({ + userId, + query: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + return [ + { + header: t`Team`, + accessorKey: 'name', + cell: ({ row }) => ( + + + {row.original.name} + + +
+
id
+
{row.original.id}
+
url
+
{row.original.url}
+
+
+
+ ), + }, + { + header: t`Organisation`, + accessorKey: 'organisation', + cell: ({ row }) => ( + + {row.original.organisation.name} + + ), + }, + { + header: t`Role`, + accessorKey: 'teamRole', + cell: ({ row }) => ( + + {i18n._(TEAM_MEMBER_ROLE_MAP[row.original.teamRole as TeamMemberRole])} + + ), + }, + { + header: t`Created At`, + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(row.original.createdAt), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + return ( + + + + + + + + + + + + + + + ), + }} + > + {(table) => + table.getPageCount() > 1 ? ( + + ) : null + } + + ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx index 15a53d46b..42dc1978e 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx @@ -10,6 +10,12 @@ import type { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@documenso/ui/primitives/accordion'; import { Button } from '@documenso/ui/primitives/button'; import { Form, @@ -30,6 +36,7 @@ import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-di import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table'; +import { AdminUserTeamsTable } from '~/components/tables/admin-user-teams-table'; import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox'; @@ -197,7 +204,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {

User Organisations

-

+

Organisations that the user is a member of.

@@ -219,6 +226,28 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => { /> +
+ + + + +
+

+ Team Memberships +

+

+ Teams that this user is a member of and their roles. +

+
+
+ +
+ +
+
+
+
+
{user && user.twoFactorEnabled && } {user && user.disabled && } diff --git a/packages/trpc/server/admin-router/find-user-teams.ts b/packages/trpc/server/admin-router/find-user-teams.ts new file mode 100644 index 000000000..3374d08f1 --- /dev/null +++ b/packages/trpc/server/admin-router/find-user-teams.ts @@ -0,0 +1,107 @@ +import { Prisma } from '@prisma/client'; + +import type { FindResultResponse } from '@documenso/lib/types/search-params'; +import { getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; + +import { adminProcedure } from '../trpc'; +import { ZFindUserTeamsRequestSchema, ZFindUserTeamsResponseSchema } from './find-user-teams.types'; + +export const findUserTeamsRoute = adminProcedure + .input(ZFindUserTeamsRequestSchema) + .output(ZFindUserTeamsResponseSchema) + .query(async ({ input }) => { + const { userId, query, page, perPage } = input; + + return await findUserTeams({ + userId, + query, + page, + perPage, + }); + }); + +type FindUserTeamsOptions = { + userId: number; + query?: string; + page?: number; + perPage?: number; +}; + +const findUserTeams = async ({ userId, query, page = 1, perPage = 10 }: FindUserTeamsOptions) => { + const whereClause: Prisma.TeamWhereInput = { + teamGroups: { + some: { + organisationGroup: { + organisationGroupMembers: { + some: { + organisationMember: { + userId, + }, + }, + }, + }, + }, + }, + }; + + if (query && query.length > 0) { + whereClause.name = { + contains: query, + mode: Prisma.QueryMode.insensitive, + }; + } + + const [data, count] = await Promise.all([ + prisma.team.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + createdAt: 'desc', + }, + include: { + organisation: { + select: { + id: true, + name: true, + url: true, + }, + }, + teamGroups: { + where: { + organisationGroup: { + organisationGroupMembers: { + some: { + organisationMember: { + userId, + }, + }, + }, + }, + }, + }, + }, + }), + prisma.team.count({ + where: whereClause, + }), + ]); + + const mappedData = data.map((team) => ({ + id: team.id, + name: team.name, + url: team.url, + createdAt: team.createdAt, + teamRole: getHighestTeamRoleInGroup(team.teamGroups), + organisation: team.organisation, + })); + + return { + data: mappedData, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultResponse; +}; diff --git a/packages/trpc/server/admin-router/find-user-teams.types.ts b/packages/trpc/server/admin-router/find-user-teams.types.ts new file mode 100644 index 000000000..9f785c72a --- /dev/null +++ b/packages/trpc/server/admin-router/find-user-teams.types.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema'; +import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema'; +import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema'; + +export const ZFindUserTeamsRequestSchema = ZFindSearchParamsSchema.extend({ + userId: z.number(), +}); + +export const ZFindUserTeamsResponseSchema = ZFindResultResponse.extend({ + data: TeamSchema.pick({ + id: true, + name: true, + url: true, + createdAt: true, + }) + .extend({ + teamRole: TeamMemberRoleSchema, + organisation: OrganisationSchema.pick({ + id: true, + name: true, + url: true, + }), + }) + .array(), +}); + +export type TFindUserTeamsRequest = z.infer; +export type TFindUserTeamsResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index c169e4813..17783072d 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -12,6 +12,7 @@ import { findDocumentAuditLogsRoute } from './find-document-audit-logs'; import { findDocumentJobsRoute } from './find-document-jobs'; import { findDocumentsRoute } from './find-documents'; import { findSubscriptionClaimsRoute } from './find-subscription-claims'; +import { findUserTeamsRoute } from './find-user-teams'; import { getAdminOrganisationRoute } from './get-admin-organisation'; import { getUserRoute } from './get-user'; import { promoteMemberToOwnerRoute } from './promote-member-to-owner'; @@ -55,6 +56,7 @@ export const adminRouter = router({ enable: enableUserRoute, disable: disableUserRoute, resetTwoFactor: resetTwoFactorRoute, + findTeams: findUserTeamsRoute, }, document: { find: findDocumentsRoute,