mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
25 Commits
v1.9.0-rc.
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
| 80c0468df2 | |||
| 3ca5e47ed4 | |||
| f34446c25e | |||
| aae790c73c | |||
| 8a668f52aa | |||
| 4e00dce226 | |||
| fe7ad56181 | |||
| 291d21006b | |||
| 3633e61bcb | |||
| 0b4b47d825 | |||
| 3f934080e3 | |||
| a99b48ce05 | |||
| 01122dbd33 | |||
| 7d1ded8b04 | |||
| 5b475daba7 | |||
| d4baab195c | |||
| 1bcea63a68 | |||
| efc23eaf75 | |||
| 7929e2f841 | |||
| a274e9b75a | |||
| 89154e6230 | |||
| 087d8bf150 | |||
| 894e857826 | |||
| 670393d0d0 | |||
| 08d94cf2b2 |
@ -11,6 +11,7 @@
|
||||
"templates": "Templates",
|
||||
"direct-links": "Direct Signing Links",
|
||||
"document-visibility": "Document Visibility",
|
||||
"teams": "Teams",
|
||||
"-- Legal Overview": {
|
||||
"type": "separator",
|
||||
"title": "Legal Overview"
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
---
|
||||
title: Document Visibility
|
||||
description: Learn how to control the visibility of your team documents.
|
||||
---
|
||||
|
||||
# Team's Document Visibility
|
||||
|
||||
By default, all documents created in a team are visible to all team members. However, you can control the visibility of your documents by changing the document's visibility settings.
|
||||
|
||||
To set the visibility of a document, click on the **Document visibility** dropdown in the document's settings panel.
|
||||
|
||||

|
||||
|
||||
The document visibility can be set to one of the following options:
|
||||
|
||||
- **Everyone** - The document is visible to all team members.
|
||||
- **Managers and above** - The document is visible to people with the role of Manager or above.
|
||||
- **Admin only** - The document is only visible to the team's admins.
|
||||
5
apps/documentation/pages/users/teams/_meta.json
Normal file
5
apps/documentation/pages/users/teams/_meta.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"general-settings": "General Settings",
|
||||
"document-visibility": "Document Visibility",
|
||||
"sender-details": "Email Sender Details"
|
||||
}
|
||||
45
apps/documentation/pages/users/teams/document-visibility.mdx
Normal file
45
apps/documentation/pages/users/teams/document-visibility.mdx
Normal file
@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Document Visibility
|
||||
description: Learn how to control the visibility of your team documents.
|
||||
---
|
||||
|
||||
import { Callout } from 'nextra/components';
|
||||
|
||||
# Team's Document Visibility
|
||||
|
||||
The default document visibility option allows you to control who can view and access the documents uploaded to your team account. The document visibility can be set to one of the following options:
|
||||
|
||||
- **Everyone** - The document is visible to all team members.
|
||||
- **Managers and above** - The document is visible to team members with the role of _Manager or above_ and _Admin_.
|
||||
- **Admin only** - The document is only visible to the team's admins.
|
||||
|
||||

