feat: add organisation template type (#2611)

This commit is contained in:
Ephraim Duncan
2026-03-16 14:29:34 +00:00
committed by GitHub
parent 943a0b50e3
commit 36bbd97514
41 changed files with 1702 additions and 279 deletions
@@ -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`),
+11 -8
View File
@@ -1,3 +1,4 @@
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import {
Links,
Meta,
@@ -138,15 +139,17 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
</div>
)} */}
<SessionProvider initialSession={session}>
<TooltipProvider>
<TrpcProvider>
{children}
<NuqsAdapter>
<SessionProvider initialSession={session}>
<TooltipProvider>
<TrpcProvider>
{children}
<Toaster />
</TrpcProvider>
</TooltipProvider>
</SessionProvider>
<Toaster />
</TrpcProvider>
</TooltipProvider>
</SessionProvider>
</NuqsAdapter>
<script
dangerouslySetInnerHTML={{
@@ -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>
);
+1
View File
@@ -53,6 +53,7 @@
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"nuqs": "^2.8.9",
"papaparse": "^5.5.3",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
+69 -1
View File
@@ -1,9 +1,17 @@
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
import {
type DocumentDataType,
DocumentStatus,
type EnvelopeType,
type TemplateType,
} from '@prisma/client';
import { EnvelopeType as EnvelopeTypeEnum, TemplateType as TemplateTypeEnum } from '@prisma/client';
import contentDisposition from 'content-disposition';
import { type Context } from 'hono';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../router';
@@ -79,3 +87,63 @@ export const handleEnvelopeItemFileRequest = async ({
return c.body(file);
};
type CheckEnvelopeFileAccessOptions = {
userId: number;
teamId: number;
envelopeType: EnvelopeType;
templateType: TemplateType;
};
/**
* Check whether a user has access to an envelope's file.
*
* First checks team membership. If that fails and the envelope is an
* ORGANISATION template (not a document), falls back to checking whether
* the user belongs to any team in the same organisation.
*/
export const checkEnvelopeFileAccess = async ({
userId,
teamId,
envelopeType,
templateType,
}: CheckEnvelopeFileAccessOptions): Promise<boolean> => {
const team = await getTeamById({ userId, teamId }).catch(() => null);
if (team) {
return true;
}
if (
envelopeType === EnvelopeTypeEnum.TEMPLATE &&
templateType === TemplateTypeEnum.ORGANISATION
) {
const orgAccess = await prisma.team.findFirst({
where: {
id: teamId,
organisation: {
teams: {
some: {
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: { userId },
},
},
},
},
},
},
},
},
},
select: { id: true },
});
return orgAccess !== null;
}
return false;
};
+10 -15
View File
@@ -6,13 +6,12 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../router';
import { handleEnvelopeItemFileRequest } from './files.helpers';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers';
import {
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
@@ -119,16 +118,14 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Envelope item not found' }, 404);
}
const team = await getTeamById({
userId: userId,
const hasAccess = await checkEnvelopeFileAccess({
userId,
teamId: envelope.teamId,
}).catch((error) => {
console.error(error);
return null;
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!team) {
if (!hasAccess) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
@@ -187,16 +184,14 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Envelope item not found' }, 404);
}
const team = await getTeamById({
const hasDownloadAccess = await checkEnvelopeFileAccess({
userId: session.user.id,
teamId: envelope.teamId,
}).catch((error) => {
console.error(error);
return null;
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!team) {
if (!hasDownloadAccess) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
@@ -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);
}