mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 09:54:51 +10:00
feat: add team memberships section to admin user detail page (#2457)
This commit is contained in:
@@ -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 { trpc } from '@documenso/trpc/react';
|
||||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||||
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-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 { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Form,
|
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 { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
|
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';
|
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">
|
<h3 className="text-lg font-semibold leading-none tracking-tight">
|
||||||
<Trans>User Organisations</Trans>
|
<Trans>User Organisations</Trans>
|
||||||
</h3>
|
</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>
|
<Trans>Organisations that the user is a member of.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,6 +226,28 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="mt-16 flex flex-col gap-4">
|
||||||
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
|
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
|
||||||
{user && user.disabled && <AdminUserEnableDialog userToEnable={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 { findDocumentJobsRoute } from './find-document-jobs';
|
||||||
import { findDocumentsRoute } from './find-documents';
|
import { findDocumentsRoute } from './find-documents';
|
||||||
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
|
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
|
||||||
|
import { findUserTeamsRoute } from './find-user-teams';
|
||||||
import { getAdminOrganisationRoute } from './get-admin-organisation';
|
import { getAdminOrganisationRoute } from './get-admin-organisation';
|
||||||
import { getUserRoute } from './get-user';
|
import { getUserRoute } from './get-user';
|
||||||
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
||||||
@@ -55,6 +56,7 @@ export const adminRouter = router({
|
|||||||
enable: enableUserRoute,
|
enable: enableUserRoute,
|
||||||
disable: disableUserRoute,
|
disable: disableUserRoute,
|
||||||
resetTwoFactor: resetTwoFactorRoute,
|
resetTwoFactor: resetTwoFactorRoute,
|
||||||
|
findTeams: findUserTeamsRoute,
|
||||||
},
|
},
|
||||||
document: {
|
document: {
|
||||||
find: findDocumentsRoute,
|
find: findDocumentsRoute,
|
||||||
|
|||||||
Reference in New Issue
Block a user