feat: add team memberships section to admin user detail page (#2457)

This commit is contained in:
Lucas Smith
2026-02-09 17:35:22 +11:00
committed by GitHub
parent d91414697d
commit a5ef1d23e6
5 changed files with 316 additions and 1 deletions
@@ -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 }) => (
<HoverCard>
<HoverCardTrigger className="cursor-default underline decoration-dotted underline-offset-4">
{row.original.name}
</HoverCardTrigger>
<HoverCardContent
className="w-auto font-mono text-xs text-muted-foreground"
align="start"
>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt>id</dt>
<dd>{row.original.id}</dd>
<dt>url</dt>
<dd>{row.original.url}</dd>
</dl>
</HoverCardContent>
</HoverCard>
),
},
{
header: t`Organisation`,
accessorKey: 'organisation',
cell: ({ row }) => (
<Link
to={`/admin/organisations/${row.original.organisation.id}`}
className="hover:underline"
>
{row.original.organisation.name}
</Link>
),
},
{
header: t`Role`,
accessorKey: 'teamRole',
cell: ({ row }) => (
<Badge variant="neutral">
{i18n._(TEAM_MEMBER_ROLE_MAP[row.original.teamRole as TeamMemberRole])}
</Badge>
),
},
{
header: t`Created At`,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell className="py-4 pr-4">
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) =>
table.getPageCount() > 1 ? (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
) : null
}
</DataTable>
);
};
@@ -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 }) => {
<h3 className="text-lg font-semibold leading-none tracking-tight">
<Trans>User Organisations</Trans>
</h3>
<p className="text-muted-foreground mt-1.5 text-sm">
<p className="mt-1.5 text-sm text-muted-foreground">
<Trans>Organisations that the user is a member of.</Trans>
</p>
</div>
@@ -219,6 +226,28 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
/>
</div>
<hr className="my-8" />
<Accordion type="single" collapsible>
<AccordionItem value="team-memberships" className="border-b-0">
<AccordionTrigger className="py-0">
<div className="text-left">
<h3 className="text-lg font-semibold leading-none tracking-tight">
<Trans>Team Memberships</Trans>
</h3>
<p className="mt-1.5 text-sm font-normal text-muted-foreground">
<Trans>Teams that this user is a member of and their roles.</Trans>
</p>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminUserTeamsTable userId={user.id} />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
@@ -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<typeof mappedData>;
};
@@ -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<typeof ZFindUserTeamsRequestSchema>;
export type TFindUserTeamsResponse = z.infer<typeof ZFindUserTeamsResponseSchema>;
@@ -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,