feat: add organisation template type (#2611)

This commit is contained in:
Ephraim Duncan 2026-03-16 14:29:34 +00:00 committed by GitHub
parent 943a0b50e3
commit 36bbd97514
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1702 additions and 279 deletions

View file

@ -135,7 +135,9 @@ Additional options that apply to all documents created from this template:
## Template Visibility
All templates are created in a team context. Team members can see, edit, delete, and use the templates in that team. See [Organisations](/docs/users/organisations) to learn about creating and managing organisations.
All templates are created in a team context. By default, templates are **Private** and only visible to members of the owning team.
If your organisation has multiple teams, you can set a template's type to **Organisation** to share it across all teams. See [Organisation Templates](/docs/users/templates/organisation-templates) for details.
---

View file

@ -24,6 +24,11 @@ description: Create reusable document templates for common signing workflows.
description="Create documents from your templates."
href="/docs/users/templates/use"
/>
<Card
title="Organisation Templates"
description="Share templates across all teams in your organisation."
href="/docs/users/templates/organisation-templates"
/>
</Cards>
---

View file

@ -1,4 +1,4 @@
{
"title": "Templates",
"pages": ["create", "use"]
"pages": ["create", "use", "organisation-templates"]
}

View file

@ -0,0 +1,131 @@
---
title: Organisation Templates
description: Share templates across all teams in your organisation so any team can create documents from them.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
## Overview
Organisation templates are templates shared across all teams within the same organisation. Any team in the organisation can browse and use them to create documents, but only the owning team can edit or delete them.
This is useful when you have standardised documents that multiple teams need to use, such as company-wide NDAs, onboarding agreements, or compliance forms.
## Requirements
The Organisation template type is available when your organisation has **two or more teams**. If your organisation has only one team, the option does not appear.
## Template Types
| Type | Who can see it | Who can edit it | Who can use it |
| ---------------- | ------------------------- | ----------------- | --------------------------- |
| **Private** | Members of the owning team | Owning team | Owning team |
| **Organisation** | All teams in the org | Owning team only | All teams in the org |
| **Public** | Anyone with the link | Owning team | Anyone via direct link |
## Set a Template as Organisation
{/* prettier-ignore */}
<Steps>
<Step>
### Open template settings
Navigate to **Templates**, open the template you want to share, and click **Edit Template** to open the editor. Then open the settings dialog.
</Step>
<Step>
### Change the template type
In the **Template type** dropdown, select **Organisation**. This option only appears if your organisation has at least two teams.
</Step>
<Step>
### Save
Click **Save** to apply the change. The template is now visible to all teams in your organisation.
</Step>
</Steps>
You can also set the template type to Organisation when creating a new template. The type dropdown appears in the template settings step.
## Browse Organisation Templates
{/* prettier-ignore */}
<Steps>
<Step>
### Open the templates page
Navigate to **Templates** in the sidebar.
</Step>
<Step>
### Switch to the Organisation tab
Click the **Organisation** tab above the template list. This tab only appears for non-personal organisations.
The Organisation tab shows all organisation templates from every team in your organisation, including your own.
</Step>
</Steps>
Templates from other teams display the owning team's name next to the template type.
## Use an Organisation Template
Any team member in the organisation can create documents from an organisation template, even if the template belongs to a different team.
{/* prettier-ignore */}
<Steps>
<Step>
Find the template in the **Organisation** tab or click through from the template detail page.
</Step>
<Step>
Click **Use Template** and fill in the recipient details. The document is created under your team, not the template's owning team.
</Step>
</Steps>
See [Use Templates](/docs/users/templates/use) for details on creating documents from templates.
## Editing and Permissions
Only members of the team that owns the template can edit or delete it. When viewing an organisation template from another team:
- The **Edit Template**, **Direct Link**, and **Bulk Send** controls are hidden
- The recipients section is read-only
- The **Use Template** button is available
To modify a template owned by another team, contact that team's members or ask an organisation admin to make changes.
## Visibility
Organisation templates respect the same visibility settings as other templates. A template's visibility determines which team roles can access it:
| Visibility | Who can access |
| --------------------- | --------------------------------------- |
| **Everyone** | All team members (Admin, Manager, Member) |
| **Manager and above** | Admins and Managers only |
| **Admin** | Admins only |
This applies to both the owning team and other teams in the organisation. A Member-role user on any team cannot see an organisation template set to Admin visibility.
## Reverting to Private
To stop sharing a template across the organisation, change the template type back to **Private** in the template settings. The template will only be visible to the owning team. Documents already created from the template are not affected.
---
## See Also
- [Create Templates](/docs/users/templates/create) - Build reusable templates
- [Use Templates](/docs/users/templates/use) - Create documents from templates
- [Organisations](/docs/users/organisations) - Managing organisations and teams

View file

@ -1,7 +1,8 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { DocumentStatus, EnvelopeType, TemplateType } from '@prisma/client';
import {
AlertTriangleIcon,
Building2Icon,
Globe2Icon,
LockIcon,
RefreshCwIcon,
@ -94,12 +95,19 @@ export default function EnvelopeEditorHeader() {
{envelope.type === EnvelopeType.TEMPLATE && (
<>
{envelope.templateType === 'PRIVATE' ? (
{envelope.templateType === TemplateType.PRIVATE && (
<Badge variant="secondary">
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
<Trans>Private Template</Trans>
</Badge>
) : (
)}
{envelope.templateType === TemplateType.ORGANISATION && (
<Badge variant="orange">
<Building2Icon className="mr-2 size-4" />
<Trans>Organisation Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.PUBLIC && (
<Badge variant="default">
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
<Trans>Public Template</Trans>

View file

@ -9,6 +9,7 @@ import {
DocumentVisibility,
EnvelopeType,
SendStatus,
TemplateType,
} from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
@ -66,6 +67,10 @@ import {
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import {
TemplateTypeSelect,
TemplateTypeTooltip,
} from '@documenso/ui/components/template/template-type-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
@ -102,6 +107,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export const ZAddSettingsFormSchema = z.object({
templateType: z.nativeEnum(TemplateType).optional(),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: z
@ -196,6 +202,7 @@ export const EnvelopeEditorSettingsDialog = ({
const createDefaultValues = () => {
return {
templateType: envelope.templateType || TemplateType.PRIVATE,
externalId: envelope.externalId || '',
visibility: envelope.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
@ -270,6 +277,7 @@ export const EnvelopeEditorSettingsDialog = ({
try {
await updateEnvelopeAsync({
data: {
templateType: envelope.type === EnvelopeType.TEMPLATE ? data.templateType : undefined,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
@ -606,6 +614,31 @@ export const EnvelopeEditorSettingsDialog = ({
)}
/>
{envelope.type === EnvelopeType.TEMPLATE && (
<FormField
control={form.control}
name="templateType"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Template type</Trans>
<TemplateTypeTooltip
organisationTeamCount={organisation.teams.length}
/>
</FormLabel>
<FormControl>
<TemplateTypeSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
{settings.allowConfigureDistribution && (
<FormField
control={form.control}

View file

@ -136,6 +136,7 @@ export const TemplateEditForm = ({
templateId: template.id,
data: {
title: data.title,
type: data.templateType,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],

View file

@ -14,36 +14,40 @@ export type TemplatePageViewRecipientsProps = {
recipients: Recipient[];
envelopeId: string;
templateRootPath: string;
readOnly?: boolean;
};
export const TemplatePageViewRecipients = ({
recipients,
envelopeId,
templateRootPath,
readOnly = false,
}: TemplatePageViewRecipientsProps) => {
const { _ } = useLingui();
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">
<h1 className="font-medium text-foreground">
<Trans>Recipients</Trans>
</h1>
<Link
to={`${templateRootPath}/${envelopeId}/edit?step=signers`}
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
{!readOnly && (
<Link
to={`${templateRootPath}/${envelopeId}/edit?step=signers`}
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
)}
</div>
<ul className="text-muted-foreground divide-y border-t">
<ul className="divide-y border-t text-muted-foreground">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
@ -60,13 +64,13 @@ export const TemplatePageViewRecipients = ({
}
primaryText={
isTemplateRecipientEmailPlaceholder(recipient.email) ? (
<p className="text-muted-foreground text-sm">{recipient.name}</p>
<p className="text-sm text-muted-foreground">{recipient.name}</p>
) : (
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-sm text-muted-foreground">{recipient.email}</p>
)
}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}

View file

@ -4,7 +4,7 @@ import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { TemplateType as TemplateTypePrisma } from '@prisma/client';
import { Globe2, Lock } from 'lucide-react';
import { Building2, Globe2, Lock } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { cn } from '@documenso/ui/lib/utils';
@ -28,6 +28,11 @@ const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
icon: Globe2,
color: 'text-green-500 dark:text-green-300',
},
ORGANISATION: {
label: msg`Organisation`,
icon: Building2,
color: 'text-orange-500 dark:text-orange-300',
},
};
export type TemplateTypeProps = HTMLAttributes<HTMLSpanElement> & {

View file

@ -5,7 +5,6 @@ import type { Recipient, TemplateDirectLink } from '@prisma/client';
import { Copy, Edit, FolderIcon, MoreHorizontal, Share2Icon, Trash2, Upload } from 'lucide-react';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import {
DropdownMenu,
DropdownMenuContent,
@ -42,76 +41,72 @@ export const TemplatesTableActionDropdown = ({
teamId,
onDelete,
}: TemplatesTableActionDropdownProps) => {
const { user } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
const isOwner = row.userId === user.id;
const isTeamTemplate = row.teamId === teamId;
const canMutate = isTeamTemplate;
const formatPath = `${templateRootPath}/${row.envelopeId}/edit`;
return (
<DropdownMenu>
<DropdownMenuTrigger data-testid="template-table-action-btn">
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<DropdownMenuItem disabled={!canMutate} asChild>
<Link to={formatPath}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDuplicateDialogOpen(true)}
>
<DropdownMenuItem disabled={!canMutate} onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<TemplateDirectLinkDialog
templateId={row.id}
recipients={row.recipients}
directLink={row.directLink}
trigger={
<div
data-testid="template-direct-link"
className="hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors"
>
<Share2Icon className="mr-2 h-4 w-4" />
<Trans>Direct link</Trans>
</div>
}
/>
{canMutate && (
<TemplateDirectLinkDialog
templateId={row.id}
recipients={row.recipients}
directLink={row.directLink}
trigger={
<div
data-testid="template-direct-link"
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Share2Icon className="mr-2 h-4 w-4" />
<Trans>Direct link</Trans>
</div>
}
/>
)}
<DropdownMenuItem onClick={() => setMoveToFolderDialogOpen(true)}>
<DropdownMenuItem disabled={!canMutate} onClick={() => setMoveToFolderDialogOpen(true)}>
<FolderIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
</DropdownMenuItem>
<TemplateBulkSendDialog
templateId={row.id}
recipients={row.recipients}
trigger={
<div className="hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors">
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</div>
}
/>
{canMutate && (
<TemplateBulkSendDialog
templateId={row.id}
recipients={row.recipients}
trigger={
<div className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</div>
}
/>
)}
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}
>
<DropdownMenuItem disabled={!canMutate} onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>

View file

@ -3,7 +3,15 @@ import { useMemo, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
import {
AlertTriangle,
Building2Icon,
Globe2Icon,
InfoIcon,
Link2Icon,
Loader,
LockIcon,
} from 'lucide-react';
import { Link } from 'react-router';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@ -166,25 +174,48 @@ export const TemplatesTable = ({
)}
</p>
</li>
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Building2Icon className="mr-2 h-5 w-5 text-orange-500 dark:text-orange-300" />
<Trans>Organisation</Trans>
</h2>
<p>
<Trans>
Organisation templates are shared across all teams within the same
organisation. Only the owning team can edit them.
</Trans>
</p>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
),
accessorKey: 'type',
cell: ({ row }) => (
<div className="flex flex-row items-center">
<TemplateType type={row.original.type} />
cell: ({ row }) => {
const isFromOtherTeam = row.original.teamId !== team?.id;
{row.original.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-2"
token={row.original.directLink.token}
enabled={row.original.directLink.enabled}
/>
)}
</div>
),
return (
<div className="flex flex-row items-center">
<TemplateType type={row.original.type} />
{isFromOtherTeam && row.original.team?.name && (
<span className="ml-2 text-xs text-muted-foreground">
({row.original.team.name})
</span>
)}
{row.original.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-2"
token={row.original.directLink.token}
enabled={row.original.directLink.enabled}
/>
)}
</div>
);
},
},
{
header: _(msg`Actions`),

View file

@ -1,3 +1,4 @@
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import {
Links,
Meta,
@ -138,15 +139,17 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
</div>
)} */}
<SessionProvider initialSession={session}>
<TooltipProvider>
<TrpcProvider>
{children}
<NuqsAdapter>
<SessionProvider initialSession={session}>
<TooltipProvider>
<TrpcProvider>
{children}
<Toaster />
</TrpcProvider>
</TooltipProvider>
</SessionProvider>
<Toaster />
</TrpcProvider>
</TooltipProvider>
</SessionProvider>
</NuqsAdapter>
<script
dangerouslySetInnerHTML={{

View file

@ -67,6 +67,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const { data } = trpc.template.findTemplates.useQuery({
type: TemplateType.PRIVATE,
perPage: 100,
});
@ -82,8 +83,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
const enabledPrivateDirectTemplates = useMemo(
() =>
(data?.data ?? []).filter(
(template): template is DirectTemplate =>
template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC,
(template): template is DirectTemplate => template.directLink?.enabled === true,
),
[data],
);
@ -143,7 +143,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
<TooltipTrigger asChild>
<div
className={cn(
'text-muted-foreground/50 flex flex-row items-center justify-center space-x-2 text-xs',
'flex flex-row items-center justify-center space-x-2 text-xs text-muted-foreground/50',
{
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
@ -164,7 +164,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
</div>
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-[40ch] space-y-2 py-2">
<TooltipContent className="max-w-[40ch] space-y-2 py-2 text-muted-foreground">
{isPublicProfileVisible ? (
<>
<p>

View file

@ -1,5 +1,5 @@
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { ChevronLeft, LucideEdit } from 'lucide-react';
import { Link, useNavigate } from 'react-router';
@ -37,19 +37,25 @@ import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/templates.$id._index';
export default function TemplatePage({ params }: Route.ComponentProps) {
const { t } = useLingui();
const { user } = useSession();
const navigate = useNavigate();
const team = useCurrentTeam();
const {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery({
envelopeId: params.id,
});
// Try fetching as a team template first; only fall back to the org endpoint if the team
// query has definitively failed (i.e. this template belongs to a sibling team).
// We disable retries on the team query so the org fallback kicks in immediately.
const teamTemplateQuery = trpc.envelope.get.useQuery({ envelopeId: params.id }, { retry: false });
const orgTemplateQuery = trpc.template.getOrganisationTemplateById.useQuery(
{ envelopeId: params.id },
{ enabled: teamTemplateQuery.isError, retry: false },
);
const envelope = teamTemplateQuery.data ?? orgTemplateQuery.data;
const isLoadingEnvelope =
teamTemplateQuery.isLoading ||
(teamTemplateQuery.isError && !orgTemplateQuery.isError && !orgTemplateQuery.data);
const isErrorEnvelope = teamTemplateQuery.isError && orgTemplateQuery.isError;
if (isLoadingEnvelope) {
return (
@ -84,6 +90,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
const documentRootPath = formatDocumentsPath(team.url);
const templateRootPath = formatTemplatesPath(team.url);
const isOwnTeamTemplate = envelope.teamId === team?.id;
// Remap to fit the DocumentReadOnlyFields component.
const readOnlyFields = envelope.fields.map((field) => {
@ -146,23 +153,27 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
</div>
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
/>
{isOwnTeamTemplate && (
<>
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
/>
<TemplateBulkSendDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
recipients={envelope.recipients}
/>
<TemplateBulkSendDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
recipients={envelope.recipients}
/>
<Button className="w-full" asChild>
<Link to={`${templateRootPath}/${envelope.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
<Trans>Edit Template</Trans>
</Link>
</Button>
<Button className="w-full" asChild>
<Link to={`${templateRootPath}/${envelope.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
<Trans>Edit Template</Trans>
</Link>
</Button>
</>
)}
</div>
</div>
@ -236,22 +247,28 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<Trans>Template</Trans>
</h3>
<div>
<TemplatesTableActionDropdown
row={{
...envelope,
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeId: envelope.id,
}}
teamId={team?.id}
templateRootPath={templateRootPath}
onDelete={async () => navigate(templateRootPath)}
/>
</div>
{isOwnTeamTemplate && (
<div>
<TemplatesTableActionDropdown
row={{
...envelope,
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeId: envelope.id,
}}
teamId={team?.id}
templateRootPath={templateRootPath}
onDelete={async () => navigate(templateRootPath)}
/>
</div>
)}
</div>
<p className="mt-2 px-4 text-sm text-muted-foreground">
<Trans>Manage and view template</Trans>
{isOwnTeamTemplate ? (
<Trans>Manage and view template</Trans>
) : (
<Trans>View organisation template</Trans>
)}
</p>
<div className="mt-4 border-t px-4 pt-4">
@ -278,6 +295,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
recipients={envelope.recipients}
envelopeId={envelope.id}
templateRootPath={templateRootPath}
readOnly={!isOwnTeamTemplate}
/>
{/* Recent activity section. */}

View file

@ -14,6 +14,7 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getOrganisationTemplateById } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
import { Button } from '@documenso/ui/primitives/button';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@ -43,18 +44,36 @@ export async function loader({ request, params }: Route.LoaderArgs) {
teamUrl: params.teamUrl,
});
// Try the team endpoint first, then fall back to the org endpoint.
const envelope = await getEnvelopeById({
id: {
type: 'templateId',
id: templateId,
},
id: { type: 'templateId', id: templateId },
type: EnvelopeType.TEMPLATE,
userId: user.id,
teamId: team.id,
}).catch((err) => {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
if (error.code === AppErrorCode.NOT_FOUND || error.code === AppErrorCode.UNAUTHORIZED) {
return null;
}
throw err;
});
if (envelope) {
const url = new URL(request.url);
throw redirect(url.pathname.replace(`/templates/${id}`, `/templates/${envelope.id}`));
}
const orgEnvelope = await getOrganisationTemplateById({
id: { type: 'templateId', id: templateId },
userId: user.id,
teamId: team.id,
}).catch((err) => {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND || error.code === AppErrorCode.UNAUTHORIZED) {
throw new Response('Not Found', { status: 404 });
}
@ -63,7 +82,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
const url = new URL(request.url);
throw redirect(url.pathname.replace(`/templates/${id}`, `/templates/${envelope.id}`));
throw redirect(url.pathname.replace(`/templates/${id}`, `/templates/${orgEnvelope.id}`));
}
}

View file

@ -1,17 +1,20 @@
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { EnvelopeType, OrganisationType } from '@prisma/client';
import { Bird } from 'lucide-react';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
import { useParams, useSearchParams } from 'react-router';
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
@ -22,12 +25,17 @@ import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
export function meta() {
return appMetaTags('Templates');
}
export default function TemplatesPage() {
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
@ -35,6 +43,14 @@ export default function TemplatesPage() {
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const [view, setView] = useQueryState(
'view',
parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'),
);
const isOrgView = view === 'organisation';
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
'templates-bulk-selection',
{},
@ -49,16 +65,41 @@ export default function TemplatesPage() {
const documentRootPath = formatDocumentsPath(team.url);
const templateRootPath = formatTemplatesPath(team.url);
const { data, isLoading, isLoadingError } = trpc.template.findTemplates.useQuery({
page: page,
perPage: perPage,
folderId,
});
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
{
page,
perPage,
folderId,
},
{
enabled: !isOrgView,
},
);
const orgTemplatesQuery = trpc.template.findOrganisationTemplates.useQuery(
{
page,
perPage,
},
{
enabled: isOrgView,
},
);
const activeQuery = isOrgView ? orgTemplatesQuery : teamTemplatesQuery;
const handleViewChange = (newView: string) => {
if (newView !== 'team' && newView !== 'organisation') {
return;
}
void setView(newView === 'team' ? null : newView);
};
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
{!isOrgView && <FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />}
<div className="mt-8">
<div className="flex flex-row items-center">
@ -74,8 +115,31 @@ export default function TemplatesPage() {
</h1>
</div>
{showOrgTab && (
<div className="mt-6">
<Tabs value={view} onValueChange={handleViewChange} data-testid="template-view-tabs">
<TabsList>
<TabsTrigger
className="min-w-[60px] hover:text-foreground"
value="team"
data-testid="template-tab-team"
>
<Trans>Team</Trans>
</TabsTrigger>
<TabsTrigger
className="min-w-[60px] hover:text-foreground"
value="organisation"
data-testid="template-tab-organisation"
>
<Trans>Organisation</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
<div className="mt-8">
{data && data.count === 0 ? (
{activeQuery.data && activeQuery.data.count === 0 ? (
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<Bird className="h-12 w-12" strokeWidth={1.5} />
@ -85,51 +149,59 @@ export default function TemplatesPage() {
</h3>
<p className="mt-2 max-w-[50ch]">
<Trans>
You have not yet created any templates. To create a template please upload
one.
</Trans>
{isOrgView ? (
<Trans>No organisation templates are shared with your team yet.</Trans>
) : (
<Trans>
You have not yet created any templates. To create a template please upload
one.
</Trans>
)}
</p>
</div>
</div>
) : (
<TemplatesTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
data={activeQuery.data}
isLoading={activeQuery.isLoading}
isLoadingError={activeQuery.isLoadingError}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
enableSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
enableSelection={!isOrgView}
rowSelection={isOrgView ? {} : rowSelection}
onRowSelectionChange={isOrgView ? undefined : setRowSelection}
/>
)}
</div>
</div>
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
{!isOrgView && (
<>
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.TEMPLATE}
open={isBulkMoveDialogOpen}
currentFolderId={folderId}
onOpenChange={setIsBulkMoveDialogOpen}
onSuccess={() => setRowSelection({})}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.TEMPLATE}
open={isBulkMoveDialogOpen}
currentFolderId={folderId}
onOpenChange={setIsBulkMoveDialogOpen}
onSuccess={() => setRowSelection({})}
/>
<EnvelopesBulkDeleteDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.TEMPLATE}
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onSuccess={() => setRowSelection({})}
/>
<EnvelopesBulkDeleteDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.TEMPLATE}
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onSuccess={() => setRowSelection({})}
/>
</>
)}
</div>
</EnvelopeDropZoneWrapper>
);

View file

@ -53,6 +53,7 @@
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"nuqs": "^2.8.9",
"papaparse": "^5.5.3",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",

View file

@ -1,9 +1,17 @@
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
import {
type DocumentDataType,
DocumentStatus,
type EnvelopeType,
type TemplateType,
} from '@prisma/client';
import { EnvelopeType as EnvelopeTypeEnum, TemplateType as TemplateTypeEnum } from '@prisma/client';
import contentDisposition from 'content-disposition';
import { type Context } from 'hono';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../router';
@ -79,3 +87,63 @@ export const handleEnvelopeItemFileRequest = async ({
return c.body(file);
};
type CheckEnvelopeFileAccessOptions = {
userId: number;
teamId: number;
envelopeType: EnvelopeType;
templateType: TemplateType;
};
/**
* Check whether a user has access to an envelope's file.
*
* First checks team membership. If that fails and the envelope is an
* ORGANISATION template (not a document), falls back to checking whether
* the user belongs to any team in the same organisation.
*/
export const checkEnvelopeFileAccess = async ({
userId,
teamId,
envelopeType,
templateType,
}: CheckEnvelopeFileAccessOptions): Promise<boolean> => {
const team = await getTeamById({ userId, teamId }).catch(() => null);
if (team) {
return true;
}
if (
envelopeType === EnvelopeTypeEnum.TEMPLATE &&
templateType === TemplateTypeEnum.ORGANISATION
) {
const orgAccess = await prisma.team.findFirst({
where: {
id: teamId,
organisation: {
teams: {
some: {
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: { userId },
},
},
},
},
},
},
},
},
},
select: { id: true },
});
return orgAccess !== null;
}
return false;
};

View file

@ -6,13 +6,12 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../router';
import { handleEnvelopeItemFileRequest } from './files.helpers';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers';
import {
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
@ -119,16 +118,14 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Envelope item not found' }, 404);
}
const team = await getTeamById({
userId: userId,
const hasAccess = await checkEnvelopeFileAccess({
userId,
teamId: envelope.teamId,
}).catch((error) => {
console.error(error);
return null;
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!team) {
if (!hasAccess) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
@ -187,16 +184,14 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Envelope item not found' }, 404);
}
const team = await getTeamById({
const hasDownloadAccess = await checkEnvelopeFileAccess({
userId: session.user.id,
teamId: envelope.teamId,
}).catch((error) => {
console.error(error);
return null;
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!team) {
if (!hasDownloadAccess) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,

View file

@ -5,13 +5,13 @@ import { z } from 'zod';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import type { DocumentDataVersion } from '@documenso/lib/types/document';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
import { checkEnvelopeFileAccess } from '../files.helpers';
const route = new Hono<HonoEnv>();
@ -67,7 +67,9 @@ route.get(
envelope: {
select: {
id: true,
type: true,
teamId: true,
templateType: true,
},
},
},
@ -78,12 +80,14 @@ route.get(
}
// Check whether the user has access to the document.
const team = await getTeamById({
const hasAccess = await checkEnvelopeFileAccess({
userId,
teamId: envelopeItem.envelope.teamId,
}).catch(() => null);
envelopeType: envelopeItem.envelope.type,
templateType: envelopeItem.envelope.templateType,
});
if (!team) {
if (!hasAccess) {
return c.json({ error: 'Not found' }, 404);
}

44
package-lock.json generated
View file

@ -693,6 +693,7 @@
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"nuqs": "^2.8.9",
"papaparse": "^5.5.3",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
@ -29859,6 +29860,49 @@
"js-sdsl": "4.3.0"
}
},
"node_modules/nuqs": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.8.9.tgz",
"integrity": "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/franky47"
},
"peerDependencies": {
"@remix-run/react": ">=2",
"@tanstack/react-router": "^1",
"next": ">=14.2.0",
"react": ">=18.2.0 || ^19.0.0-0",
"react-router": "^5 || ^6 || ^7",
"react-router-dom": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"@remix-run/react": {
"optional": true
},
"@tanstack/react-router": {
"optional": true
},
"next": {
"optional": true
},
"react-router": {
"optional": true
},
"react-router-dom": {
"optional": true
}
}
},
"node_modules/nuqs/node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/nypm": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.0.tgz",

View file

@ -136,7 +136,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(4).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Type' }).click();
@ -163,7 +163,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('combobox').nth(6).click();
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
await triggerAutosave(page);
@ -187,7 +187,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(6).click();
await page.getByRole('combobox').nth(7).click();
await page.getByRole('option', { name: 'Europe/London' }).click();
await triggerAutosave(page);
@ -247,7 +247,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
const newExternalId = 'MULTI-TEST-123';
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await page.getByRole('combobox').nth(6).click();
await page.getByRole('combobox').nth(7).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
await triggerAutosave(page);

View file

@ -0,0 +1,553 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { TemplateType } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
const nanoid = customAlphabet('1234567890abcdef', 10);
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
/**
* Helper to set up the standard two-team-one-org scenario:
*
* - One organisation with two teams (teamA and teamB).
* - ownerA owns both the org and teamA.
* - memberB is only a member of teamB (no relation to teamA).
* - An ORGANISATION template is created on teamA.
*/
const seedOrgTemplateScenario = async () => {
const { user: ownerA, organisation, team: teamA } = await seedUser();
const teamBUrl = `team-b-${nanoid()}`;
await createTeam({
userId: ownerA.id,
teamName: `Team B ${teamBUrl}`,
teamUrl: teamBUrl,
organisationId: organisation.id,
inheritMembers: false,
});
const teamB = await prisma.team.findFirstOrThrow({
where: { url: teamBUrl },
});
// memberB is only added to teamB, not teamA.
const memberB = await seedTeamMember({
teamId: teamB.id,
role: 'MEMBER',
});
const orgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Org Template ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
},
});
return { ownerA, organisation, teamA, teamB, memberB, orgTemplate };
};
/**
* Helper to make tRPC queries via the authenticated page context.
*/
const trpcQuery = async (
page: Page,
procedure: string,
input: Record<string, unknown>,
teamId?: number,
) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: input }));
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`;
const headers: Record<string, string> = {};
if (teamId) {
headers['x-team-id'] = teamId.toString();
}
const res = await page.context().request.get(url, { headers });
return { res, json: res.ok() ? await res.json() : null };
};
const trpcMutation = async (
page: Page,
procedure: string,
input: Record<string, unknown>,
teamId?: number,
) => {
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}`;
const headers: Record<string, string> = {
'content-type': 'application/json',
};
if (teamId) {
headers['x-team-id'] = teamId.toString();
}
const res = await page.context().request.post(url, {
data: JSON.stringify({ json: input }),
headers,
});
return { res, json: res.ok() ? await res.json() : null };
};
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
test.describe('Organisation Templates - UI Tabs', () => {
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
const { ownerA, teamA } = await seedOrgTemplateScenario();
await apiSignin({
page,
email: ownerA.email,
redirectPath: `/t/${teamA.url}/templates`,
});
await expect(page.getByTestId('template-tab-team')).toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
});
test('should not show tabs for personal organisations', async ({ page }) => {
const { user, team } = await seedUser({ isPersonalOrganisation: true });
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates`,
});
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
});
});
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
test.describe('Organisation Templates - Listing', () => {
test('should list org templates from other teams under the Organisation tab', async ({
page,
}) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
page,
email: memberB.email,
redirectPath: `/t/${teamB.url}/templates`,
});
// Team tab should show 0 (memberB has no templates on teamB).
await expect(page.getByTestId('template-tab-team')).toBeVisible();
// Switch to Organisation tab.
await page.getByTestId('template-tab-organisation').click();
// Should see the org template from teamA.
await expect(page.getByText(orgTemplate.title)).toBeVisible();
});
test('should not show private templates from other teams under Organisation tab', async ({
page,
}) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
// Create a private template on teamA — should NOT appear in org tab.
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Private Template ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({
page,
email: memberB.email,
redirectPath: `/t/${teamB.url}/templates?view=organisation`,
});
await expect(page.getByText(privateTemplate.title)).not.toBeVisible();
});
});
// ─── UI: Organisation Template Detail Page ───────────────────────────────────
test.describe('Organisation Templates - Detail Page', () => {
test('should show org template detail but hide edit/delete actions', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
page,
email: memberB.email,
redirectPath: `/t/${teamB.url}/templates?view=organisation`,
});
// Click into the org template.
await page.getByText(orgTemplate.title).click();
// Should see the template title.
await expect(page.getByRole('heading', { name: orgTemplate.title })).toBeVisible();
// Should see the Use button.
await expect(page.getByRole('button', { name: 'Use' })).toBeVisible();
// Should NOT see the Edit Template button.
await expect(page.getByRole('link', { name: 'Edit Template' })).not.toBeVisible();
});
});
// ─── API: findOrganisationTemplates ──────────────────────────────────────────
test.describe('Organisation Templates - findOrganisationTemplates API', () => {
test('should return org templates for a member of a sibling team', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberB.email });
const { res, json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
expect(res.ok()).toBeTruthy();
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).toContain(orgTemplate.title);
});
test('should not return private templates from sibling teams', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Private No Show ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({ page, email: memberB.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(privateTemplate.title);
});
test('should not return org templates to a user outside the organisation', async ({ page }) => {
const { orgTemplate } = await seedOrgTemplateScenario();
// Create a completely separate user with their own org.
const { user: outsider, team: outsiderTeam } = await seedUser();
await apiSignin({ page, email: outsider.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
outsiderTeam.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(orgTemplate.title);
});
test('should respect document visibility based on the viewer team role', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const everyoneTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Visibility Everyone ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: 'EVERYONE',
},
});
const adminOnlyTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Visibility Admin ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: 'ADMIN',
},
});
// memberB has role MEMBER on teamB — should only see EVERYONE visibility.
await apiSignin({ page, email: memberB.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).toContain(everyoneTemplate.title);
expect(titles).not.toContain(adminOnlyTemplate.title);
await apiSignout({ page });
// ownerA has role ADMIN on teamA — should see both.
await apiSignin({ page, email: ownerA.email });
const { json: adminJson } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamA.id,
);
const adminTitles = adminJson.result.data.json.data.map((t: { title: string }) => t.title);
expect(adminTitles).toContain(everyoneTemplate.title);
expect(adminTitles).toContain(adminOnlyTemplate.title);
});
});
// ─── API: getOrganisationTemplateById ────────────────────────────────────────
test.describe('Organisation Templates - getOrganisationTemplateById API', () => {
test('should allow a sibling team member to fetch an org template', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberB.email });
const { res, json } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: orgTemplate.id },
teamB.id,
);
expect(res.ok()).toBeTruthy();
expect(json.result.data.json.title).toBe(orgTemplate.title);
});
test('should reject access from a user outside the organisation', async ({ page }) => {
const { orgTemplate } = await seedOrgTemplateScenario();
const { user: outsider, team: outsiderTeam } = await seedUser();
await apiSignin({ page, email: outsider.email });
const { res } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: orgTemplate.id },
outsiderTeam.id,
);
// Should fail — outsider is not in the same org.
expect(res.ok()).toBeFalsy();
});
test('should reject fetching a private template via the org endpoint', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Private ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({ page, email: memberB.email });
const { res } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: privateTemplate.id },
teamB.id,
);
// Should fail — template is PRIVATE, not ORGANISATION.
expect(res.ok()).toBeFalsy();
});
});
// ─── API: createDocumentFromTemplate with org template ───────────────────────
test.describe('Organisation Templates - Use from different team', () => {
test('should allow creating a document from an org template owned by a sibling team', async ({
page,
}) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
// Add a recipient to the org template so we can use it.
await prisma.recipient.create({
data: {
email: 'recipient@test.documenso.com',
name: 'Recipient',
token: Math.random().toString().slice(2, 7),
envelopeId: orgTemplate.id,
},
});
const orgTemplateWithRecipients = await prisma.envelope.findFirstOrThrow({
where: { id: orgTemplate.id },
include: { recipients: true },
});
await apiSignin({ page, email: memberB.email });
const templateId = mapSecondaryIdToTemplateId(orgTemplateWithRecipients.secondaryId);
const { res } = await trpcMutation(
page,
'template.createDocumentFromTemplate',
{
templateId,
recipients: orgTemplateWithRecipients.recipients.map((r) => ({
id: r.id,
email: r.email,
name: r.name ?? '',
})),
},
teamB.id,
);
expect(res.ok()).toBeTruthy();
});
});
// ─── Adversarial: Cross-organisation access ──────────────────────────────────
test.describe('Organisation Templates - Adversarial', () => {
test('should not allow accessing org template from a different organisation via getEnvelopeById', async ({
page,
}) => {
const { orgTemplate } = await seedOrgTemplateScenario();
const { user: outsider, team: outsiderTeam } = await seedUser();
await apiSignin({ page, email: outsider.email });
// Try to fetch via the standard envelope.get endpoint.
const { res } = await trpcQuery(
page,
'envelope.get',
{ envelopeId: orgTemplate.id },
outsiderTeam.id,
);
expect(res.ok()).toBeFalsy();
});
test('should not allow a sibling team member to fetch a private template via org endpoint', async ({
page,
}) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Adversarial Private ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({ page, email: memberB.email });
// Attempt 1: Try via org template endpoint.
const { res: orgRes } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: privateTemplate.id },
teamB.id,
);
expect(orgRes.ok()).toBeFalsy();
// Attempt 2: Try via standard envelope endpoint.
const { res: envelopeRes } = await trpcQuery(
page,
'envelope.get',
{ envelopeId: privateTemplate.id },
teamB.id,
);
expect(envelopeRes.ok()).toBeFalsy();
});
test('should not list org templates from a completely unrelated organisation', async ({
page,
}) => {
// Create scenario A.
const { orgTemplate: orgTemplateA } = await seedOrgTemplateScenario();
// Create scenario B (separate org, separate teams).
const { memberB: memberFromOrgB, teamB: teamFromOrgB } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberFromOrgB.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamFromOrgB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(orgTemplateA.title);
});
test('should not allow unauthenticated access to org template endpoints', async ({ page }) => {
const { orgTemplate, teamB } = await seedOrgTemplateScenario();
// No apiSignin — unauthenticated.
const { res: findRes } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
expect(findRes.ok()).toBeFalsy();
expect(findRes.status()).toBe(401);
const { res: getRes } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: orgTemplate.id },
teamB.id,
);
expect(getRes.ok()).toBeFalsy();
expect(getRes.status()).toBe(401);
});
test('should not return org template data via findTemplates (team endpoint)', async ({
page,
}) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberB.email });
// The standard findTemplates endpoint should NOT include org templates from other teams.
const { json } = await trpcQuery(
page,
'template.findTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(orgTemplate.title);
});
});

View file

@ -36,6 +36,9 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
title: true,
userId: true,
internalVersion: true,
templateType: true,
publicTitle: true,
publicDescription: true,
envelopeItems: {
include: {
documentData: {
@ -87,6 +90,11 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
}),
]);
const duplicatedTemplateType =
envelope.templateType === 'ORGANISATION' && envelope.teamId !== teamId
? 'PRIVATE'
: envelope.templateType ?? undefined;
const duplicatedEnvelope = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
@ -99,6 +107,9 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
documentMetaId: createdDocumentMeta.id,
authOptions: envelope.authOptions || undefined,
visibility: envelope.visibility,
templateType: duplicatedTemplateType,
publicTitle: envelope.publicTitle ?? undefined,
publicDescription: envelope.publicDescription ?? undefined,
source:
envelope.type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
},

View file

@ -1,5 +1,4 @@
import type { Prisma } from '@prisma/client';
import type { EnvelopeType } from '@prisma/client';
import type { EnvelopeType, Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';

View file

@ -61,6 +61,7 @@ import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
type FinalRecipient = Pick<
Recipient,
@ -312,29 +313,43 @@ export const createDocumentFromTemplate = async ({
attachments,
formValues,
}: CreateDocumentFromTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
const templateInclude = {
recipients: {
include: {
fields: true,
},
},
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
} as const;
const { envelopeWhereInput, team: callerTeam } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const template = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: {
include: {
fields: true,
},
},
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
},
});
const [teamTemplate, organisationTemplate] = await Promise.all([
prisma.envelope.findFirst({
where: envelopeWhereInput,
include: templateInclude,
}),
prisma.envelope.findFirst({
where: getOrganisationTemplateWhereInput({
id,
organisationId: callerTeam.organisationId,
teamRole: callerTeam.currentTeamRole,
}),
include: templateInclude,
}),
]);
const template = teamTemplate ?? organisationTemplate;
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@ -541,7 +556,7 @@ export const createDocumentFromTemplate = async ({
templateId: legacyTemplateId, // The template this envelope was created from.
userId,
folderId,
teamId: template.teamId,
teamId,
title: finalEnvelopeTitle,
envelopeItems: {
createMany: {

View file

@ -0,0 +1,84 @@
import { EnvelopeType, type Prisma, TemplateType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { type FindResultResponse } from '../../types/search-params';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
export type FindOrganisationTemplatesOptions = {
userId: number;
teamId: number;
page?: number;
perPage?: number;
};
export const findOrganisationTemplates = async ({
userId,
teamId,
page = 1,
perPage = 10,
}: FindOrganisationTemplatesOptions) => {
const [team, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
}),
]);
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId: team.organisationId,
},
};
const templateInclude = {
team: {
select: {
id: true,
url: true,
name: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
} as const;
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where,
include: templateInclude,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({ where }),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

View file

@ -24,8 +24,6 @@ export const findTemplates = async ({
perPage = 10,
folderId,
}: FindTemplatesOptions) => {
const whereFilter: Prisma.EnvelopeWhereInput[] = [];
const { teamRole } = await getMemberRoles({
teamId,
reference: {
@ -34,63 +32,55 @@ export const findTemplates = async ({
},
});
whereFilter.push(
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: [
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
},
},
{ userId, teamId },
],
},
);
{ userId, teamId },
],
},
folderId ? { folderId } : { folderId: null },
],
};
if (folderId) {
whereFilter.push({ folderId });
} else {
whereFilter.push({ folderId: null });
}
const templateInclude = {
team: {
select: {
id: true,
url: true,
name: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
} as const;
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: whereFilter,
},
include: {
team: {
select: {
id: true,
url: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
},
where,
include: templateInclude,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: whereFilter,
},
}),
prisma.envelope.count({ where }),
]);
return {

View file

@ -0,0 +1,136 @@
import type { Prisma, TeamMemberRole } from '@prisma/client';
import { EnvelopeType, TemplateType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
export type GetOrganisationTemplateByIdOptions = {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
};
/**
* Get an organisation template by ID.
*
* This validates that the caller's team belongs to the same organisation as the template's team,
* that the template is of type ORGANISATION, and that the template's visibility is permitted
* for the caller's role on their own team.
*/
export const getOrganisationTemplateById = async ({
id,
userId,
teamId,
}: GetOrganisationTemplateByIdOptions) => {
const [callerTeam, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
}),
]);
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.TEMPLATE),
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId: callerTeam.organisationId,
},
},
include: {
envelopeItems: {
include: {
documentData: true,
},
orderBy: {
order: 'asc',
},
},
folder: true,
documentMeta: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
fields: true,
team: {
select: {
id: true,
url: true,
},
},
directLink: {
select: {
directTemplateRecipientId: true,
enabled: true,
id: true,
token: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation template not found',
});
}
return {
...envelope,
user: {
id: envelope.user.id,
name: envelope.user.name || '',
email: envelope.user.email,
},
};
};
/**
* Build a where input for querying an organisation template.
*
* Matches a TEMPLATE envelope with templateType ORGANISATION belonging to any team
* within the provided organisation, respecting the caller's team role visibility.
*/
export const getOrganisationTemplateWhereInput = ({
id,
organisationId,
teamRole,
}: {
id: EnvelopeIdOptions;
organisationId: string;
teamRole: TeamMemberRole;
}): Prisma.EnvelopeWhereInput => {
return {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.TEMPLATE),
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId,
},
};
};

View file

@ -34,19 +34,23 @@ export const searchTemplatesWithKeyword = async ({
const teamIds = [...teamGroupsByTeamId.keys()];
const titleOrRecipientMatch: Prisma.EnvelopeWhereInput = {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
};
const filters: Prisma.EnvelopeWhereInput[] = [
// Templates owned by the user matching title or recipient email.
{
userId,
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
...titleOrRecipientMatch,
},
];
@ -55,14 +59,7 @@ export const searchTemplatesWithKeyword = async ({
filters.push({
teamId: { in: teamIds },
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
...titleOrRecipientMatch,
});
}
@ -85,6 +82,7 @@ export const searchTemplatesWithKeyword = async ({
},
team: {
select: {
id: true,
url: true,
},
},
@ -122,6 +120,7 @@ export const searchTemplatesWithKeyword = async ({
.slice(0, limit)
.map((envelope) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
const path = `${formatTemplatesPath(envelope.team.url)}/${legacyTemplateId}`;
return {

View file

@ -146,6 +146,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
team: TeamSchema.pick({
id: true,
url: true,
name: true,
}).nullable(),
fields: ZFieldSchema.array(),
recipients: ZRecipientLiteSchema.array(),

View file

@ -0,0 +1 @@
ALTER TYPE "TemplateType" ADD VALUE 'ORGANISATION';

View file

@ -954,6 +954,7 @@ model TeamEmailVerification {
enum TemplateType {
PUBLIC
PRIVATE
ORGANISATION
}
model TemplateDirectLink {

View file

@ -1,3 +1,4 @@
import { TemplateType } from '@prisma/client';
import { z } from 'zod';
import {
@ -33,6 +34,7 @@ export const ZUpdateEnvelopeRequestSchema = z.object({
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
folderId: z.string().nullish(),
templateType: z.nativeEnum(TemplateType).optional(),
})
.optional(),
meta: ZDocumentMetaUpdateSchema.optional(),

View file

@ -55,6 +55,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
select: {
id: true,
url: true,
name: true,
},
},
documentMeta: {
@ -97,6 +98,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
? {
id: envelope.team.id,
url: envelope.team.url,
name: envelope.team.name,
}
: null,
fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),

View file

@ -18,7 +18,9 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/
import { createTemplateDirectLink } from '@documenso/lib/server-only/template/create-template-direct-link';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/delete-template-direct-link';
import { findOrganisationTemplates } from '@documenso/lib/server-only/template/find-organisation-templates';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getOrganisationTemplateById } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
@ -46,8 +48,11 @@ import {
ZDeleteTemplateMutationSchema,
ZDuplicateTemplateMutationSchema,
ZDuplicateTemplateResponseSchema,
ZFindOrganisationTemplatesRequestSchema,
ZFindTemplatesRequestSchema,
ZFindTemplatesResponseSchema,
ZGetOrganisationTemplateByIdRequestSchema,
ZGetOrganisationTemplateByIdResponseSchema,
ZGetTemplateByIdRequestSchema,
ZGetTemplateByIdResponseSchema,
ZToggleTemplateDirectLinkRequestSchema,
@ -122,6 +127,75 @@ export const templateRouter = router({
};
}),
/**
* @private
*/
findOrganisationTemplates: authenticatedProcedure
.input(ZFindOrganisationTemplatesRequestSchema)
.output(ZFindTemplatesResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId } = ctx;
const result = await findOrganisationTemplates({
userId: ctx.user.id,
teamId,
...input,
});
// Remapping for backwards compatibility.
return {
...result,
data: result.data.map((envelope) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
return {
id: legacyTemplateId,
envelopeId: envelope.id,
type: envelope.templateType,
visibility: envelope.visibility,
externalId: envelope.externalId,
title: envelope.title,
userId: envelope.userId,
teamId: envelope.teamId,
authOptions: envelope.authOptions,
createdAt: envelope.createdAt,
updatedAt: envelope.updatedAt,
publicTitle: envelope.publicTitle,
publicDescription: envelope.publicDescription,
folderId: envelope.folderId,
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
team: envelope.team,
fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
recipients: envelope.recipients.map((recipient) =>
mapRecipientToLegacyRecipient(recipient, envelope),
),
templateMeta: envelope.documentMeta,
directLink: envelope.directLink,
};
}),
};
}),
/**
* @private
*/
getOrganisationTemplateById: authenticatedProcedure
.input(ZGetOrganisationTemplateByIdRequestSchema)
.output(ZGetOrganisationTemplateByIdResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId } = ctx;
const { envelopeId } = input;
return await getOrganisationTemplateById({
id: {
type: 'envelopeId',
id: envelopeId,
},
userId: ctx.user.id,
teamId,
});
}),
/**
* @public
*/

View file

@ -22,6 +22,7 @@ import {
ZDocumentMetaTypedSignatureEnabledSchema,
ZDocumentMetaUploadSignatureEnabledSchema,
} from '@documenso/lib/types/document-meta';
import { ZEnvelopeSchema } from '@documenso/lib/types/envelope';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
@ -295,6 +296,8 @@ export const ZFindTemplatesRequestSchema = ZFindSearchParamsSchema.extend({
folderId: z.string().describe('The ID of the folder to filter templates by.').optional(),
});
export const ZFindOrganisationTemplatesRequestSchema = ZFindSearchParamsSchema;
export const ZFindTemplatesResponseSchema = ZFindResultResponse.extend({
data: ZTemplateManySchema.array(),
});
@ -308,6 +311,12 @@ export const ZGetTemplateByIdRequestSchema = z.object({
export const ZGetTemplateByIdResponseSchema = ZTemplateSchema;
export const ZGetOrganisationTemplateByIdRequestSchema = z.object({
envelopeId: z.string(),
});
export const ZGetOrganisationTemplateByIdResponseSchema = ZEnvelopeSchema;
export const ZBulkSendTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number(),

View file

@ -0,0 +1,76 @@
import React, { forwardRef } from 'react';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TemplateType } from '@prisma/client';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type TemplateTypeSelectProps = SelectProps;
export const TemplateTypeSelect = forwardRef<HTMLButtonElement, TemplateTypeSelectProps>(
({ ...props }, ref) => {
useLingui();
return (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={TemplateType.PRIVATE}>{t`Private`}</SelectItem>
<SelectItem value={TemplateType.PUBLIC}>{t`Public`}</SelectItem>
<SelectItem value={TemplateType.ORGANISATION}>{t`Organisation`}</SelectItem>
</SelectContent>
</Select>
);
},
);
TemplateTypeSelect.displayName = 'TemplateTypeSelect';
export const TemplateTypeTooltip = ({
organisationTeamCount,
}: {
organisationTeamCount: number;
}) => {
return (
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<p>
<Trans>
<strong>Private</strong> templates can only be used by your team.
</Trans>
</p>
<p>
<Trans>
<strong>Public</strong> templates are linked to your public profile.
</Trans>
</p>
{organisationTeamCount >= 2 && (
<p>
<Trans>
<strong>Organisation</strong> templates are shared across all teams in your
organisation but can only be edited by the owning team.
</Trans>
</p>
)}
</TooltipContent>
</Tooltip>
);
};

View file

@ -20,6 +20,8 @@ const badgeVariants = cva(
'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20',
secondary:
'bg-blue-50 text-blue-700 ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30',
orange:
'bg-orange-50 text-orange-700 ring-orange-700/10 dark:bg-orange-400/10 dark:text-orange-400 dark:ring-orange-400/30',
},
size: {
small: 'px-1.5 py-0.5 text-xs',

View file

@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentVisibility, TeamMemberRole, TemplateType } from '@prisma/client';
import { DocumentDistributionMethod, type Field, type Recipient } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
@ -36,6 +36,10 @@ import {
DocumentVisibilitySelect,
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import {
TemplateTypeSelect,
TemplateTypeTooltip,
} from '@documenso/ui/components/template/template-type-select';
import {
Accordion,
AccordionContent,
@ -109,6 +113,7 @@ export const AddTemplateSettingsFormPartial = ({
defaultValues: {
title: template.title,
externalId: template.externalId || undefined,
templateType: template.type || TemplateType.PRIVATE,
visibility: template.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
@ -325,6 +330,29 @@ export const AddTemplateSettingsFormPartial = ({
/>
)}
<FormField
control={form.control}
name="templateType"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Template type</Trans>
<TemplateTypeTooltip organisationTeamCount={organisation.teams.length} />
</FormLabel>
<FormControl>
<TemplateTypeSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.distributionMethod"

View file

@ -1,6 +1,6 @@
import { msg } from '@lingui/core/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DocumentVisibility, TemplateType } from '@prisma/client';
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@ -21,6 +21,7 @@ import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
externalId: z.string().optional(),
templateType: z.nativeEnum(TemplateType).optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: z
.array(z.union([ZDocumentAccessAuthTypesSchema, z.literal('-1')]))