-
{row.original.user.name}
- {row.original.user.id === organisation?.ownerUserId && (
-
- Owner
-
+
+
+ {row.original.user.name ?? row.original.user.email}
+
+ {row.original.user.name && (
+
+ {row.original.user.email}
+
)}
),
},
{
- header: t`Email`,
+ header: t`User ID`,
+ accessorKey: 'userId',
cell: ({ row }) => (
-
{row.original.user.email}
+
{row.original.userId}
),
},
+ {
+ header: t`Role`,
+ cell: ({ row }) => {
+ if (!organisation) {
+ return null;
+ }
+
+ const isOwner = row.original.userId === organisation.ownerUserId;
+
+ if (isOwner) {
+ return
{t`Owner`};
+ }
+
+ const highestRole = getHighestOrganisationRoleInGroup(
+ row.original.organisationGroupMembers.map((ogm) => ogm.group),
+ );
+
+ const roleLabel = match(highestRole)
+ .with(OrganisationMemberRole.ADMIN, () => t`Admin`)
+ .with(OrganisationMemberRole.MANAGER, () => t`Manager`)
+ .with(OrganisationMemberRole.MEMBER, () => t`Member`)
+ .exhaustive();
+
+ return
{roleLabel};
+ },
+ },
+ {
+ header: t`Joined`,
+ accessorKey: 'createdAt',
+ cell: ({ row }) => {
+ return (
+
+ {i18n.date(row.original.createdAt)}
+
+ );
+ },
+ },
{
header: t`Actions`,
cell: ({ row }) => {
@@ -143,7 +220,7 @@ export default function OrganisationGroupSettingsPage({
},
},
] satisfies DataTableColumnDef
[];
- }, [organisation, t]);
+ }, [organisation, i18n, t]);
if (isLoadingOrganisation) {
return (
@@ -191,6 +268,61 @@ export default function OrganisationGroupSettingsPage({
+
+
+
+
+ Organisation usage
+
+
+ Current usage against organisation limits.
+
+
+
+
+
+ Members}>
+
+ {organisation.members.length} /{' '}
+ {organisation.organisationClaim.memberCount === 0
+ ? t`Unlimited`
+ : organisation.organisationClaim.memberCount}
+
+
+
+ Teams}>
+
+ {organisation.teams.length} /{' '}
+ {organisation.organisationClaim.teamCount === 0
+ ? t`Unlimited`
+ : organisation.organisationClaim.teamCount}
+
+
+
+
+
+
+
+
+
+
+
+ Global Settings
+
+
+ Default settings applied to this organisation.
+
+
+
+
+
+
+
+
+
+
0,
+ },
+ );
+
+ const onCopyToClipboard = async (text: string) => {
+ await navigator.clipboard.writeText(text);
+
+ toast({
+ title: _(msg`Copied to clipboard`),
+ });
+ };
+
+ const teamMembersColumns = useMemo(() => {
+ return [
+ {
+ header: _(msg`Member`),
+ cell: ({ row }) => (
+
+
+ {row.original.user.name ?? row.original.user.email}
+
+ {row.original.user.name && (
+
+ {row.original.user.email}
+
+ )}
+
+ ),
+ },
+ {
+ header: _(msg`User ID`),
+ accessorKey: 'userId',
+ },
+ {
+ header: _(msg`Team role`),
+ accessorKey: 'teamRole',
+ cell: ({ row }) => (
+ {_(TEAM_MEMBER_ROLE_MAP[row.original.teamRole])}
+ ),
+ },
+ {
+ header: _(msg`Organisation role`),
+ accessorKey: 'organisationRole',
+ cell: ({ row }) => {
+ const isOwner = row.original.userId === team?.organisation.ownerUserId;
+
+ if (isOwner) {
+ return {_(msg`Owner`)};
+ }
+
+ return (
+
+ {_(ORGANISATION_MEMBER_ROLE_MAP[row.original.organisationRole])}
+
+ );
+ },
+ },
+ {
+ header: _(msg`Joined`),
+ accessorKey: 'createdAt',
+ cell: ({ row }) => i18n.date(row.original.createdAt),
+ },
+ ] satisfies DataTableColumnDef[];
+ }, [team, _, i18n]);
+
+ const pendingInvitesColumns = useMemo(() => {
+ return [
+ {
+ header: _(msg`Email`),
+ accessorKey: 'email',
+ },
+ {
+ header: _(msg`Role`),
+ accessorKey: 'organisationRole',
+ cell: ({ row }) => _(ORGANISATION_MEMBER_ROLE_MAP[row.original.organisationRole]),
+ },
+ {
+ header: _(msg`Invited`),
+ accessorKey: 'createdAt',
+ cell: ({ row }) => i18n.date(row.original.createdAt),
+ },
+ ] satisfies DataTableColumnDef[];
+ }, [_, i18n]);
+
+ if (!Number.isFinite(teamId) || teamId <= 0) {
+ return (
+
+
+ Go back
+
+
+ }
+ secondaryButton={null}
+ />
+ );
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!team) {
+ return (
+
+
+ Go back
+
+
+ }
+ secondaryButton={null}
+ />
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ Team details
+
+
+ Key identifiers and relationships for this team.
+
+
+
+
+ Team ID}
+ action={
+
+ }
+ >
+ {team.id}
+
+
+ Team URL}>
+ {team.url}
+
+
+ Created}>
+ {i18n.date(team.createdAt)}
+
+
+ Members}>
+ {team.memberCount}
+
+
+ Organisation ID}
+ action={
+
+ }
+ >
+ {team.organisation.id}
+
+
+ {team.teamEmail && (
+ <>
+ Team email}>
+ {team.teamEmail.email}
+
+
+ Team email name}>
+ {team.teamEmail.name}
+
+ >
+ )}
+
+
+
+ {team.teamGlobalSettings && (
+
+
+
+
+
+
+ Global Settings
+
+
+
+ Default settings applied to this team. Inherited values come from the
+ organisation.
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ Team Members
+
+
+ Members that currently belong to this team.
+
+
+
+
+
+
+
+
+
+ Pending Organisation Invites
+
+
+ Organisation-level pending invites for this team's parent organisation.
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/trpc/server/admin-router/get-admin-team.ts b/packages/trpc/server/admin-router/get-admin-team.ts
new file mode 100644
index 000000000..00d67a328
--- /dev/null
+++ b/packages/trpc/server/admin-router/get-admin-team.ts
@@ -0,0 +1,131 @@
+import { OrganisationMemberInviteStatus } from '@prisma/client';
+
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
+import { getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
+import { prisma } from '@documenso/prisma';
+
+import { adminProcedure } from '../trpc';
+import { ZGetAdminTeamRequestSchema, ZGetAdminTeamResponseSchema } from './get-admin-team.types';
+
+export const getAdminTeamRoute = adminProcedure
+ .input(ZGetAdminTeamRequestSchema)
+ .output(ZGetAdminTeamResponseSchema)
+ .query(async ({ input, ctx }) => {
+ const { teamId } = input;
+
+ ctx.logger.info({
+ input: {
+ teamId,
+ },
+ });
+
+ const team = await prisma.team.findUnique({
+ where: {
+ id: teamId,
+ },
+ include: {
+ organisation: {
+ select: {
+ id: true,
+ name: true,
+ url: true,
+ ownerUserId: true,
+ },
+ },
+ teamEmail: true,
+ teamGlobalSettings: true,
+ },
+ });
+
+ if (!team) {
+ throw new AppError(AppErrorCode.NOT_FOUND, {
+ message: 'Team not found',
+ });
+ }
+
+ const [teamMembers, pendingInvites] = await Promise.all([
+ prisma.organisationMember.findMany({
+ where: {
+ organisationId: team.organisationId,
+ organisationGroupMembers: {
+ some: {
+ group: {
+ teamGroups: {
+ some: {
+ teamId,
+ },
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ select: {
+ id: true,
+ userId: true,
+ createdAt: true,
+ organisationGroupMembers: {
+ include: {
+ group: {
+ include: {
+ teamGroups: {
+ where: {
+ teamId,
+ },
+ },
+ },
+ },
+ },
+ },
+ user: {
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ },
+ },
+ },
+ }),
+ // Invites are organisation-scoped in the schema (no team relation), so this is intentionally
+ // all pending invites for the team's parent organisation.
+ prisma.organisationMemberInvite.findMany({
+ where: {
+ organisationId: team.organisationId,
+ status: OrganisationMemberInviteStatus.PENDING,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ select: {
+ id: true,
+ email: true,
+ createdAt: true,
+ organisationRole: true,
+ status: true,
+ },
+ }),
+ ]);
+
+ const mappedTeamMembers = teamMembers.map((teamMember) => {
+ const groups = teamMember.organisationGroupMembers.map(({ group }) => group);
+
+ return {
+ id: teamMember.id,
+ userId: teamMember.userId,
+ createdAt: teamMember.createdAt,
+ user: teamMember.user,
+ teamRole: getHighestTeamRoleInGroup(groups.flatMap((group) => group.teamGroups)),
+ organisationRole: getHighestOrganisationRoleInGroup(groups),
+ };
+ });
+
+ return {
+ ...team,
+ memberCount: mappedTeamMembers.length,
+ teamMembers: mappedTeamMembers,
+ pendingInvites,
+ };
+ });
diff --git a/packages/trpc/server/admin-router/get-admin-team.types.ts b/packages/trpc/server/admin-router/get-admin-team.types.ts
new file mode 100644
index 000000000..e57e70489
--- /dev/null
+++ b/packages/trpc/server/admin-router/get-admin-team.types.ts
@@ -0,0 +1,52 @@
+import { z } from 'zod';
+
+import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
+import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
+import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
+import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
+import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
+import TeamEmailSchema from '@documenso/prisma/generated/zod/modelSchema/TeamEmailSchema';
+import TeamGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/TeamGlobalSettingsSchema';
+import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
+import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
+
+export const ZGetAdminTeamRequestSchema = z.object({
+ teamId: z.number().min(1),
+});
+
+export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
+ organisation: OrganisationSchema.pick({
+ id: true,
+ name: true,
+ url: true,
+ ownerUserId: true,
+ }),
+ teamEmail: TeamEmailSchema.nullable(),
+ teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
+ memberCount: z.number(),
+ teamMembers: OrganisationMemberSchema.pick({
+ id: true,
+ userId: true,
+ createdAt: true,
+ })
+ .extend({
+ user: UserSchema.pick({
+ id: true,
+ email: true,
+ name: true,
+ }),
+ teamRole: TeamMemberRoleSchema,
+ organisationRole: OrganisationMemberRoleSchema,
+ })
+ .array(),
+ pendingInvites: OrganisationMemberInviteSchema.pick({
+ id: true,
+ email: true,
+ createdAt: true,
+ organisationRole: true,
+ status: true,
+ }).array(),
+});
+
+export type TGetAdminTeamRequest = z.infer;
+export type TGetAdminTeamResponse = z.infer;
diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts
index 7f8c231d2..48e75b31f 100644
--- a/packages/trpc/server/admin-router/router.ts
+++ b/packages/trpc/server/admin-router/router.ts
@@ -17,6 +17,7 @@ import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { findUnsealedDocumentsRoute } from './find-unsealed-documents';
import { findUserTeamsRoute } from './find-user-teams';
import { getAdminOrganisationRoute } from './get-admin-organisation';
+import { getAdminTeamRoute } from './get-admin-team';
import { getEmailDomainRoute } from './get-email-domain';
import { getUserRoute } from './get-user';
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
@@ -82,5 +83,8 @@ export const adminRouter = router({
get: getEmailDomainRoute,
reregister: reregisterEmailDomainRoute,
},
+ team: {
+ get: getAdminTeamRoute,
+ },
updateSiteSetting: updateSiteSettingRoute,
});
diff --git a/packages/ui/primitives/select.tsx b/packages/ui/primitives/select.tsx
index ab572d5a5..bf7883f75 100644
--- a/packages/ui/primitives/select.tsx
+++ b/packages/ui/primitives/select.tsx
@@ -23,7 +23,7 @@ const SelectTrigger = React.forwardRef<
(({ className, ...props }, ref) => (
));