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,