diff --git a/apps/remix/app/components/general/admin-details.tsx b/apps/remix/app/components/general/admin-details.tsx new file mode 100644 index 000000000..49698bbf4 --- /dev/null +++ b/apps/remix/app/components/general/admin-details.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@documenso/ui/lib/utils'; + +export type DetailsCardProps = { + label: ReactNode; + action?: ReactNode; + children: ReactNode; +}; + +export const DetailsCard = ({ label, action, children }: DetailsCardProps) => { + return ( +
+
+ {label} + {action ?? null} +
+
{children}
+
+ ); +}; + +export type DetailsValueProps = { + children: ReactNode; + isMono?: boolean; + isSelectable?: boolean; +}; + +export const DetailsValue = ({ + children, + isMono = true, + isSelectable = false, +}: DetailsValueProps) => { + return ( +
+ {children} +
+ ); +}; diff --git a/apps/remix/app/components/general/admin-global-settings-section.tsx b/apps/remix/app/components/general/admin-global-settings-section.tsx new file mode 100644 index 000000000..c6e07c0f2 --- /dev/null +++ b/apps/remix/app/components/general/admin-global-settings-section.tsx @@ -0,0 +1,163 @@ +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client'; + +import { DOCUMENT_VISIBILITY } from '@documenso/lib/constants/document-visibility'; +import { + type TDocumentEmailSettings, + ZDocumentEmailSettingsSchema, +} from '@documenso/lib/types/document-email'; + +import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; + +const EMAIL_SETTINGS_LABELS: Record = { + recipientSigningRequest: msg`Recipient signing request`, + recipientRemoved: msg`Recipient removed`, + recipientSigned: msg`Recipient signed`, + documentPending: msg`Document pending`, + documentCompleted: msg`Document completed`, + documentDeleted: msg`Document deleted`, + ownerDocumentCompleted: msg`Owner document completed`, + ownerRecipientExpired: msg`Owner recipient expired`, + ownerDocumentCreated: msg`Owner document created`, +}; + +const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocumentEmailSettings)[]; + +type AdminGlobalSettingsSectionProps = { + settings: TeamGlobalSettings | OrganisationGlobalSettings | null; + isTeam?: boolean; +}; + +export const AdminGlobalSettingsSection = ({ + settings, + isTeam = false, +}: AdminGlobalSettingsSectionProps) => { + const { _ } = useLingui(); + const notSetLabel = isTeam ? Inherited : Not set; + + if (!settings) { + return null; + } + + const textValue = (value: string | null | undefined) => { + if (value === null || value === undefined) { + return notSetLabel; + } + + return value; + }; + + const brandingTextValue = (value: string | null | undefined) => { + if (value === null || value === undefined || value.trim() === '') { + return notSetLabel; + } + + return value; + }; + + const booleanValue = (value: boolean | null | undefined) => { + if (value === null || value === undefined) { + return notSetLabel; + } + + return value ? Enabled : Disabled; + }; + + const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse( + settings.emailDocumentSettings, + ); + + return ( +
+ Document visibility}> + + {settings.documentVisibility != null + ? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value) + : notSetLabel} + + + + Document language}> + {textValue(settings.documentLanguage)} + + + Document timezone}> + {textValue(settings.documentTimezone)} + + + Date format}> + {textValue(settings.documentDateFormat)} + + + Include sender details}> + {booleanValue(settings.includeSenderDetails)} + + + Include signing certificate}> + {booleanValue(settings.includeSigningCertificate)} + + + Include audit log}> + {booleanValue(settings.includeAuditLog)} + + + Delegate document ownership}> + {booleanValue(settings.delegateDocumentOwnership)} + + + Typed signature}> + {booleanValue(settings.typedSignatureEnabled)} + + + Upload signature}> + {booleanValue(settings.uploadSignatureEnabled)} + + + Draw signature}> + {booleanValue(settings.drawSignatureEnabled)} + + + Branding}> + {booleanValue(settings.brandingEnabled)} + + + Branding logo}> + {brandingTextValue(settings.brandingLogo)} + + + Branding URL}> + {brandingTextValue(settings.brandingUrl)} + + + Branding company details}> + {brandingTextValue(settings.brandingCompanyDetails)} + + + Email reply-to}> + {textValue(settings.emailReplyTo)} + + + {isTeam && parsedEmailSettings.success && ( + Email document settings}> +
+ {emailSettingsKeys.map((key) => ( +
+ {_(EMAIL_SETTINGS_LABELS[key])} + + {parsedEmailSettings.data[key] ? On : Off} + +
+ ))} +
+
+ )} + + AI features}> + {booleanValue(settings.aiFeaturesEnabled)} + +
+ ); +}; diff --git a/apps/remix/app/components/tables/organisation-insights-table.tsx b/apps/remix/app/components/tables/organisation-insights-table.tsx index 6304e0b50..5d6c5dc44 100644 --- a/apps/remix/app/components/tables/organisation-insights-table.tsx +++ b/apps/remix/app/components/tables/organisation-insights-table.tsx @@ -62,7 +62,14 @@ export const OrganisationInsightsTable = ({ { header: _(msg`Team Name`), accessorKey: 'name', - cell: ({ row }) => {row.getValue('name')}, + cell: ({ row }) => ( + + {row.getValue('name')} + + ), size: 240, }, { @@ -276,12 +283,12 @@ const SummaryCard = ({ value: number; subtitle?: string; }) => ( -
- +
+
-

{title}

+

{title}

{value}

- {subtitle &&

{subtitle}

} + {subtitle &&

{subtitle}

}
); diff --git a/apps/remix/app/components/tables/organisation-teams-table.tsx b/apps/remix/app/components/tables/organisation-teams-table.tsx index ff5085a93..73632814f 100644 --- a/apps/remix/app/components/tables/organisation-teams-table.tsx +++ b/apps/remix/app/components/tables/organisation-teams-table.tsx @@ -64,7 +64,7 @@ export const OrganisationTeamsTable = () => { avatarClass="h-12 w-12" avatarFallback={row.original.name.slice(0, 1).toUpperCase()} primaryText={ - {row.original.name} + {row.original.name} } secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/t/${row.original.url}`} /> diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisation-insights.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisation-insights.$id.tsx index e53cc04ff..7fb0b1f24 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisation-insights.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisation-insights.$id.tsx @@ -54,6 +54,7 @@ export default function OrganisationInsights({ loaderData }: Route.ComponentProp
+
( + + {row.original.name} + + ), + }, + { + header: t`Team ID`, + accessorKey: 'id', + cell: ({ row }) => ( + {row.original.id} + ), }, { header: t`Team url`, accessorKey: 'url', + cell: ({ row }) => {row.original.url}, + }, + { + header: t`Created`, + accessorKey: 'createdAt', + cell: ({ row }) => { + return ( + + {i18n.date(row.original.createdAt)} + + ); + }, }, ] satisfies DataTableColumnDef[]; - }, [t]); + }, [i18n, t]); const organisationMembersColumns = useMemo(() => { return [ { header: t`Member`, cell: ({ row }) => ( -
- {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) => ( ));