|
||||
|
||||
The default document visibility is set to "_EVERYONE_" by default. You can change this setting by going to the [team's general settings page](/users/teams/general-settings) and selecting a different visibility option.
|
||||
|
||||
<Callout type="warning">
|
||||
If the team member uploading the document has a role lower than the default document visibility,
|
||||
the document visibility will be set to a lower visibility level matching the team member's role.
|
||||
</Callout>
|
||||
|
||||
Here's how it works:
|
||||
|
||||
- If a user with the "_Member_" role creates a document and the default document visibility is set to "_Admin_" or "_Managers and above_", the document's visibility is set to "_Everyone_".
|
||||
- If a user with the "_Manager_" role creates a document and the default document visibility is set to "_Admin_", the document's visibility is set to "_Managers and above_".
|
||||
- Otherwise, the document's visibility is set to the default document visibility.
|
||||
|
||||
You can change the visibility of a document at any time by editing the document and selecting a different visibility option.
|
||||
|
||||

|
||||
|
||||
<Callout type="warning">
|
||||
Updating the default document visibility in the team's general settings will not affect the
|
||||
visibility of existing documents. You will need to update the visibility of each document
|
||||
individually.
|
||||
</Callout>
|
||||
|
||||
## A Note on Document Access
|
||||
|
||||
The `document owner` (the user who created the document) always has access to the document, regardless of the document's visibility settings. This means that even if a document is set to "Admins only", the document owner can still view and edit the document.
|
||||
|
||||
The `recipient` (the user who receives the document for signature, approval, etc.) also has access to the document, regardless of the document's visibility settings. This means that even if a document is set to "Admins only", the recipient can still view and sign the document.
|
||||
15
apps/documentation/pages/users/teams/general-settings.mdx
Normal file
15
apps/documentation/pages/users/teams/general-settings.mdx
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
title: General Settings
|
||||
description: Learn how to manage your team's General settings.
|
||||
---
|
||||
|
||||
# General Settings
|
||||
|
||||
You can manage your team's general settings by clicking on the **General Settings** tab in the team's settings dashboard.
|
||||
|
||||

|
||||
|
||||
The general settings page allows you to update the following settings:
|
||||
|
||||
- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/teams/document-visibility).
|
||||
- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. Learn more about [sender details](/users/teams/sender-details).
|
||||
14
apps/documentation/pages/users/teams/sender-details.mdx
Normal file
14
apps/documentation/pages/users/teams/sender-details.mdx
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
title: Email Sender Details
|
||||
description: Learn how to update the sender details for your team's email notifications.
|
||||
---
|
||||
|
||||
## Sender Details
|
||||
|
||||
If the **Sender Details** setting is enabled, the emails sent by the team will include the sender's name. The email will say:
|
||||
|
||||
> "Example User" on behalf of "Example Team" has invited you to sign "document.pdf"
|
||||
|
||||
If the **Sender Details** setting is disabled, the emails sent by the team will not include the sender's name. The email will say:
|
||||
|
||||
> "Example Team" has invited you to sign "document.pdf"
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
BIN
apps/documentation/public/teams/team-general-settings.webp
Normal file
BIN
apps/documentation/public/teams/team-general-settings.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
64
apps/marketing/content/blog/project-babel.mdx
Normal file
64
apps/marketing/content/blog/project-babel.mdx
Normal file
@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Project Babel
|
||||
description: We are announcing Project Babel - an initiative to support all languages of the world on Documenso.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-10-28
|
||||
tags:
|
||||
- Languages
|
||||
- Community
|
||||
- Open Source
|
||||
---
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/babel.png"
|
||||
width="800"
|
||||
height="800"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">The tower of Babel to add some Gravitas to this project.</figcaption>
|
||||
</figure>
|
||||
|
||||
> TLDR; We are opening up translations to the community. Read this to add a language: https://documen.so/babel-fish
|
||||
|
||||
## Announcing Project Babel: Powering Documenso with Global Language Support
|
||||
|
||||
At Documenso, we believe that open source is more than just a software philosophy—it’s a way to build solutions that are open to all, technically and economically. Now, we’re happy to take that mission further with Project Babel, a community-driven initiative designed to bring worldwide language support to the Documenso platform. This project aims to enable Documenso to offer more languages than any other e-signature tool by harnessing the power of the open source community.
|
||||
|
||||
## Why Language Support Matters
|
||||
|
||||
We already have customers from 36 different countries and are seeing traffic from even more. When it comes to critical tools like signature platforms, having a user interface in your native language can make all the difference. No matter who and where you are, your team deserves tools that are fully accessible and intuitive. That’s why we’re making it our goal to support every language, and we need your help to make it happen! We’re building Documenso as a truly global public commodity.
|
||||
|
||||
## The Vision Behind Project Babel
|
||||
|
||||
The goal of Project Babel is simple but bold: We want to out-ship and out-customize every other document signing tool worldwide. How? By leveraging the collective power of our global community.
|
||||
|
||||
Unlike closed-source software, where localization means waiting for updates from the core team, Project Babel lets anyone contribute a new language, improve an existing translation, or customize the experience to meet local cultural nuances. This flexibility isn’t just a bonus—it’s the baseline for truly global products.
|
||||
|
||||
Through Project Babel, you can help make Documenso the most inclusive e-signature tool. Whether by adding a language you speak or fine-tuning existing translations, you’re shaping a platform that works for everyone, everywhere.
|
||||
|
||||
## How You Can Contribute
|
||||
|
||||
We’ve created a simple GitHub-based contribution flow to get started. We’ll improve the flow and user experience as the project progresses. As always, your contributions are highly valued.
|
||||
|
||||
Check out the contribution guide here: [https://documen.so/babel-fish](https://documen.so/babel-fish)
|
||||
|
||||
## Open Source Makes It Possible
|
||||
|
||||
Closed-source solutions can’t keep up with the speed or depth of customization that open source offers. While other companies might take months or years to localize their products, Documenso can adapt and grow in real-time, thanks to contributions from our community. Whether it’s a small regional dialect or a widely spoken language, Project Babel ensures that Documenso evolves to meet the needs of people everywhere.
|
||||
|
||||
> More importantly, this initiative empowers users. It allows you to control your software experience, ensuring it reflects your culture, language, and unique needs.
|
||||
|
||||
Project Babel is more than a localization effort—it’s the first step toward democratizing access to highly customized software for everyone, no matter where they are or what language they speak. We’re incredibly excited about this initiative, but it can only succeed with your participation. We invite you to join us in making Documenso the most linguistically inclusive platform out there.
|
||||
|
||||
Ready to get started? Check out the full tutorial and become part of the Babel community today! Let’s build open signing for the world: https://documen.so/babel-fish
|
||||
|
||||
If you have any questions or comments, reach out on [Twitter / X](https://twitter.com/eltimuro) (DMs open) or [Discord](https://documen.so/discord).
|
||||
|
||||
Thinking about switching to a modern signing platform? Reach out anytime: [https://documen.so/sales](https://documen.so/sales)
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
BIN
apps/marketing/public/blog/babel.png
Normal file
BIN
apps/marketing/public/blog/babel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 467 KiB |
@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { ChevronDownIcon as CaretSortIcon, Loader } from 'lucide-react';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
export type SigningVolume = {
|
||||
id: number;
|
||||
name: string;
|
||||
signingVolume: number;
|
||||
createdAt: Date;
|
||||
planId: string;
|
||||
};
|
||||
|
||||
type LeaderboardTableProps = {
|
||||
signingVolume: SigningVolume[];
|
||||
totalPages: number;
|
||||
perPage: number;
|
||||
page: number;
|
||||
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export const LeaderboardTable = ({
|
||||
signingVolume,
|
||||
totalPages,
|
||||
perPage,
|
||||
page,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}: LeaderboardTableProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const debouncedSearchString = useDebouncedValue(searchString, 1000);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: () => (
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => handleColumnSort('name')}
|
||||
>
|
||||
{_(msg`Name`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) => {
|
||||
console.log('row.original', row.original);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<a
|
||||
className="text-primary underline"
|
||||
href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`}
|
||||
target="_blank"
|
||||
>
|
||||
{row.getValue('name')}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 250,
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => handleColumnSort('signingVolume')}
|
||||
>
|
||||
{_(msg`Signing Volume`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'signingVolume',
|
||||
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
|
||||
},
|
||||
{
|
||||
header: () => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => handleColumnSort('createdAt')}
|
||||
>
|
||||
{_(msg`Created`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||
},
|
||||
] satisfies DataTableColumnDef<SigningVolume>[];
|
||||
}, [sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
search: debouncedSearchString,
|
||||
page: 1,
|
||||
perPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchString]);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchString(e.target.value);
|
||||
};
|
||||
|
||||
const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
sortBy: column,
|
||||
sortOrder: sortOrder === 'asc' ? 'desc' : 'asc',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="my-6 flex flex-row gap-4"
|
||||
type="text"
|
||||
placeholder={_(msg`Search by name or email`)}
|
||||
value={searchString}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={signingVolume}
|
||||
perPage={perPage}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume-fix';
|
||||
|
||||
type SearchOptions = {
|
||||
search: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export async function search({ search, page, perPage, sortBy, sortOrder }: SearchOptions) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const results = await getSigningVolume({ search, page, perPage, sortBy, sortOrder });
|
||||
|
||||
return results;
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
|
||||
|
||||
type SearchOptions = {
|
||||
search: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export async function search({ search, page, perPage, sortBy, sortOrder }: SearchOptions) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const results = await getSigningVolume({ search, page, perPage, sortBy, sortOrder });
|
||||
|
||||
return results;
|
||||
}
|
||||
81
apps/web/src/app/(dashboard)/admin/leaderboard/page.tsx
Normal file
81
apps/web/src/app/(dashboard)/admin/leaderboard/page.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
|
||||
import { LeaderboardTable } from './data-table-leaderboard';
|
||||
import { search as search2 } from './fetch-leaderboard-fix.actions';
|
||||
import { search } from './fetch-leaderboard.actions';
|
||||
|
||||
type AdminLeaderboardProps = {
|
||||
searchParams?: {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
sortBy?: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
};
|
||||
};
|
||||
|
||||
export default async function Leaderboard({ searchParams = {} }: AdminLeaderboardProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 10;
|
||||
const searchString = searchParams.search || '';
|
||||
const sortBy = searchParams.sortBy || 'signingVolume';
|
||||
const sortOrder = searchParams.sortOrder || 'desc';
|
||||
|
||||
const { leaderboard: signingVolume, totalPages } = await search({
|
||||
search: searchString,
|
||||
page,
|
||||
perPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
const { leaderboard: signingVolume2, totalPages: totalPagesFix2 } = await search2({
|
||||
search: searchString,
|
||||
page,
|
||||
perPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Signing Volume</Trans>
|
||||
</h2>
|
||||
<div className="mt-8">
|
||||
<LeaderboardTable
|
||||
signingVolume={signingVolume}
|
||||
totalPages={totalPages}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
/>
|
||||
|
||||
<h2 className="mt-20 text-2xl font-semibold">
|
||||
<Trans>Signing Volume 2</Trans>
|
||||
</h2>
|
||||
<LeaderboardTable
|
||||
signingVolume={signingVolume2}
|
||||
totalPages={totalPagesFix2}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
|
||||
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -80,6 +80,20 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/leaderboard">
|
||||
<Trophy className="mr-2 h-5 w-5" />
|
||||
<Trans>Leaderboard</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
|
||||
@ -74,7 +74,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
|
||||
let canAccessDocument = true;
|
||||
|
||||
if (team && !isRecipient) {
|
||||
if (team && !isRecipient && document?.userId !== user.id) {
|
||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -177,8 +178,8 @@ export const EditDocumentForm = ({
|
||||
stepIndex: 3,
|
||||
},
|
||||
subject: {
|
||||
title: msg`Add Subject`,
|
||||
description: msg`Add the subject and message you wish to send to signers.`,
|
||||
title: msg`Distribute Document`,
|
||||
description: msg`Choose how the document will reach recipients`,
|
||||
stepIndex: 4,
|
||||
},
|
||||
};
|
||||
@ -307,7 +308,7 @@ export const EditDocumentForm = ({
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message } = data.meta;
|
||||
const { subject, message, distributionMethod, emailSettings } = data.meta;
|
||||
|
||||
try {
|
||||
await sendDocument({
|
||||
@ -316,16 +317,31 @@ export const EditDocumentForm = ({
|
||||
meta: {
|
||||
subject,
|
||||
message,
|
||||
distributionMethod,
|
||||
emailSettings,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Document sent`),
|
||||
description: _(msg`Your document has been sent successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
|
||||
toast({
|
||||
title: _(msg`Document sent`),
|
||||
description: _(msg`Your document has been sent successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push(documentRootPath);
|
||||
router.push(documentRootPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.DRAFT) {
|
||||
toast({
|
||||
title: _(msg`Links Generated`),
|
||||
description: _(msg`Signing links have been generated for this document.`),
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
router.push(`${documentRootPath}/${document.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
|
||||
let canAccessDocument = true;
|
||||
|
||||
if (!isRecipient) {
|
||||
if (!isRecipient && document?.userId !== user.id) {
|
||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||
|
||||
@ -145,6 +145,7 @@ export const TemplatesDataTable = ({
|
||||
<UseTemplateDialog
|
||||
templateId={row.original.id}
|
||||
templateSigningOrder={row.original.templateMeta?.signingOrder}
|
||||
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
|
||||
recipients={row.original.Recipient}
|
||||
documentRootPath={documentRootPath}
|
||||
/>
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
} from '@documenso/lib/constants/template';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { DocumentSigningOrder } from '@documenso/prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -49,7 +49,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
sendDocument: z.boolean(),
|
||||
distributeDocument: z.boolean(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
@ -93,12 +93,14 @@ export type UseTemplateDialogProps = {
|
||||
templateId: number;
|
||||
templateSigningOrder?: DocumentSigningOrder | null;
|
||||
recipients: Recipient[];
|
||||
documentDistributionMethod?: DocumentDistributionMethod;
|
||||
documentRootPath: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function UseTemplateDialog({
|
||||
recipients,
|
||||
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||
documentRootPath,
|
||||
templateId,
|
||||
templateSigningOrder,
|
||||
@ -116,7 +118,7 @@ export function UseTemplateDialog({
|
||||
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||
defaultValues: {
|
||||
sendDocument: false,
|
||||
distributeDocument: false,
|
||||
recipients: recipients
|
||||
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
||||
.map((recipient) => {
|
||||
@ -147,7 +149,7 @@ export function UseTemplateDialog({
|
||||
templateId,
|
||||
teamId: team?.id,
|
||||
recipients: data.recipients,
|
||||
sendDocument: data.sendDocument,
|
||||
distributeDocument: data.distributeDocument,
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -156,7 +158,16 @@ export function UseTemplateDialog({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push(`${documentRootPath}/${id}`);
|
||||
let documentPath = `${documentRootPath}/${id}`;
|
||||
|
||||
if (
|
||||
data.distributeDocument &&
|
||||
documentDistributionMethod === DocumentDistributionMethod.NONE
|
||||
) {
|
||||
documentPath += '?action=view-signing-links';
|
||||
}
|
||||
|
||||
router.push(documentPath);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@ -295,43 +306,76 @@ export function UseTemplateDialog({
|
||||
<div className="mt-4 flex flex-row items-center">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendDocument"
|
||||
name="distributeDocument"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
id="sendDocument"
|
||||
id="distributeDocument"
|
||||
className="h-5 w-5"
|
||||
checkClassName="dark:text-white text-primary"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="sendDocument"
|
||||
>
|
||||
<Trans>Send document</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="distributeDocument"
|
||||
>
|
||||
<Trans>Send document</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<p>
|
||||
<Trans>
|
||||
{' '}
|
||||
The document will be immediately sent to recipients if this is
|
||||
checked.
|
||||
</Trans>
|
||||
</p>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<p>
|
||||
<Trans>
|
||||
The document will be immediately sent to recipients if this is
|
||||
checked.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>Otherwise, the document will be created as a draft.</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<p>
|
||||
<Trans>
|
||||
Otherwise, the document will be created as a draft.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="distributeDocument"
|
||||
>
|
||||
<Trans>Create as pending</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<p>
|
||||
<Trans>Create the document as pending and ready to sign.</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>We won't send anything to notify recipients.</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-2">
|
||||
<Trans>
|
||||
We will generate signing links for you, which you can send to
|
||||
the recipients through your method of choice.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
@ -347,10 +391,12 @@ export function UseTemplateDialog({
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{form.getValues('sendDocument') ? (
|
||||
{!form.getValues('distributeDocument') ? (
|
||||
<Trans>Create as draft</Trans>
|
||||
) : documentDistributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<Trans>Create and send</Trans>
|
||||
) : (
|
||||
<Trans>Create as draft</Trans>
|
||||
<Trans>Create signing links</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
import { type Recipient } from '@documenso/prisma/client';
|
||||
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||
@ -53,7 +53,9 @@ export const DirectTemplatePageView = ({
|
||||
const [step, setStep] = useState<DirectTemplateStep>('configure');
|
||||
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||
|
||||
const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION_ENG[directTemplateRecipient.role];
|
||||
const recipientActionVerb = _(
|
||||
RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role].actionVerb,
|
||||
);
|
||||
|
||||
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
|
||||
configure: {
|
||||
@ -62,9 +64,8 @@ export const DirectTemplatePageView = ({
|
||||
stepIndex: 1,
|
||||
},
|
||||
sign: {
|
||||
// Todo: Translations
|
||||
title: msg`${recipientRoleDescription.actionVerb} document`,
|
||||
description: msg`${recipientRoleDescription.actionVerb} the document to complete the process.`,
|
||||
title: msg`${recipientActionVerb} document`,
|
||||
description: msg`${recipientActionVerb} the document to complete the process.`,
|
||||
stepIndex: 2,
|
||||
},
|
||||
};
|
||||
|
||||
@ -52,7 +52,13 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
|
||||
|
||||
<AvatarImageForm className="mb-8" team={team} user={session.user} />
|
||||
|
||||
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||
<UpdateTeamForm
|
||||
teamId={team.id}
|
||||
teamName={team.name}
|
||||
teamUrl={team.url}
|
||||
documentVisibility={team.teamGlobalSettings?.documentVisibility}
|
||||
includeSenderDetails={team.teamGlobalSettings?.includeSenderDetails}
|
||||
/>
|
||||
|
||||
<section className="mt-6 space-y-6">
|
||||
{(team.teamEmail || team.emailVerification) && (
|
||||
|
||||
@ -0,0 +1,319 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
const ZTeamBrandingPreferencesFormSchema = z.object({
|
||||
brandingEnabled: z.boolean(),
|
||||
brandingLogo: z
|
||||
.instanceof(File)
|
||||
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
|
||||
.refine(
|
||||
(file) => ACCEPTED_FILE_TYPES.includes(file.type),
|
||||
'Only .jpg, .png, and .webp files are accepted',
|
||||
)
|
||||
.nullish(),
|
||||
brandingUrl: z.string().url().optional().or(z.literal('')),
|
||||
brandingCompanyDetails: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
type TTeamBrandingPreferencesFormSchema = z.infer<typeof ZTeamBrandingPreferencesFormSchema>;
|
||||
|
||||
export type TeamBrandingPreferencesFormProps = {
|
||||
team: Team;
|
||||
settings?: TeamGlobalSettings | null;
|
||||
};
|
||||
|
||||
export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||
|
||||
const { mutateAsync: updateTeamBrandingSettings } =
|
||||
trpc.team.updateTeamBrandingSettings.useMutation();
|
||||
|
||||
const form = useForm<TTeamBrandingPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
brandingEnabled: settings?.brandingEnabled ?? false,
|
||||
brandingUrl: settings?.brandingUrl ?? '',
|
||||
brandingLogo: undefined,
|
||||
brandingCompanyDetails: settings?.brandingCompanyDetails ?? '',
|
||||
},
|
||||
resolver: zodResolver(ZTeamBrandingPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const isBrandingEnabled = form.watch('brandingEnabled');
|
||||
|
||||
const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
|
||||
let uploadedBrandingLogo = settings?.brandingLogo;
|
||||
|
||||
if (brandingLogo) {
|
||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||
}
|
||||
|
||||
if (brandingLogo === null) {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
await updateTeamBrandingSettings({
|
||||
teamId: team.id,
|
||||
settings: {
|
||||
brandingEnabled,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Branding preferences updated`),
|
||||
description: _(msg`Your branding preferences have been updated`),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
msg`We were unable to update your branding preferences at this time, please try again later`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.brandingLogo) {
|
||||
const file = JSON.parse(settings.brandingLogo);
|
||||
|
||||
if ('type' in file && 'data' in file) {
|
||||
void getFile(file).then((binaryData) => {
|
||||
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
|
||||
|
||||
setPreviewUrl(objectUrl);
|
||||
setHasLoadedPreview(true);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setHasLoadedPreview(true);
|
||||
}, [settings?.brandingLogo]);
|
||||
|
||||
// Cleanup ObjectURL on unmount or when previewUrl changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Enable Custom Branding</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Enable custom branding for all documents in this team.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative flex w-full flex-col gap-y-4">
|
||||
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9999]" />}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingLogo"
|
||||
render={({ field: { value: _value, onChange, ...field } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Branding Logo</FormLabel>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border-border bg-background relative h-48 w-full overflow-hidden rounded-lg border">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Logo preview"
|
||||
className="h-full w-full object-contain p-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
|
||||
Please upload a logo
|
||||
{!hasLoadedPreview && (
|
||||
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<FormControl className="relative">
|
||||
<Input
|
||||
type="file"
|
||||
accept={ACCEPTED_FILE_TYPES.join(',')}
|
||||
disabled={!isBrandingEnabled}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
if (previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
setPreviewUrl(objectUrl);
|
||||
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'h-auto p-2',
|
||||
'file:text-primary hover:file:bg-primary/90',
|
||||
'file:mr-4 file:cursor-pointer file:rounded-md file:border-0',
|
||||
'file:p-2 file:py-2 file:font-medium',
|
||||
'file:bg-primary file:text-primary-foreground',
|
||||
!isBrandingEnabled && 'cursor-not-allowed',
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="absolute right-2 top-0 inline-flex h-full items-center justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-destructive text-xs"
|
||||
onClick={() => {
|
||||
setPreviewUrl('');
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Upload your brand logo (max 5MB, JPG, PNG, or WebP)</Trans>
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Brand Website</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
disabled={!isBrandingEnabled}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Your brand website URL</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingCompanyDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Brand Details</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={_(msg`Enter your brand details`)}
|
||||
className="min-h-[100px] resize-y"
|
||||
disabled={!isBrandingEnabled}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Additional brand information to display at the bottom of emails</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
isValidLanguageCode,
|
||||
} from '@documenso/lib/constants/i18n';
|
||||
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
import { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZTeamDocumentPreferencesFormSchema = z.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||
includeSenderDetails: z.boolean(),
|
||||
});
|
||||
|
||||
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
|
||||
|
||||
export type TeamDocumentPreferencesFormProps = {
|
||||
team: Team;
|
||||
settings?: TeamGlobalSettings | null;
|
||||
};
|
||||
|
||||
export const TeamDocumentPreferencesForm = ({
|
||||
team,
|
||||
settings,
|
||||
}: TeamDocumentPreferencesFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { data } = useSession();
|
||||
|
||||
const placeholderEmail = data?.user.email ?? 'user@example.com';
|
||||
|
||||
const { mutateAsync: updateTeamDocumentPreferences } =
|
||||
trpc.team.updateTeamDocumentSettings.useMutation();
|
||||
|
||||
const form = useForm<TTeamDocumentPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
documentVisibility: settings?.documentVisibility ?? 'EVERYONE',
|
||||
documentLanguage: isValidLanguageCode(settings?.documentLanguage)
|
||||
? settings?.documentLanguage
|
||||
: 'en',
|
||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const includeSenderDetails = form.watch('includeSenderDetails');
|
||||
|
||||
const onSubmit = async (data: TTeamDocumentPreferencesFormSchema) => {
|
||||
try {
|
||||
const { documentVisibility, documentLanguage, includeSenderDetails } = data;
|
||||
|
||||
await updateTeamDocumentPreferences({
|
||||
teamId: team.id,
|
||||
settings: {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Document preferences updated`),
|
||||
description: _(msg`Your document preferences have been updated`),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong!`),
|
||||
description: _(
|
||||
msg`We were unable to update your document preferences at this time, please try again later`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentVisibility"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Document Visibility</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone can access and view the document</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Only managers and above can access and view the document</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Only admins can access and view the document</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Controls the default visibility of an uploaded document.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentLanguage"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Document Language</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{language.full}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the default language of an uploaded document. This will be used as the
|
||||
language in email communications with the recipients.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Send on Behalf of Team</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl className="block">
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
<Trans>Preview</Trans>
|
||||
</div>
|
||||
|
||||
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
|
||||
{includeSenderDetails
|
||||
? _(msg`"${placeholderEmail}" on behalf of "${team.name}" has invited you to sign "example
|
||||
document".`)
|
||||
: _(msg`"${team.name}" has invited you to sign "example document".`)}
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the formatting of the message that will be sent when inviting a
|
||||
recipient to sign a document. If a custom message has been provided while
|
||||
configuring the document, it will be used instead.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,52 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
|
||||
import { TeamBrandingPreferencesForm } from './branding-preferences';
|
||||
import { TeamDocumentPreferencesForm } from './document-preferences';
|
||||
|
||||
export type TeamsSettingsPageProps = {
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { teamUrl } = params;
|
||||
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Team Preferences`)}
|
||||
subtitle={_(msg`Here you can set preferences and defaults for your team.`)}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<TeamDocumentPreferencesForm team={team} settings={team.teamGlobalSettings} />
|
||||
</section>
|
||||
|
||||
<SettingsHeader
|
||||
title={_(msg`Branding Preferences`)}
|
||||
subtitle={_(msg`Here you can set preferences and defaults for branding.`)}
|
||||
className="mt-8"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<TeamBrandingPreferencesForm team={team} settings={team.teamGlobalSettings} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,14 +6,22 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
import {
|
||||
DocumentVisibilitySelect,
|
||||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -29,18 +37,29 @@ export type UpdateTeamDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
documentVisibility?: DocumentVisibility;
|
||||
includeSenderDetails?: boolean;
|
||||
};
|
||||
|
||||
const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
|
||||
name: true,
|
||||
url: true,
|
||||
documentVisibility: true,
|
||||
includeSenderDetails: true,
|
||||
});
|
||||
|
||||
type TUpdateTeamFormSchema = z.infer<typeof ZUpdateTeamFormSchema>;
|
||||
|
||||
export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => {
|
||||
export const UpdateTeamForm = ({
|
||||
teamId,
|
||||
teamName,
|
||||
teamUrl,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
}: UpdateTeamDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { data: session } = useSession();
|
||||
const email = session?.user?.email;
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -49,17 +68,36 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
defaultValues: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
|
||||
const includeSenderDetailsCheck = form.watch('includeSenderDetails');
|
||||
|
||||
const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => {
|
||||
const mapVisibilityToRole = (visibility: DocumentVisibility): DocumentVisibility =>
|
||||
match(visibility)
|
||||
.with(DocumentVisibility.ADMIN, () => DocumentVisibility.ADMIN)
|
||||
.with(DocumentVisibility.MANAGER_AND_ABOVE, () => DocumentVisibility.MANAGER_AND_ABOVE)
|
||||
.otherwise(() => DocumentVisibility.EVERYONE);
|
||||
|
||||
const currentVisibilityRole = mapVisibilityToRole(
|
||||
documentVisibility ?? DocumentVisibility.EVERYONE,
|
||||
);
|
||||
const onFormSubmit = async ({
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
}: TUpdateTeamFormSchema) => {
|
||||
try {
|
||||
await updateTeam({
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
},
|
||||
teamId,
|
||||
});
|
||||
@ -73,6 +111,8 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
form.reset({
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
});
|
||||
|
||||
if (url !== teamUrl) {
|
||||
@ -146,6 +186,68 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentVisibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="mt-4 flex flex-row items-center">
|
||||
<Trans>Default Document Visibility</Trans>
|
||||
<DocumentVisibilityTooltip />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DocumentVisibilitySelect
|
||||
currentMemberRole={currentVisibilityRole}
|
||||
isTeamSettings={true}
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="mt-6 flex flex-row items-center gap-4">
|
||||
<FormLabel>
|
||||
<Trans>Send on Behalf of Team</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5"
|
||||
checkClassName="text-white"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
{includeSenderDetailsCheck ? (
|
||||
<blockquote className="text-foreground/50 text-xs italic">
|
||||
<Trans>
|
||||
"{email}" on behalf of "{teamName}" has invited you to sign "example
|
||||
document".
|
||||
</Trans>
|
||||
</blockquote>
|
||||
) : (
|
||||
<blockquote className="text-foreground/50 text-xs italic">
|
||||
<Trans>"{teamUrl}" has invited you to sign "example document".</Trans>
|
||||
</blockquote>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<AnimatePresence>
|
||||
{form.formState.isDirty && (
|
||||
|
||||
@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Settings, Users, Webhook } from 'lucide-react';
|
||||
import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
@ -26,6 +26,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||
|
||||
const settingsPath = `/t/${teamUrl}/settings`;
|
||||
const preferencesPath = `/t/${teamUrl}/settings/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
@ -44,6 +45,20 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={preferencesPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(preferencesPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isPublicProfileEnabled && (
|
||||
<Link href={publicProfilePath}>
|
||||
<Button
|
||||
|
||||
@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Key, User, Webhook } from 'lucide-react';
|
||||
import { Braces, CreditCard, Globe2Icon, Key, Settings2, User, Webhook } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
@ -26,6 +26,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||
|
||||
const settingsPath = `/t/${teamUrl}/settings`;
|
||||
const preferencesPath = `/t/${teamUrl}/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
@ -52,6 +53,21 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={preferencesPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(preferencesPath) &&
|
||||
pathname.split('/').length === 4 &&
|
||||
'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2 className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isPublicProfileEnabled && (
|
||||
<Link href={publicProfilePath}>
|
||||
<Button
|
||||
|
||||
59
apps/web/src/pages/api/branding/logo/team/[teamId].ts
Normal file
59
apps/web/src/pages/api/branding/logo/team/[teamId].ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const teamId = Number(req.query['teamId']);
|
||||
|
||||
if (teamId === 0 || Number.isNaN(teamId)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Invalid team ID',
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await prisma.teamGlobalSettings.findFirst({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!settings || !settings.brandingEnabled) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!settings.brandingLogo) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
});
|
||||
}
|
||||
|
||||
const file = await getFile(JSON.parse(settings.brandingLogo)).catch(() => null);
|
||||
|
||||
if (!file) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
});
|
||||
}
|
||||
|
||||
const img = await sharp(file)
|
||||
.toFormat('png', {
|
||||
quality: 80,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Content-Length', img.length);
|
||||
// Stale while revalidate for 1 hours to 24 hours
|
||||
res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
|
||||
|
||||
res.status(200).send(img);
|
||||
}
|
||||
@ -36,7 +36,7 @@ test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
// Add signature.
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
@ -93,7 +93,7 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
// Add signature.
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
@ -262,7 +262,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
||||
}
|
||||
|
||||
// Add signature.
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
@ -373,7 +373,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
|
||||
}
|
||||
|
||||
// Add signature.
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
|
||||
@ -106,7 +106,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
@ -190,7 +190,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
@ -287,7 +287,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
@ -566,7 +566,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
@ -462,6 +462,45 @@ test('[TEAMS]: check document visibility based on team member role', async ({ pa
|
||||
}
|
||||
});
|
||||
|
||||
test('[TEAMS]: ensure document owner can see document regardless of visibility', async ({
|
||||
page,
|
||||
}) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
// Seed a member user
|
||||
const memberUser = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
role: TeamMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
// Seed a document with ADMIN visibility but make the member user a recipient
|
||||
await seedDocuments([
|
||||
{
|
||||
sender: memberUser,
|
||||
recipients: [],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
documentOptions: {
|
||||
teamId: team.id,
|
||||
visibility: 'ADMIN',
|
||||
title: 'Admin Document with Member Document Owner',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
|
||||
});
|
||||
|
||||
// Check that the member user can see the document
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Admin Document with Member Document Owner', exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('[TEAMS]: ensure recipient can see document regardless of visibility', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
|
||||
62
packages/app-tests/e2e/teams/team-global-settings.spec.ts
Normal file
62
packages/app-tests/e2e/teams/team-global-settings.spec.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEAMS]: update the default document visibility in the team global settings', async ({
|
||||
page,
|
||||
}) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Admin' }).click();
|
||||
await page.getByRole('button', { name: 'Update team' }).click();
|
||||
|
||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast.getByText('Success', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
toast.getByText('Your team has been successfully updated.', { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: update the sender details in the team global settings', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
const checkbox = page.getByLabel('Send on Behalf of Team');
|
||||
await checkbox.check();
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
|
||||
await page.getByRole('button', { name: 'Update team' }).click();
|
||||
|
||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast.getByText('Success', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
toast.getByText('Your team has been successfully updated.', { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
@ -29,7 +29,7 @@ test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
|
||||
await page.getByLabel('Confirm by typing transfer').fill('transfer');
|
||||
await page.getByRole('button', { name: 'Transfer' }).click();
|
||||
|
||||
await expect(page.locator('[id="\\:r2\\:-form-item-message"]')).toContainText(
|
||||
await expect(page.locator('[id*="form-item-message"]').first()).toContainText(
|
||||
`You must enter 'transfer ${team.name}' to proceed`,
|
||||
);
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
if (box) {
|
||||
|
||||
@ -12,7 +12,7 @@ test('[USER] update full name', async ({ page }) => {
|
||||
|
||||
await page.getByLabel('Full Name').fill('John Doe');
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
if (box) {
|
||||
|
||||
44
packages/email/providers/branding.tsx
Normal file
44
packages/email/providers/branding.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
type BrandingContextValue = {
|
||||
brandingEnabled: boolean;
|
||||
brandingUrl: string;
|
||||
brandingLogo: string;
|
||||
brandingCompanyDetails: string;
|
||||
brandingHidePoweredBy: boolean;
|
||||
};
|
||||
|
||||
const BrandingContext = createContext<BrandingContextValue | undefined>(undefined);
|
||||
|
||||
const defaultBrandingContextValue: BrandingContextValue = {
|
||||
brandingEnabled: false,
|
||||
brandingUrl: '',
|
||||
brandingLogo: '',
|
||||
brandingCompanyDetails: '',
|
||||
brandingHidePoweredBy: false,
|
||||
};
|
||||
|
||||
export const BrandingProvider = (props: {
|
||||
branding?: BrandingContextValue;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<BrandingContext.Provider value={props.branding ?? defaultBrandingContextValue}>
|
||||
{props.children}
|
||||
</BrandingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBranding = () => {
|
||||
const ctx = useContext(BrandingContext);
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Branding context not found');
|
||||
}
|
||||
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export type BrandingSettings = BrandingContextValue;
|
||||
@ -1,11 +1,18 @@
|
||||
import * as reactEmail from '@react-email/render';
|
||||
import * as ReactEmail from '@react-email/render';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import { Tailwind } from './components';
|
||||
import { BrandingProvider, type BrandingSettings } from './providers/branding';
|
||||
|
||||
export const render: typeof reactEmail.render = (element, options) => {
|
||||
return reactEmail.render(
|
||||
export type RenderOptions = ReactEmail.Options & {
|
||||
branding?: BrandingSettings;
|
||||
};
|
||||
|
||||
export const render = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return ReactEmail.render(
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
@ -15,14 +22,16 @@ export const render: typeof reactEmail.render = (element, options) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
<BrandingProvider branding={branding}>{element}</BrandingProvider>
|
||||
</Tailwind>,
|
||||
options,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
export const renderAsync: typeof reactEmail.renderAsync = async (element, options) => {
|
||||
return reactEmail.renderAsync(
|
||||
export const renderAsync = async (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return await ReactEmail.renderAsync(
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
@ -32,8 +41,8 @@ export const renderAsync: typeof reactEmail.renderAsync = async (element, option
|
||||
},
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
<BrandingProvider branding={branding}>{element}</BrandingProvider>
|
||||
</Tailwind>,
|
||||
options,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
@ -13,8 +12,6 @@ export const TemplateConfirmationEmail = ({
|
||||
confirmationLink,
|
||||
assetBaseUrl,
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
@ -38,7 +38,7 @@ export const TemplateDocumentCompleted = ({
|
||||
</Section>
|
||||
|
||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
||||
<Trans>{customBody ?? `“${documentName}” was signed by all signers`}</Trans>
|
||||
{customBody || <Trans>“{documentName}” was signed by all signers</Trans>}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
@ -46,13 +46,6 @@ export const TemplateDocumentCompleted = ({
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
{/* <Button
|
||||
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={reviewLink}
|
||||
>
|
||||
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
|
||||
Review
|
||||
</Button> */}
|
||||
<Button
|
||||
className="rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={downloadLink}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { RecipientRole } from '@documenso/prisma/client';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
@ -17,6 +18,7 @@ export interface TemplateDocumentInviteProps {
|
||||
selfSigner: boolean;
|
||||
isTeamInvite: boolean;
|
||||
teamName?: string;
|
||||
includeSenderDetails?: boolean;
|
||||
}
|
||||
|
||||
export const TemplateDocumentInvite = ({
|
||||
@ -28,10 +30,11 @@ export const TemplateDocumentInvite = ({
|
||||
selfSigner,
|
||||
isTeamInvite,
|
||||
teamName,
|
||||
includeSenderDetails,
|
||||
}: TemplateDocumentInviteProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION_ENG[role];
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -41,29 +44,38 @@ export const TemplateDocumentInvite = ({
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{selfSigner ? (
|
||||
<Trans>
|
||||
{`Please ${_(actionVerb).toLowerCase()} your document`}
|
||||
<br />
|
||||
{`"${documentName}"`}
|
||||
Please {_(actionVerb).toLowerCase()} your document
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
) : isTeamInvite ? (
|
||||
<Trans>
|
||||
{`${inviterName} on behalf of ${teamName} has invited you to ${_(
|
||||
actionVerb,
|
||||
).toLowerCase()}`}
|
||||
<br />
|
||||
{`"${documentName}"`}
|
||||
</Trans>
|
||||
<>
|
||||
{includeSenderDetails ? (
|
||||
<Trans>
|
||||
{inviterName} on behalf of {teamName} has invited you to{' '}
|
||||
{_(actionVerb).toLowerCase()}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{teamName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
</Trans>
|
||||
)}
|
||||
<br />"{documentName}"
|
||||
</>
|
||||
) : (
|
||||
<Trans>
|
||||
{`${inviterName} has invited you to ${_(actionVerb).toLowerCase()}`}
|
||||
<br />
|
||||
{`"${documentName}"`}
|
||||
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>Continue by {_(progressiveVerb).toLowerCase()} the document.</Trans>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.exhaustive()}
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
@ -71,7 +83,12 @@ export const TemplateDocumentInvite = ({
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={signDocumentLink}
|
||||
>
|
||||
<Trans>{_(actionVerb)} Document</Trans>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { Link, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
|
||||
export type TemplateFooterProps = {
|
||||
isDocument?: boolean;
|
||||
};
|
||||
|
||||
export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
const branding = useBranding();
|
||||
|
||||
return (
|
||||
<Section>
|
||||
{isDocument && (
|
||||
{isDocument && !branding.brandingHidePoweredBy && (
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
<Trans>
|
||||
This document was sent using{' '}
|
||||
@ -20,11 +23,24 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
Documenso, Inc.
|
||||
<br />
|
||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
||||
</Text>
|
||||
{branding.brandingCompanyDetails ? (
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
{branding.brandingCompanyDetails.split('\n').map((line, idx) => {
|
||||
return (
|
||||
<>
|
||||
{idx > 0 && <br />}
|
||||
{line}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
Documenso, Inc.
|
||||
<br />
|
||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,6 +2,7 @@ import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import type { TemplateConfirmationEmailProps } from '../template-components/template-confirmation-email';
|
||||
import { TemplateConfirmationEmail } from '../template-components/template-confirmation-email';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
@ -11,6 +12,7 @@ export const ConfirmEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Please confirm your email address`;
|
||||
|
||||
@ -26,11 +28,15 @@ export const ConfirmEmailTemplate = ({
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateConfirmationEmail
|
||||
confirmationLink={confirmationLink}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
@ -10,11 +10,13 @@ import {
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
@ -34,6 +36,7 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
token = '',
|
||||
}: ConfirmTeamEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Accept team email request for ${teamName} on Documenso`;
|
||||
|
||||
@ -45,11 +48,15 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
@ -61,12 +68,14 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
Verify your team email address
|
||||
<Trans>Verify your team email address</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="text-center text-base">
|
||||
<span className="font-bold">{teamName}</span> has requested to use your email
|
||||
address for their team on Documenso.
|
||||
<Trans>
|
||||
<span className="font-bold">{teamName}</span> has requested to use your email
|
||||
address for their team on Documenso.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto mt-6 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
@ -75,25 +84,29 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
|
||||
<Section className="mt-6">
|
||||
<Text className="my-0 text-sm">
|
||||
By accepting this request, you will be granting <strong>{teamName}</strong> access
|
||||
to:
|
||||
<Trans>
|
||||
By accepting this request, you will be granting <strong>{teamName}</strong>{' '}
|
||||
access to:
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<ul className="mb-0 mt-2">
|
||||
<li className="text-sm">
|
||||
View all documents sent to and from this email address
|
||||
<Trans>View all documents sent to and from this email address</Trans>
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
Allow document recipients to reply directly to this email address
|
||||
<Trans>Allow document recipients to reply directly to this email address</Trans>
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
Send documents on behalf of the team using the email address
|
||||
<Trans>Send documents on behalf of the team using the email address</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Text className="mt-2 text-sm">
|
||||
You can revoke access at any time in your team settings on Documenso{' '}
|
||||
<Link href={`${baseUrl}/settings/teams`}>here.</Link>
|
||||
<Trans>
|
||||
You can revoke access at any time in your team settings on Documenso{' '}
|
||||
<Link href={`${baseUrl}/settings/teams`}>here.</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
@ -102,12 +115,14 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={`${baseUrl}/team/verify/email/${token}`}
|
||||
>
|
||||
Accept
|
||||
<Trans>Accept</Trans>
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Text className="text-center text-xs text-slate-500">Link expires in 1 hour.</Text>
|
||||
<Text className="text-center text-xs text-slate-500">
|
||||
<Trans>Link expires in 1 hour.</Trans>
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
@ -2,6 +2,7 @@ import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import type { TemplateDocumentCancelProps } from '../template-components/template-document-cancel';
|
||||
import { TemplateDocumentCancel } from '../template-components/template-document-cancel';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
@ -15,6 +16,7 @@ export const DocumentCancelTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentCancelEmailTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`${inviterName} has cancelled the document ${documentName}, you don't need to sign it anymore.`;
|
||||
|
||||
@ -31,11 +33,15 @@ export const DocumentCancelTemplate = ({
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateDocumentCancel
|
||||
inviterName={inviterName}
|
||||
|
||||
@ -2,6 +2,7 @@ import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import type { TemplateDocumentCompletedProps } from '../template-components/template-document-completed';
|
||||
import { TemplateDocumentCompleted } from '../template-components/template-document-completed';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
@ -17,6 +18,7 @@ export const DocumentCompletedEmailTemplate = ({
|
||||
customBody,
|
||||
}: DocumentCompletedEmailTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Completed Document`;
|
||||
|
||||
@ -33,11 +35,15 @@ export const DocumentCompletedEmailTemplate = ({
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateDocumentCompleted
|
||||
downloadLink={downloadLink}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
|
||||
import { Body, Button, Container, Head, Html, Img, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import TemplateDocumentImage from '../template-components/template-document-image';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import { RecipientRole } from '.prisma/client';
|
||||
@ -24,8 +25,9 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentCompletedEmailTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const action = _(RECIPIENT_ROLES_DESCRIPTION_ENG[recipientRole].actioned).toLowerCase();
|
||||
const action = _(RECIPIENT_ROLES_DESCRIPTION[recipientRole].actioned).toLowerCase();
|
||||
|
||||
const previewText = msg`Document created from direct template`;
|
||||
|
||||
@ -42,11 +44,15 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import type { TemplateDocumentInviteProps } from '../template-components/template-document-invite';
|
||||
import { TemplateDocumentInvite } from '../template-components/template-document-invite';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
@ -16,6 +17,7 @@ export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInvitePro
|
||||
isTeamInvite?: boolean;
|
||||
teamName?: string;
|
||||
teamEmail?: string;
|
||||
includeSenderDetails?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentInviteEmailTemplate = ({
|
||||
@ -29,16 +31,24 @@ export const DocumentInviteEmailTemplate = ({
|
||||
selfSigner = false,
|
||||
isTeamInvite = false,
|
||||
teamName,
|
||||
includeSenderDetails,
|
||||
}: DocumentInviteEmailTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const action = _(RECIPIENT_ROLES_DESCRIPTION_ENG[role].actionVerb).toLowerCase();
|
||||
const action = _(RECIPIENT_ROLES_DESCRIPTION[role].actionVerb).toLowerCase();
|
||||
|
||||
const previewText = selfSigner
|
||||
? msg`Please ${action} your document ${documentName}`
|
||||
: isTeamInvite
|
||||
? msg`${inviterName} on behalf of ${teamName} has invited you to ${action} ${documentName}`
|
||||
: msg`${inviterName} has invited you to ${action} ${documentName}`;
|
||||
let previewText = msg`${inviterName} has invited you to ${action} ${documentName}`;
|
||||
|
||||
if (isTeamInvite) {
|
||||
previewText = includeSenderDetails
|
||||
? msg`${inviterName} on behalf of ${teamName} has invited you to ${action} ${documentName}`
|
||||
: msg`${teamName} has invited you to ${action} ${documentName}`;
|
||||
}
|
||||
|
||||
if (selfSigner) {
|
||||
previewText = msg`Please ${action} your document ${documentName}`;
|
||||
}
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
@ -53,11 +63,15 @@ export const DocumentInviteEmailTemplate = ({
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateDocumentInvite
|
||||
inviterName={inviterName}
|
||||
@ -69,6 +83,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
selfSigner={selfSigner}
|
||||
isTeamInvite={isTeamInvite}
|
||||
teamName={teamName}
|
||||
includeSenderDetails={includeSenderDetails}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
@ -89,7 +104,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
|
||||
) : (
|
||||
<Trans>
|
||||
`${inviterName} has invited you to ${action} the document "${documentName}".`
|
||||
{inviterName} has invited you to {action} the document "{documentName}".
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@ -2,6 +2,7 @@ import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import type { TemplateDocumentPendingProps } from '../template-components/template-document-pending';
|
||||
import { TemplateDocumentPending } from '../template-components/template-document-pending';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
@ -13,6 +14,7 @@ export const DocumentPendingEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentPendingEmailTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Pending Document`;
|
||||
|
||||
@ -29,11 +31,15 @@ export const DocumentPendingEmailTemplate = ({
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateDocumentPending documentName={documentName} assetBaseUrl={assetBaseUrl} />
|
||||
</Section>
|
||||
|
||||
@ -2,6 +2,7 @@ import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import type { TemplateDocumentSelfSignedProps } from '../template-components/template-document-self-signed';
|
||||
import { TemplateDocumentSelfSigned } from '../template-components/template-document-self-signed';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
@ -13,6 +14,7 @@ export const DocumentSelfSignedEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentSelfSignedTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Completed Document`;
|
||||
|
||||
@ -29,11 +31,15 @@ export const DocumentSelfSignedEmailTemplate = ({
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateDocumentSelfSigned documentName={documentName} assetBaseUrl={assetBaseUrl} />
|
||||
</Section>
|
||||
|
||||
@ -2,6 +2,7 @@ import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import {
|
||||
TemplateDocumentDelete,
|
||||
type TemplateDocumentDeleteProps,
|
||||
@ -16,6 +17,7 @@ export const DocumentSuperDeleteEmailTemplate = ({
|
||||
reason = 'Unknown',
|
||||
}: DocumentDeleteEmailTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`An admin has deleted your document "${documentName}".`;
|
||||
|
||||
@ -32,11 +34,15 @@ export const DocumentSuperDeleteEmailTemplate = ({
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateDocumentDelete
|
||||
reason={reason}
|
||||
|
||||
@ -2,6 +2,7 @@ import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import type { TemplateForgotPasswordProps } from '../template-components/template-forgot-password';
|
||||
import { TemplateForgotPassword } from '../template-components/template-forgot-password';
|
||||
@ -13,6 +14,7 @@ export const ForgotPasswordTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: ForgotPasswordTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Password Reset Requested`;
|
||||
|
||||
@ -29,11 +31,15 @@ export const ForgotPasswordTemplate = ({
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateForgotPassword
|
||||
resetPasswordLink={resetPasswordLink}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import type { TemplateDocumentCancelProps } from '../template-components/template-document-cancel';
|
||||
import TemplateDocumentImage from '../template-components/template-document-image';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
@ -14,6 +15,7 @@ export const RecipientRemovedFromDocumentTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentCancelEmailTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`${inviterName} has removed you from the document ${documentName}.`;
|
||||
|
||||
@ -30,18 +32,24 @@ export const RecipientRemovedFromDocumentTemplate = ({
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{inviterName} has removed you from the document
|
||||
<br />"{documentName}"
|
||||
<Trans>
|
||||
{inviterName} has removed you from the document
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
@ -2,6 +2,7 @@ import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import type { TemplateResetPasswordProps } from '../template-components/template-reset-password';
|
||||
import { TemplateResetPassword } from '../template-components/template-reset-password';
|
||||
@ -14,6 +15,7 @@ export const ResetPasswordTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: ResetPasswordTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Password Reset Successful`;
|
||||
|
||||
@ -30,11 +32,15 @@ export const ResetPasswordTemplate = ({
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateResetPassword
|
||||
userName={userName}
|
||||
|
||||
@ -3,7 +3,8 @@ import { useLingui } from '@lingui/react';
|
||||
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Preview, Section, Text } from '../components';
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
@ -21,6 +22,7 @@ export const TeamDeleteEmailTemplate = ({
|
||||
isOwner = false,
|
||||
}: TeamDeleteEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = isOwner
|
||||
? msg`Your team has been deleted`
|
||||
@ -42,11 +44,15 @@ export const TeamDeleteEmailTemplate = ({
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
|
||||
@ -3,7 +3,8 @@ import { useLingui } from '@lingui/react';
|
||||
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Preview, Section, Text } from '../components';
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
@ -23,6 +24,7 @@ export const TeamEmailRemovedTemplate = ({
|
||||
teamUrl = 'demo',
|
||||
}: TeamEmailRemovedTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Team email removed for ${teamName} on Documenso`;
|
||||
|
||||
@ -34,11 +36,15 @@ export const TeamEmailRemovedTemplate = ({
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
|
||||
@ -3,7 +3,19 @@ import { useLingui } from '@lingui/react';
|
||||
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { Body, Button, Container, Head, Hr, Html, Preview, Section, Text } from '../components';
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
@ -25,6 +37,7 @@ export const TeamInviteEmailTemplate = ({
|
||||
token = '',
|
||||
}: TeamInviteEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Accept invitation to join a team on Documenso`;
|
||||
|
||||
@ -36,11 +49,15 @@ export const TeamInviteEmailTemplate = ({
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
|
||||
@ -3,7 +3,8 @@ import { useLingui } from '@lingui/react';
|
||||
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Preview, Section, Text } from '../components';
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
@ -25,6 +26,7 @@ export const TeamJoinEmailTemplate = ({
|
||||
teamUrl = 'demo',
|
||||
}: TeamJoinEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`A team member has joined a team on Documenso`;
|
||||
|
||||
@ -36,11 +38,15 @@ export const TeamJoinEmailTemplate = ({
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
|
||||
@ -3,7 +3,8 @@ import { useLingui } from '@lingui/react';
|
||||
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Preview, Section, Text } from '../components';
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
@ -25,6 +26,7 @@ export const TeamLeaveEmailTemplate = ({
|
||||
teamUrl = 'demo',
|
||||
}: TeamLeaveEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`A team member has left a team on Documenso`;
|
||||
|
||||
@ -36,11 +38,15 @@ export const TeamLeaveEmailTemplate = ({
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/macro';
|
||||
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const DOCUMENT_STATUS: {
|
||||
[status in DocumentStatus]: { description: MessageDescriptor };
|
||||
@ -16,3 +16,19 @@ export const DOCUMENT_STATUS: {
|
||||
description: msg`Pending`,
|
||||
},
|
||||
};
|
||||
|
||||
type DocumentDistributionMethodTypeData = {
|
||||
value: DocumentDistributionMethod;
|
||||
description: MessageDescriptor;
|
||||
};
|
||||
|
||||
export const DOCUMENT_DISTRIBUTION_METHODS: Record<string, DocumentDistributionMethodTypeData> = {
|
||||
[DocumentDistributionMethod.EMAIL]: {
|
||||
value: DocumentDistributionMethod.EMAIL,
|
||||
description: msg`Email`,
|
||||
},
|
||||
[DocumentDistributionMethod.NONE]: {
|
||||
value: DocumentDistributionMethod.NONE,
|
||||
description: msg`None`,
|
||||
},
|
||||
} satisfies Record<DocumentDistributionMethod, DocumentDistributionMethodTypeData>;
|
||||
|
||||
@ -9,59 +9,27 @@ export const RECIPIENT_ROLES_DESCRIPTION = {
|
||||
actioned: msg`Approved`,
|
||||
progressiveVerb: msg`Approving`,
|
||||
roleName: msg`Approver`,
|
||||
roleNamePlural: msg`Approvers`,
|
||||
},
|
||||
[RecipientRole.CC]: {
|
||||
actionVerb: msg`CC`,
|
||||
actioned: msg`CC'd`,
|
||||
progressiveVerb: msg`CC`,
|
||||
roleName: msg`Cc`,
|
||||
roleNamePlural: msg`Ccers`,
|
||||
},
|
||||
[RecipientRole.SIGNER]: {
|
||||
actionVerb: msg`Sign`,
|
||||
actioned: msg`Signed`,
|
||||
progressiveVerb: msg`Signing`,
|
||||
roleName: msg`Signer`,
|
||||
roleNamePlural: msg`Signers`,
|
||||
},
|
||||
[RecipientRole.VIEWER]: {
|
||||
actionVerb: msg`View`,
|
||||
actioned: msg`Viewed`,
|
||||
progressiveVerb: msg`Viewing`,
|
||||
roleName: msg`Viewer`,
|
||||
},
|
||||
} satisfies Record<keyof typeof RecipientRole, unknown>;
|
||||
|
||||
/**
|
||||
* Raw english descriptions for emails.
|
||||
*
|
||||
* Todo: Handle i18n for emails.
|
||||
*/
|
||||
export const RECIPIENT_ROLES_DESCRIPTION_ENG = {
|
||||
[RecipientRole.APPROVER]: {
|
||||
actionVerb: `Approve`,
|
||||
actioned: `Approved`,
|
||||
progressiveVerb: `Approving`,
|
||||
roleName: `Approver`,
|
||||
roleNamePlural: msg`Approvers`,
|
||||
},
|
||||
[RecipientRole.CC]: {
|
||||
actionVerb: `CC`,
|
||||
actioned: `CC'd`,
|
||||
progressiveVerb: `CC`,
|
||||
roleName: `Cc`,
|
||||
roleNamePlural: msg`Ccers`,
|
||||
},
|
||||
[RecipientRole.SIGNER]: {
|
||||
actionVerb: `Sign`,
|
||||
actioned: `Signed`,
|
||||
progressiveVerb: `Signing`,
|
||||
roleName: `Signer`,
|
||||
roleNamePlural: msg`Signers`,
|
||||
},
|
||||
[RecipientRole.VIEWER]: {
|
||||
actionVerb: `View`,
|
||||
actioned: `Viewed`,
|
||||
progressiveVerb: `Viewing`,
|
||||
roleName: `Viewer`,
|
||||
roleNamePlural: msg`Viewers`,
|
||||
},
|
||||
} satisfies Record<keyof typeof RecipientRole, unknown>;
|
||||
|
||||
@ -169,7 +169,7 @@ export class LocalJobProvider extends BaseJobProvider {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[JOBS]: Job ${options.name} failed`, error);
|
||||
console.log(`[JOBS]: Job ${options.name} failed`, error);
|
||||
|
||||
const taskHasExceededRetries = error instanceof BackgroundTaskExceededRetriesError;
|
||||
const jobHasExceededRetries =
|
||||
@ -295,7 +295,7 @@ export class LocalJobProvider extends BaseJobProvider {
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
task = await prisma.backgroundJobTask.update({
|
||||
where: {
|
||||
id: task.id,
|
||||
@ -309,6 +309,8 @@ export class LocalJobProvider extends BaseJobProvider {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[JOBS:${task.id}] Task failed`, err);
|
||||
|
||||
throw new BackgroundTaskFailedError('Task failed');
|
||||
}
|
||||
},
|
||||
|
||||
@ -17,14 +17,16 @@ import { getI18nInstance } from '../../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION_ENG,
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '../../../constants/recipient-roles';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_SIGNING_EMAIL_JOB_DEFINITION_ID = 'send.signing.requested.email';
|
||||
@ -64,6 +66,7 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
|
||||
select: {
|
||||
teamEmail: true,
|
||||
name: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -81,6 +84,14 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
if (!isRecipientSigningRequestEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customEmail = document?.documentMeta;
|
||||
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
|
||||
const isTeamDocument = document.teamId !== null;
|
||||
@ -89,11 +100,13 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
|
||||
|
||||
const { email, name } = recipient;
|
||||
const selfSigner = email === user.email;
|
||||
const recipientActionVerb =
|
||||
RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].actionVerb.toLowerCase();
|
||||
|
||||
const i18n = await getI18nInstance(documentMeta?.language);
|
||||
|
||||
const recipientActionVerb = i18n
|
||||
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
|
||||
.toLowerCase();
|
||||
|
||||
let emailMessage = customEmail?.message || '';
|
||||
let emailSubject = i18n._(msg`Please ${recipientActionVerb} this document`);
|
||||
|
||||
@ -115,11 +128,15 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
|
||||
|
||||
if (isTeamDocument && team) {
|
||||
emailSubject = i18n._(msg`${team.name} invited you to ${recipientActionVerb} a document`);
|
||||
emailMessage =
|
||||
customEmail?.message ||
|
||||
i18n._(
|
||||
msg`${user.name} on behalf of ${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
|
||||
emailMessage = customEmail?.message ?? '';
|
||||
|
||||
if (!emailMessage) {
|
||||
emailMessage = i18n._(
|
||||
team.teamGlobalSettings?.includeSenderDetails
|
||||
? msg`${user.name} on behalf of ${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`
|
||||
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const customEmailTemplate = {
|
||||
@ -143,13 +160,19 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
|
||||
isTeamInvite: isTeamDocument,
|
||||
teamName: team?.name,
|
||||
teamEmail: team?.teamEmail?.email,
|
||||
includeSenderDetails: team?.teamGlobalSettings?.includeSenderDetails,
|
||||
});
|
||||
|
||||
await io.runTask('send-signing-email', async () => {
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: documentMeta?.language }),
|
||||
renderEmailWithI18N(template, { lang: documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: documentMeta?.language,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocumentVisibility } from '@documenso/prisma/client';
|
||||
|
||||
import { sendTeamDeleteEmail } from '../../../server-only/team/delete-team';
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
@ -10,6 +12,19 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
ownerUserId: z.number(),
|
||||
teamGlobalSettings: z
|
||||
.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||
documentLanguage: z.string(),
|
||||
includeSenderDetails: z.boolean(),
|
||||
brandingEnabled: z.boolean(),
|
||||
brandingLogo: z.string(),
|
||||
brandingUrl: z.string(),
|
||||
brandingCompanyDetails: z.string(),
|
||||
brandingHidePoweredBy: z.boolean(),
|
||||
teamId: z.number(),
|
||||
})
|
||||
.nullish(),
|
||||
}),
|
||||
members: z.array(
|
||||
z.object({
|
||||
@ -35,8 +50,7 @@ export const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION = {
|
||||
await io.runTask(`send-team-deleted-email--${team.url}_${member.id}`, async () => {
|
||||
await sendTeamDeleteEmail({
|
||||
email: member.email,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
team,
|
||||
isOwner: member.id === team.ownerUserId,
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -10,6 +12,7 @@ import { getI18nInstance } from '../../../client-only/providers/i18n.server';
|
||||
import { WEBAPP_BASE_URL } from '../../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID = 'send.team-member-joined.email';
|
||||
@ -43,6 +46,7 @@ export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -64,7 +68,7 @@ export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
|
||||
await io.runTask(
|
||||
`send-team-member-joined-email--${invitedMember.id}_${member.id}`,
|
||||
async () => {
|
||||
const emailContent = TeamJoinEmailTemplate({
|
||||
const emailContent = createElement(TeamJoinEmailTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
memberName: invitedMember.user.name || '',
|
||||
@ -73,13 +77,26 @@ export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
|
||||
teamUrl: team.url,
|
||||
});
|
||||
|
||||
const branding = team.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const lang = team.teamGlobalSettings?.documentLanguage;
|
||||
|
||||
// !: Replace with the actual language of the recipient later
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(emailContent),
|
||||
renderEmailWithI18N(emailContent, { plainText: true }),
|
||||
renderEmailWithI18N(emailContent, {
|
||||
lang,
|
||||
branding,
|
||||
}),
|
||||
renderEmailWithI18N(emailContent, {
|
||||
lang,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance();
|
||||
const i18n = await getI18nInstance(lang);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: member.user.email,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -10,6 +12,7 @@ import { getI18nInstance } from '../../../client-only/providers/i18n.server';
|
||||
import { WEBAPP_BASE_URL } from '../../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID = 'send.team-member-left.email';
|
||||
@ -43,6 +46,7 @@ export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -54,7 +58,7 @@ export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
|
||||
|
||||
for (const member of team.members) {
|
||||
await io.runTask(`send-team-member-left-email--${oldMember.id}_${member.id}`, async () => {
|
||||
const emailContent = TeamJoinEmailTemplate({
|
||||
const emailContent = createElement(TeamJoinEmailTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
memberName: oldMember.name || '',
|
||||
@ -63,12 +67,25 @@ export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
|
||||
teamUrl: team.url,
|
||||
});
|
||||
|
||||
const branding = team.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const lang = team.teamGlobalSettings?.documentLanguage;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(emailContent),
|
||||
renderEmailWithI18N(emailContent, { plainText: true }),
|
||||
renderEmailWithI18N(emailContent, {
|
||||
lang,
|
||||
branding,
|
||||
}),
|
||||
renderEmailWithI18N(emailContent, {
|
||||
lang,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance();
|
||||
const i18n = await getI18nInstance(lang);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: member.user.email,
|
||||
|
||||
148
packages/lib/server-only/admin/get-signing-volume-fix.ts
Normal file
148
packages/lib/server-only/admin/get-signing-volume-fix.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export type SigningVolume = {
|
||||
id: number;
|
||||
name: string;
|
||||
signingVolume: number;
|
||||
createdAt: Date;
|
||||
planId: string;
|
||||
};
|
||||
|
||||
export type GetSigningVolumeOptions = {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
sortBy?: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export async function getSigningVolume({
|
||||
search = '',
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
sortBy = 'signingVolume',
|
||||
sortOrder = 'desc',
|
||||
}: GetSigningVolumeOptions) {
|
||||
const whereClause = Prisma.validator<Prisma.SubscriptionWhereInput>()({
|
||||
status: 'ACTIVE',
|
||||
OR: [
|
||||
{
|
||||
User: {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
name: { contains: search, mode: 'insensitive' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const orderByClause = getOrderByClause({ sortBy, sortOrder });
|
||||
|
||||
const [subscriptions, totalCount] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
User: {
|
||||
include: {
|
||||
Document: {
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
include: {
|
||||
document: {
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: orderByClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.subscription.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
const leaderboardWithVolume: SigningVolume[] = subscriptions.map((subscription) => {
|
||||
const name =
|
||||
subscription.User?.name || subscription.team?.name || subscription.User?.email || 'Unknown';
|
||||
|
||||
const userSignedDocs = subscription.User?.Document?.length || 0;
|
||||
const teamSignedDocs = subscription.team?.document?.length || 0;
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
name,
|
||||
signingVolume: userSignedDocs + teamSignedDocs,
|
||||
createdAt: subscription.createdAt,
|
||||
planId: subscription.planId,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
leaderboard: leaderboardWithVolume,
|
||||
totalPages: Math.ceil(totalCount / perPage),
|
||||
};
|
||||
}
|
||||
|
||||
function getOrderByClause(options: {
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}): Prisma.SubscriptionOrderByWithRelationInput | Prisma.SubscriptionOrderByWithRelationInput[] {
|
||||
const { sortBy, sortOrder } = options;
|
||||
|
||||
if (sortBy === 'name') {
|
||||
return [
|
||||
{
|
||||
User: {
|
||||
name: sortOrder,
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
name: sortOrder,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (sortBy === 'createdAt') {
|
||||
return {
|
||||
createdAt: sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: sort by signing volume
|
||||
return [
|
||||
{
|
||||
User: {
|
||||
Document: {
|
||||
_count: sortOrder,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
document: {
|
||||
_count: sortOrder,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
157
packages/lib/server-only/admin/get-signing-volume.ts
Normal file
157
packages/lib/server-only/admin/get-signing-volume.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export type SigningVolume = {
|
||||
id: number;
|
||||
name: string;
|
||||
signingVolume: number;
|
||||
createdAt: Date;
|
||||
planId: string;
|
||||
};
|
||||
|
||||
export type GetSigningVolumeOptions = {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
sortBy?: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export async function getSigningVolume({
|
||||
search = '',
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
sortBy = 'signingVolume',
|
||||
sortOrder = 'desc',
|
||||
}: GetSigningVolumeOptions) {
|
||||
const whereClause = Prisma.validator<Prisma.SubscriptionWhereInput>()({
|
||||
status: 'ACTIVE',
|
||||
OR: [
|
||||
{
|
||||
User: {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
name: { contains: search, mode: 'insensitive' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const orderBy = getOrderByClause({ sortBy, sortOrder });
|
||||
|
||||
const [subscriptions, totalCount] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
User: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
Document: {
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
Subscription: {
|
||||
select: {
|
||||
planId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
document: {
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
subscription: {
|
||||
select: {
|
||||
planId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.subscription.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
const leaderboardWithVolume: SigningVolume[] = subscriptions.map((subscription) => {
|
||||
const name =
|
||||
subscription.User?.name || subscription.team?.name || subscription.User?.email || 'Unknown';
|
||||
const signingVolume =
|
||||
(subscription.User?.Document.length || 0) + (subscription.team?.document.length || 0);
|
||||
const planId =
|
||||
subscription.User?.Subscription?.[0]?.planId || subscription.team?.subscription?.planId || '';
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
name,
|
||||
signingVolume,
|
||||
createdAt: subscription.createdAt,
|
||||
planId,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
leaderboard: leaderboardWithVolume,
|
||||
totalPages: Math.ceil(totalCount / perPage),
|
||||
};
|
||||
}
|
||||
|
||||
type GetOrderByClauseOptions = {
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
const getOrderByClause = ({ sortBy, sortOrder }: GetOrderByClauseOptions) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return [{ User: { name: sortOrder } }, { team: { name: sortOrder } }];
|
||||
case 'createdAt':
|
||||
return { createdAt: sortOrder };
|
||||
default:
|
||||
return [
|
||||
{
|
||||
User: {
|
||||
Document: {
|
||||
_count: sortOrder,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
document: {
|
||||
_count: sortOrder,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
@ -7,9 +7,10 @@ import {
|
||||
diffDocumentMetaChanges,
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentSigningOrder } from '@documenso/prisma/client';
|
||||
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
|
||||
|
||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
|
||||
export type CreateDocumentMetaOptions = {
|
||||
documentId: number;
|
||||
@ -19,7 +20,9 @@ export type CreateDocumentMetaOptions = {
|
||||
password?: string;
|
||||
dateFormat?: string;
|
||||
redirectUrl?: string;
|
||||
emailSettings?: TDocumentEmailSettings;
|
||||
signingOrder?: DocumentSigningOrder;
|
||||
distributionMethod?: DocumentDistributionMethod;
|
||||
typedSignatureEnabled?: boolean;
|
||||
language?: SupportedLanguageCodes;
|
||||
userId: number;
|
||||
@ -36,6 +39,8 @@ export const upsertDocumentMeta = async ({
|
||||
userId,
|
||||
redirectUrl,
|
||||
signingOrder,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
typedSignatureEnabled,
|
||||
language,
|
||||
requestMetadata,
|
||||
@ -88,6 +93,8 @@ export const upsertDocumentMeta = async ({
|
||||
documentId,
|
||||
redirectUrl,
|
||||
signingOrder,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
typedSignatureEnabled,
|
||||
language,
|
||||
},
|
||||
@ -99,6 +106,8 @@ export const upsertDocumentMeta = async ({
|
||||
timezone,
|
||||
redirectUrl,
|
||||
signingOrder,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
typedSignatureEnabled,
|
||||
language,
|
||||
},
|
||||
|
||||
@ -5,7 +5,9 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentSource, WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
import { DocumentSource, DocumentVisibility, WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@ -48,6 +50,51 @@ export const createDocument = async ({
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
|
||||
}
|
||||
|
||||
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
|
||||
let userTeamRole: TeamMemberRole | undefined;
|
||||
|
||||
if (teamId) {
|
||||
const teamWithUserRole = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
members: {
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
team = teamWithUserRole;
|
||||
userTeamRole = teamWithUserRole.members[0]?.role;
|
||||
}
|
||||
|
||||
const determineVisibility = (
|
||||
globalVisibility: DocumentVisibility | null | undefined,
|
||||
userRole: TeamMemberRole,
|
||||
): DocumentVisibility => {
|
||||
const defaultVisibility = globalVisibility ?? DocumentVisibility.EVERYONE;
|
||||
|
||||
if (userRole === TeamMemberRole.ADMIN) {
|
||||
return defaultVisibility;
|
||||
}
|
||||
|
||||
if (userRole === TeamMemberRole.MANAGER) {
|
||||
if (defaultVisibility === DocumentVisibility.ADMIN) {
|
||||
return DocumentVisibility.MANAGER_AND_ABOVE;
|
||||
}
|
||||
return defaultVisibility;
|
||||
}
|
||||
|
||||
return DocumentVisibility.EVERYONE;
|
||||
};
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
@ -56,8 +103,17 @@ export const createDocument = async ({
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
visibility: determineVisibility(
|
||||
team?.teamGlobalSettings?.documentVisibility,
|
||||
userTeamRole ?? TeamMemberRole.MEMBER,
|
||||
),
|
||||
formValues,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
documentMeta: {
|
||||
create: {
|
||||
language: team?.teamGlobalSettings?.documentLanguage,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -7,16 +7,25 @@ import { msg } from '@lingui/macro';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
|
||||
import type {
|
||||
Document,
|
||||
DocumentMeta,
|
||||
Recipient,
|
||||
Team,
|
||||
TeamGlobalSettings,
|
||||
User,
|
||||
} from '@documenso/prisma/client';
|
||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export type DeleteDocumentOptions = {
|
||||
id: number;
|
||||
@ -49,8 +58,9 @@ export const deleteDocument = async ({
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
select: {
|
||||
include: {
|
||||
members: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -73,6 +83,7 @@ export const deleteDocument = async ({
|
||||
await handleDocumentOwnerDelete({
|
||||
document,
|
||||
user,
|
||||
team: document.team,
|
||||
requestMetadata,
|
||||
});
|
||||
}
|
||||
@ -113,6 +124,11 @@ type HandleDocumentOwnerDeleteOptions = {
|
||||
Recipient: Recipient[];
|
||||
documentMeta: DocumentMeta | null;
|
||||
};
|
||||
team?:
|
||||
| (Team & {
|
||||
teamGlobalSettings?: TeamGlobalSettings | null;
|
||||
})
|
||||
| null;
|
||||
user: User;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
@ -120,6 +136,7 @@ type HandleDocumentOwnerDeleteOptions = {
|
||||
const handleDocumentOwnerDelete = async ({
|
||||
document,
|
||||
user,
|
||||
team,
|
||||
requestMetadata,
|
||||
}: HandleDocumentOwnerDeleteOptions) => {
|
||||
if (document.deletedAt) {
|
||||
@ -178,6 +195,14 @@ const handleDocumentOwnerDelete = async ({
|
||||
});
|
||||
});
|
||||
|
||||
const isDocumentDeleteEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).documentDeleted;
|
||||
|
||||
if (!isDocumentDeleteEmailEnabled) {
|
||||
return deletedDocument;
|
||||
}
|
||||
|
||||
// Send cancellation emails to recipients.
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
@ -194,9 +219,17 @@ const handleDocumentOwnerDelete = async ({
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const branding = team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(document.documentMeta?.language);
|
||||
|
||||
@ -124,11 +124,18 @@ export const findDocuments = async ({
|
||||
}))
|
||||
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })),
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
OR: [
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -143,11 +143,18 @@ export const getDocumentWhereInput = async ({
|
||||
])
|
||||
.otherwise(() => [{ visibility: DocumentVisibility.EVERYONE }]),
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
OR: [
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -6,11 +6,10 @@ import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
import type { Prisma, User } from '@documenso/prisma/client';
|
||||
import { SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
|
||||
export type GetStatsInput = {
|
||||
user: User;
|
||||
team?: Omit<GetTeamCountsOption, 'createdAt'>;
|
||||
@ -207,47 +206,45 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
let hasSignedCountsGroupByArgs = null;
|
||||
|
||||
const visibilityFilters = [
|
||||
...match(options.currentTeamMemberRole)
|
||||
.with(TeamMemberRole.ADMIN, () => [
|
||||
{ visibility: DocumentVisibility.EVERYONE },
|
||||
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
|
||||
{ visibility: DocumentVisibility.ADMIN },
|
||||
])
|
||||
.with(TeamMemberRole.MANAGER, () => [
|
||||
{ visibility: DocumentVisibility.EVERYONE },
|
||||
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
|
||||
])
|
||||
.otherwise(() => [{ visibility: DocumentVisibility.EVERYONE }]),
|
||||
];
|
||||
|
||||
ownerCountsWhereInput = {
|
||||
...ownerCountsWhereInput,
|
||||
OR: [
|
||||
const visibilityFiltersWhereInput: Prisma.DocumentWhereInput = {
|
||||
AND: [
|
||||
{ deletedAt: null },
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
visibility: {
|
||||
in: visibilityFilters.map((filter) => filter.visibility),
|
||||
},
|
||||
},
|
||||
{
|
||||
Recipient: {
|
||||
none: {
|
||||
email: options.currentUserEmail,
|
||||
OR: [
|
||||
match(options.currentTeamMemberRole)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
in: [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
],
|
||||
},
|
||||
},
|
||||
}))
|
||||
.with(TeamMemberRole.MANAGER, () => ({
|
||||
visibility: {
|
||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
||||
},
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
visibility: {
|
||||
equals: DocumentVisibility.EVERYONE,
|
||||
},
|
||||
})),
|
||||
{
|
||||
OR: [
|
||||
{ userId: options.userId },
|
||||
{ Recipient: { some: { email: options.currentUserEmail } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: options.currentUserEmail,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
ownerCountsWhereInput = {
|
||||
...ownerCountsWhereInput,
|
||||
...visibilityFiltersWhereInput,
|
||||
...searchFilter,
|
||||
};
|
||||
|
||||
|
||||
@ -19,7 +19,9 @@ import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
@ -65,6 +67,7 @@ export const resendDocument = async ({
|
||||
select: {
|
||||
teamEmail: true,
|
||||
name: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -89,6 +92,14 @@ export const resendDocument = async ({
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
if (!isRecipientSigningRequestEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.role === RecipientRole.CC) {
|
||||
@ -149,12 +160,20 @@ export const resendDocument = async ({
|
||||
teamName: document.team?.name,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
branding,
|
||||
}),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -10,11 +10,13 @@ import { DocumentSource } from '@documenso/prisma/client';
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
documentId: number;
|
||||
@ -35,6 +37,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -66,17 +69,32 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
|
||||
const i18n = await getI18nInstance(document.documentMeta?.language);
|
||||
|
||||
// If the document owner is not a recipient then send the email to them separately
|
||||
if (!document.Recipient.find((recipient) => recipient.email === owner.email)) {
|
||||
const isDocumentCompletedEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).documentCompleted;
|
||||
|
||||
// If the document owner is not a recipient, OR recipient emails are disabled, then send the email to them separately.
|
||||
if (
|
||||
!document.Recipient.find((recipient) => recipient.email === owner.email) ||
|
||||
!isDocumentCompletedEmailEnabled
|
||||
) {
|
||||
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||
documentName: document.title,
|
||||
assetBaseUrl,
|
||||
downloadLink: documentOwnerDownloadLink,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
@ -119,6 +137,10 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
});
|
||||
}
|
||||
|
||||
if (!isDocumentCompletedEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const customEmailTemplate = {
|
||||
@ -139,9 +161,17 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
|
||||
@ -8,7 +8,9 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export interface SendDeleteEmailOptions {
|
||||
documentId: number;
|
||||
@ -22,6 +24,12 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -29,6 +37,14 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).documentDeleted;
|
||||
|
||||
if (!isDocumentDeletedEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { email, name } = document.User;
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
@ -39,9 +55,17 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template),
|
||||
renderEmailWithI18N(template, { plainText: true }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance();
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@ -29,7 +30,7 @@ export const sendDocument = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
sendEmail = true,
|
||||
sendEmail,
|
||||
requestMetadata,
|
||||
}: SendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -156,7 +157,14 @@ export const sendDocument = async ({
|
||||
// throw new Error('Some signers have not been assigned a signature field.');
|
||||
// }
|
||||
|
||||
if (sendEmail) {
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
// Only send email if one of the following is true:
|
||||
// - It is explicitly set
|
||||
// - The email is enabled for signing requests AND sendEmail is undefined
|
||||
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
|
||||
await Promise.all(
|
||||
recipientsToNotify.map(async (recipient) => {
|
||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||
|
||||
@ -8,7 +8,9 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export interface SendPendingEmailOptions {
|
||||
documentId: number;
|
||||
@ -32,6 +34,11 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -43,6 +50,14 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).documentPending;
|
||||
|
||||
if (!isDocumentPendingEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [recipient] = document.Recipient;
|
||||
|
||||
const { email, name } = recipient;
|
||||
@ -54,12 +69,20 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance();
|
||||
const i18n = await getI18nInstance(document.documentMeta?.language);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
|
||||
@ -13,9 +13,11 @@ import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export type SuperDeleteDocumentOptions = {
|
||||
id: number;
|
||||
@ -31,6 +33,11 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
User: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -40,8 +47,16 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
|
||||
const { status, User: user } = document;
|
||||
|
||||
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).documentDeleted;
|
||||
|
||||
// if the document is pending, send cancellation emails to all recipients
|
||||
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
|
||||
if (
|
||||
status === DocumentStatus.PENDING &&
|
||||
document.Recipient.length > 0 &&
|
||||
isDocumentDeletedEmailEnabled
|
||||
) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT) {
|
||||
@ -56,9 +71,17 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(document.documentMeta?.language);
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
'use server';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
@ -20,7 +22,7 @@ export type UpdateDocumentSettingsOptions = {
|
||||
data: {
|
||||
title?: string;
|
||||
externalId?: string | null;
|
||||
visibility?: string | null;
|
||||
visibility?: DocumentVisibility | null;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||
};
|
||||
@ -63,8 +65,62 @@ export const updateDocumentSettings = async ({
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (teamId) {
|
||||
const currentUserRole = document.team?.members[0]?.role;
|
||||
|
||||
match(currentUserRole)
|
||||
.with(TeamMemberRole.ADMIN, () => true)
|
||||
.with(TeamMemberRole.MANAGER, () => {
|
||||
const allowedVisibilities: DocumentVisibility[] = [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
];
|
||||
|
||||
if (
|
||||
!allowedVisibilities.includes(document.visibility) ||
|
||||
(data.visibility && !allowedVisibilities.includes(data.visibility))
|
||||
) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to update the document visibility',
|
||||
);
|
||||
}
|
||||
})
|
||||
.with(TeamMemberRole.MEMBER, () => {
|
||||
if (
|
||||
document.visibility !== DocumentVisibility.EVERYONE ||
|
||||
(data.visibility && data.visibility !== DocumentVisibility.EVERYONE)
|
||||
) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to update the document visibility',
|
||||
);
|
||||
}
|
||||
})
|
||||
.otherwise(() => {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to update the document',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const { documentAuthOption } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
@ -26,8 +26,10 @@ import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { canRecipientBeModified } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export interface SetRecipientsForDocumentOptions {
|
||||
userId: number;
|
||||
@ -66,6 +68,11 @@ export const setRecipientsForDocument = async ({
|
||||
include: {
|
||||
Field: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -280,10 +287,14 @@ export const setRecipientsForDocument = async ({
|
||||
});
|
||||
});
|
||||
|
||||
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).recipientRemoved;
|
||||
|
||||
// Send emails to deleted recipients.
|
||||
await Promise.all(
|
||||
removedRecipients.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT) {
|
||||
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientRemovedEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -295,6 +306,10 @@ export const setRecipientsForDocument = async ({
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
|
||||
|
||||
@ -11,10 +11,12 @@ import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export type CreateTeamEmailVerificationOptions = {
|
||||
userId: number;
|
||||
@ -48,6 +50,7 @@ export const createTeamEmailVerification = async ({
|
||||
include: {
|
||||
teamEmail: true,
|
||||
emailVerification: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -80,7 +83,7 @@ export const createTeamEmailVerification = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
|
||||
await sendTeamEmailVerificationEmail(data.email, token, team);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
@ -112,25 +115,36 @@ export const createTeamEmailVerification = async ({
|
||||
export const sendTeamEmailVerificationEmail = async (
|
||||
email: string,
|
||||
token: string,
|
||||
teamName: string,
|
||||
teamUrl: string,
|
||||
team: Team & {
|
||||
teamGlobalSettings?: TeamGlobalSettings | null;
|
||||
},
|
||||
) => {
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(ConfirmTeamEmailTemplate, {
|
||||
assetBaseUrl,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
teamName,
|
||||
teamUrl,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
token,
|
||||
});
|
||||
|
||||
const branding = team.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const lang = team.teamGlobalSettings?.documentLanguage;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template),
|
||||
renderEmailWithI18N(template, { plainText: true }),
|
||||
renderEmailWithI18N(template, { lang, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance();
|
||||
const i18n = await getI18nInstance(lang);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
@ -139,7 +153,7 @@ export const sendTeamEmailVerificationEmail = async (
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(
|
||||
msg`A request to use your email has been initiated by ${teamName} on Documenso`,
|
||||
msg`A request to use your email has been initiated by ${team.name} on Documenso`,
|
||||
),
|
||||
html,
|
||||
text,
|
||||
|
||||
@ -4,7 +4,6 @@ import { msg } from '@lingui/macro';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite';
|
||||
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
@ -12,11 +11,13 @@ import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
|
||||
import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export type CreateTeamMemberInvitesOptions = {
|
||||
userId: number;
|
||||
@ -59,6 +60,7 @@ export const createTeamMemberInvites = async ({
|
||||
},
|
||||
},
|
||||
invites: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -112,8 +114,7 @@ export const createTeamMemberInvites = async ({
|
||||
sendTeamMemberInviteEmail({
|
||||
email,
|
||||
token,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
team,
|
||||
senderName: userName,
|
||||
}),
|
||||
),
|
||||
@ -134,8 +135,13 @@ export const createTeamMemberInvites = async ({
|
||||
}
|
||||
};
|
||||
|
||||
type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
|
||||
type SendTeamMemberInviteEmailOptions = {
|
||||
email: string;
|
||||
senderName: string;
|
||||
token: string;
|
||||
team: Team & {
|
||||
teamGlobalSettings?: TeamGlobalSettings | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@ -143,20 +149,33 @@ type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | '
|
||||
*/
|
||||
export const sendTeamMemberInviteEmail = async ({
|
||||
email,
|
||||
...emailTemplateOptions
|
||||
senderName,
|
||||
token,
|
||||
team,
|
||||
}: SendTeamMemberInviteEmailOptions) => {
|
||||
const template = createElement(TeamInviteEmailTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
...emailTemplateOptions,
|
||||
senderName,
|
||||
token,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
});
|
||||
|
||||
const branding = team.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template),
|
||||
renderEmailWithI18N(template, { plainText: true }),
|
||||
renderEmailWithI18N(template, { lang: team.teamGlobalSettings?.documentLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: team.teamGlobalSettings?.documentLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance();
|
||||
const i18n = await getI18nInstance(team.teamGlobalSettings?.documentLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
@ -164,9 +183,7 @@ export const sendTeamMemberInviteEmail = async ({
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(
|
||||
msg`You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
|
||||
),
|
||||
subject: i18n._(msg`You have been invited to join ${team.name} on Documenso`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
@ -11,6 +11,7 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export type DeleteTeamEmailOptions = {
|
||||
userId: number;
|
||||
@ -54,6 +55,7 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -77,12 +79,18 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
|
||||
teamUrl: team.url,
|
||||
});
|
||||
|
||||
const branding = team.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const lang = team.teamGlobalSettings?.documentLanguage;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template),
|
||||
renderEmailWithI18N(template, { plainText: true }),
|
||||
renderEmailWithI18N(template, { lang, branding }),
|
||||
renderEmailWithI18N(template, { lang, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance();
|
||||
const i18n = await getI18nInstance(lang);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import type { TeamDeleteEmailProps } from '@documenso/email/templates/team-delete';
|
||||
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export type DeleteTeamOptions = {
|
||||
userId: number;
|
||||
@ -38,6 +42,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -60,6 +65,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
name: team.name,
|
||||
url: team.url,
|
||||
ownerUserId: team.ownerUserId,
|
||||
teamGlobalSettings: team.teamGlobalSettings,
|
||||
},
|
||||
members: team.members.map((member) => ({
|
||||
id: member.user.id,
|
||||
@ -80,33 +86,42 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
);
|
||||
};
|
||||
|
||||
type SendTeamDeleteEmailOptions = Omit<TeamDeleteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
|
||||
type SendTeamDeleteEmailOptions = {
|
||||
email: string;
|
||||
teamName: string;
|
||||
team: Pick<Team, 'url' | 'name'> & {
|
||||
teamGlobalSettings?: TeamGlobalSettings | null;
|
||||
};
|
||||
isOwner: boolean;
|
||||
};
|
||||
|
||||
export const sendTeamDeleteEmail = async ({
|
||||
email,
|
||||
...emailTemplateOptions
|
||||
}: SendTeamDeleteEmailOptions) => {
|
||||
export const sendTeamDeleteEmail = async ({ email, isOwner, team }: SendTeamDeleteEmailOptions) => {
|
||||
const template = createElement(TeamDeleteEmailTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
...emailTemplateOptions,
|
||||
teamUrl: team.url,
|
||||
isOwner,
|
||||
});
|
||||
|
||||
const branding = team.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const lang = team.teamGlobalSettings?.documentLanguage;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template),
|
||||
renderEmailWithI18N(template, { plainText: true }),
|
||||
renderEmailWithI18N(template, { lang, branding }),
|
||||
renderEmailWithI18N(template, { lang, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(lang);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `Team "${emailTemplateOptions.teamName}" has been deleted on Documenso`,
|
||||
subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
@ -30,6 +30,7 @@ export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => {
|
||||
where: whereFilter,
|
||||
include: {
|
||||
teamEmail: true,
|
||||
teamGlobalSettings: true,
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
@ -89,6 +90,7 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
|
||||
},
|
||||
},
|
||||
subscription: true,
|
||||
teamGlobalSettings: true,
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
|
||||
@ -33,6 +33,7 @@ export const resendTeamEmailVerification = async ({
|
||||
},
|
||||
include: {
|
||||
emailVerification: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -61,7 +62,7 @@ export const resendTeamEmailVerification = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
|
||||
await sendTeamEmailVerificationEmail(emailVerification.email, token, team);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
@ -49,6 +49,9 @@ export const resendTeamMemberInvitation = async ({
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
@ -69,9 +72,8 @@ export const resendTeamMemberInvitation = async ({
|
||||
await sendTeamMemberInviteEmail({
|
||||
email: teamMemberInvite.email,
|
||||
token: teamMemberInvite.token,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
senderName: userName,
|
||||
team,
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
export type UpdateTeamBrandingSettingsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
|
||||
settings: {
|
||||
brandingEnabled: boolean;
|
||||
brandingLogo: string;
|
||||
brandingUrl: string;
|
||||
brandingCompanyDetails: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const updateTeamBrandingSettings = async ({
|
||||
userId,
|
||||
teamId,
|
||||
settings,
|
||||
}: UpdateTeamBrandingSettingsOptions) => {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = settings;
|
||||
|
||||
const member = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!member || member.role !== TeamMemberRole.ADMIN) {
|
||||
throw new Error('You do not have permission to update this team.');
|
||||
}
|
||||
|
||||
return await prisma.teamGlobalSettings.upsert({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
create: {
|
||||
teamId,
|
||||
brandingEnabled,
|
||||
brandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
},
|
||||
update: {
|
||||
brandingEnabled,
|
||||
brandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,52 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
|
||||
export type UpdateTeamDocumentSettingsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
|
||||
settings: {
|
||||
documentVisibility: DocumentVisibility;
|
||||
documentLanguage: SupportedLanguageCodes;
|
||||
includeSenderDetails: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const updateTeamDocumentSettings = async ({
|
||||
userId,
|
||||
teamId,
|
||||
settings,
|
||||
}: UpdateTeamDocumentSettingsOptions) => {
|
||||
const { documentVisibility, documentLanguage, includeSenderDetails } = settings;
|
||||
|
||||
const member = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!member || member.role !== TeamMemberRole.ADMIN) {
|
||||
throw new Error('You do not have permission to update this team.');
|
||||
}
|
||||
|
||||
return await prisma.teamGlobalSettings.upsert({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
create: {
|
||||
teamId,
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
},
|
||||
update: {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -4,6 +4,7 @@ import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
import type { DocumentVisibility } from '@documenso/prisma/client';
|
||||
|
||||
export type UpdateTeamOptions = {
|
||||
userId: number;
|
||||
@ -11,6 +12,8 @@ export type UpdateTeamOptions = {
|
||||
data: {
|
||||
name?: string;
|
||||
url?: string;
|
||||
documentVisibility?: DocumentVisibility;
|
||||
includeSenderDetails?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@ -42,6 +45,18 @@ export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) =>
|
||||
data: {
|
||||
url: data.url,
|
||||
name: data.name,
|
||||
teamGlobalSettings: {
|
||||
upsert: {
|
||||
create: {
|
||||
documentVisibility: data.documentVisibility,
|
||||
includeSenderDetails: data.includeSenderDetails,
|
||||
},
|
||||
update: {
|
||||
documentVisibility: data.documentVisibility,
|
||||
includeSenderDetails: data.includeSenderDetails,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -40,6 +40,7 @@ import {
|
||||
extractDocumentAuthMethods,
|
||||
} from '../../utils/document-auth';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { formatDocumentsPath } from '../../utils/teams';
|
||||
import { sendDocument } from '../document/send-document';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
@ -91,6 +92,11 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
templateDocumentData: true,
|
||||
templateMeta: true,
|
||||
User: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -145,7 +151,8 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT;
|
||||
const metaEmailMessage = template.templateMeta?.message || '';
|
||||
const metaEmailSubject = template.templateMeta?.subject || '';
|
||||
const metaLanguage = template.templateMeta?.language;
|
||||
const metaLanguage =
|
||||
template.templateMeta?.language ?? template.team?.teamGlobalSettings?.documentLanguage;
|
||||
const metaSigningOrder = template.templateMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
|
||||
|
||||
// Associate, validate and map to a query every direct template recipient field with the provided fields.
|
||||
@ -237,6 +244,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
createdAt: initialRequestTime,
|
||||
status: DocumentStatus.PENDING,
|
||||
externalId: directTemplateExternalId,
|
||||
visibility: template.team?.teamGlobalSettings?.documentVisibility,
|
||||
documentDataId: documentData.id,
|
||||
authOptions: createDocumentAuthOptions({
|
||||
globalAccessAuth: templateAuthOptions.globalAccessAuth,
|
||||
@ -275,6 +283,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
subject: metaEmailSubject,
|
||||
language: metaLanguage,
|
||||
signingOrder: metaSigningOrder,
|
||||
distributionMethod: template.templateMeta?.distributionMethod,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -533,9 +542,13 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
|
||||
});
|
||||
|
||||
const branding = template.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(template.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(emailTemplate, { lang: metaLanguage }),
|
||||
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, plainText: true }),
|
||||
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding }),
|
||||
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(metaLanguage);
|
||||
|
||||
@ -47,6 +47,11 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
Field: true,
|
||||
templateDocumentData: true,
|
||||
templateMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -69,6 +74,7 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
userId,
|
||||
teamId: template.teamId,
|
||||
title: template.title,
|
||||
visibility: template.team?.teamGlobalSettings?.documentVisibility,
|
||||
documentDataId: documentData.id,
|
||||
Recipient: {
|
||||
create: template.Recipient.map((recipient) => ({
|
||||
@ -87,7 +93,8 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
dateFormat: template.templateMeta?.dateFormat,
|
||||
redirectUrl: template.templateMeta?.redirectUrl,
|
||||
signingOrder: template.templateMeta?.signingOrder ?? undefined,
|
||||
language: template.templateMeta?.language,
|
||||
language:
|
||||
template.templateMeta?.language || template.team?.teamGlobalSettings?.documentLanguage,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentDistributionMethod } from '@documenso/prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentSource,
|
||||
@ -62,6 +63,7 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
redirectUrl?: string;
|
||||
signingOrder?: DocumentSigningOrder;
|
||||
language?: SupportedLanguageCodes;
|
||||
distributionMethod?: DocumentDistributionMethod;
|
||||
};
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
@ -108,6 +110,11 @@ export const createDocumentFromTemplate = async ({
|
||||
},
|
||||
templateDocumentData: true,
|
||||
templateMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -169,6 +176,7 @@ export const createDocumentFromTemplate = async ({
|
||||
globalAccessAuth: templateAuthOptions.globalAccessAuth,
|
||||
globalActionAuth: templateAuthOptions.globalActionAuth,
|
||||
}),
|
||||
visibility: template.team?.teamGlobalSettings?.documentVisibility,
|
||||
documentMeta: {
|
||||
create: {
|
||||
subject: override?.subject || template.templateMeta?.subject,
|
||||
@ -177,11 +185,17 @@ export const createDocumentFromTemplate = async ({
|
||||
password: override?.password || template.templateMeta?.password,
|
||||
dateFormat: override?.dateFormat || template.templateMeta?.dateFormat,
|
||||
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
|
||||
distributionMethod:
|
||||
override?.distributionMethod || template.templateMeta?.distributionMethod,
|
||||
emailSettings: template.templateMeta?.emailSettings || undefined,
|
||||
signingOrder:
|
||||
override?.signingOrder ||
|
||||
template.templateMeta?.signingOrder ||
|
||||
DocumentSigningOrder.PARALLEL,
|
||||
language: override?.language || template.templateMeta?.language,
|
||||
language:
|
||||
override?.language ||
|
||||
template.templateMeta?.language ||
|
||||
template.team?.teamGlobalSettings?.documentLanguage,
|
||||
},
|
||||
},
|
||||
Recipient: {
|
||||
|
||||
@ -60,7 +60,10 @@ export const duplicateTemplate = async ({
|
||||
|
||||
if (template.templateMeta) {
|
||||
templateMeta = {
|
||||
create: omit(template.templateMeta, ['id', 'templateId']),
|
||||
create: {
|
||||
...omit(template.templateMeta, ['id', 'templateId']),
|
||||
emailSettings: template.templateMeta.emailSettings || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -54,6 +54,7 @@ export const findTemplates = async ({
|
||||
templateMeta: {
|
||||
select: {
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
},
|
||||
},
|
||||
directLink: {
|
||||
|
||||
@ -112,9 +112,11 @@ export const updateTemplateSettings = async ({
|
||||
},
|
||||
create: {
|
||||
...meta,
|
||||
emailSettings: meta?.emailSettings || undefined,
|
||||
},
|
||||
update: {
|
||||
...meta,
|
||||
emailSettings: meta?.emailSettings || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user