mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
feat: add organisation template type (#2611)
This commit is contained in:
parent
943a0b50e3
commit
36bbd97514
41 changed files with 1702 additions and 279 deletions
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"title": "Templates",
|
||||
"pages": ["create", "use"]
|
||||
"pages": ["create", "use", "organisation-templates"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 : [],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> & {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
44
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
553
packages/app-tests/e2e/templates/organisation-templates.spec.ts
Normal file
553
packages/app-tests/e2e/templates/organisation-templates.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE "TemplateType" ADD VALUE 'ORGANISATION';
|
||||
|
|
@ -954,6 +954,7 @@ model TeamEmailVerification {
|
|||
enum TemplateType {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
ORGANISATION
|
||||
}
|
||||
|
||||
model TemplateDirectLink {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
76
packages/ui/components/template/template-type-select.tsx
Normal file
76
packages/ui/components/template/template-type-select.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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')]))
|
||||
|
|
|
|||
Loading…
Reference in a new issue