fix: merge conflicts

This commit is contained in:
Ephraim Atta-Duncan
2024-11-22 11:12:49 +00:00
364 changed files with 31009 additions and 3755 deletions

View File

@ -7,58 +7,94 @@ import { setupI18n } from '@lingui/core';
import { setI18n } from '@lingui/react/server';
import { IS_APP_WEB } from '../../constants/app';
import { SUPPORTED_LANGUAGE_CODES } from '../../constants/i18n';
import {
APP_I18N_OPTIONS,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '../../constants/i18n';
import { extractLocaleData } from '../../utils/i18n';
import { remember } from '../../utils/remember';
type SupportedLanguages = (typeof SUPPORTED_LANGUAGE_CODES)[number];
async function loadCatalog(lang: SupportedLanguages): Promise<{
export async function loadCatalog(lang: SupportedLanguages): Promise<{
[k: string]: Messages;
}> {
const extension = process.env.NODE_ENV === 'development' ? 'po' : 'js';
const context = IS_APP_WEB ? 'web' : 'marketing';
const { messages } = await import(`../../translations/${lang}/${context}.${extension}`);
let { messages } = await import(`../../translations/${lang}/${context}.${extension}`);
if (extension === 'po') {
const { messages: commonMessages } = await import(
`../../translations/${lang}/common.${extension}`
);
messages = { ...messages, ...commonMessages };
}
return {
[lang]: messages,
};
}
const catalogs = await Promise.all(SUPPORTED_LANGUAGE_CODES.map(loadCatalog));
const catalogs = Promise.all(SUPPORTED_LANGUAGE_CODES.map(loadCatalog));
// transform array of catalogs into a single object
export const allMessages = catalogs.reduce((acc, oneCatalog) => {
return { ...acc, ...oneCatalog };
}, {});
const allMessages = async () => {
return await catalogs.then((catalogs) =>
catalogs.reduce((acc, oneCatalog) => {
return {
...acc,
...oneCatalog,
};
}, {}),
);
};
type AllI18nInstances = { [K in SupportedLanguages]: I18n };
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
export const allI18nInstances = SUPPORTED_LANGUAGE_CODES.reduce((acc, lang) => {
const messages = allMessages[lang] ?? {};
export const allI18nInstances = remember('i18n.allI18nInstances', async () => {
const loadedMessages = await allMessages();
const i18n = setupI18n({
locale: lang,
messages: { [lang]: messages },
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return SUPPORTED_LANGUAGE_CODES.reduce((acc, lang) => {
const messages = loadedMessages[lang] ?? {};
return { ...acc, [lang]: i18n };
}, {}) as AllI18nInstances;
const i18n = setupI18n({
locale: lang,
messages: { [lang]: messages },
});
return { ...acc, [lang]: i18n };
}, {}) as AllI18nInstances;
});
// eslint-disable-next-line @typescript-eslint/ban-types
export const getI18nInstance = async (lang?: SupportedLanguages | (string & {})) => {
const instances = await allI18nInstances;
if (!isValidLanguageCode(lang)) {
return instances[APP_I18N_OPTIONS.sourceLang];
}
return instances[lang] ?? instances[APP_I18N_OPTIONS.sourceLang];
};
/**
* This needs to be run in all layouts and page server components that require i18n.
*
* https://lingui.dev/tutorials/react-rsc#pages-layouts-and-lingui
*/
export const setupI18nSSR = () => {
export const setupI18nSSR = async () => {
const { lang, locales } = extractLocaleData({
cookies: cookies(),
headers: headers(),
});
// Get and set a ready-made i18n instance for the given language.
const i18n = allI18nInstances[lang];
const i18n = await getI18nInstance(lang);
// Reactivate the i18n instance with the locale for date and number formatting.
i18n.activate(lang, locales);

View File

@ -6,6 +6,7 @@ export enum RecipientStatusType {
OPENED = 'opened',
WAITING = 'waiting',
UNSIGNED = 'unsigned',
REJECTED = 'rejected',
}
export const getRecipientType = (recipient: Recipient) => {
@ -16,6 +17,10 @@ export const getRecipientType = (recipient: Recipient) => {
return RecipientStatusType.COMPLETED;
}
if (recipient.signingStatus === SigningStatus.REJECTED) {
return RecipientStatusType.REJECTED;
}
if (
recipient.sendStatus === SendStatus.SENT &&
recipient.readStatus === ReadStatus.OPENED &&

View File

@ -0,0 +1,34 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/macro';
import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
export const DOCUMENT_STATUS: {
[status in DocumentStatus]: { description: MessageDescriptor };
} = {
[DocumentStatus.COMPLETED]: {
description: msg`Completed`,
},
[DocumentStatus.DRAFT]: {
description: msg`Draft`,
},
[DocumentStatus.PENDING]: {
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>;

View File

@ -1,6 +1,6 @@
import { z } from 'zod';
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr'] as const;
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es'] as const;
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
@ -42,4 +42,11 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
full: 'French',
short: 'fr',
},
es: {
full: 'Spanish',
short: 'es',
},
} satisfies Record<SupportedLanguageCodes, SupportedLanguage>;
export const isValidLanguageCode = (code: unknown): code is SupportedLanguageCodes =>
SUPPORTED_LANGUAGE_CODES.includes(code as SupportedLanguageCodes);

View File

@ -9,63 +9,35 @@ 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`,
},
[RecipientRole.CC]: {
actionVerb: `CC`,
actioned: `CC'd`,
progressiveVerb: `CC`,
roleName: `Cc`,
},
[RecipientRole.SIGNER]: {
actionVerb: `Sign`,
actioned: `Signed`,
progressiveVerb: `Signing`,
roleName: `Signer`,
},
[RecipientRole.VIEWER]: {
actionVerb: `View`,
actioned: `Viewed`,
progressiveVerb: `Viewing`,
roleName: `Viewer`,
roleNamePlural: msg`Viewers`,
},
} satisfies Record<keyof typeof RecipientRole, unknown>;
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
[RecipientRole.SIGNER]: `SIGNING_REQUEST`,
[RecipientRole.VIEWER]: `VIEW_REQUEST`,
[RecipientRole.APPROVER]: `APPROVE_REQUEST`,
} as const;
export const RECIPIENT_ROLE_SIGNING_REASONS = {
@ -74,13 +46,3 @@ export const RECIPIENT_ROLE_SIGNING_REASONS = {
[RecipientRole.CC]: msg`I am required to receive a copy of this document`,
[RecipientRole.VIEWER]: msg`I am a viewer of this document`,
} satisfies Record<keyof typeof RecipientRole, MessageDescriptor>;
/**
* Raw english descriptions for certificates.
*/
export const RECIPIENT_ROLE_SIGNING_REASONS_ENG = {
[RecipientRole.SIGNER]: `I am a signer of this document`,
[RecipientRole.APPROVER]: `I am an approver of this document`,
[RecipientRole.CC]: `I am required to receive a copy of this document`,
[RecipientRole.VIEWER]: `I am a viewer of this document`,
} satisfies Record<keyof typeof RecipientRole, string>;

View File

@ -1,5 +1,6 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
@ -17,6 +18,7 @@ export const jobsClient = new JobClient([
SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;

View File

@ -43,18 +43,10 @@ export class LocalJobProvider extends BaseJobProvider {
}
public async triggerJob(options: SimpleTriggerJobOptions) {
console.log({ jobDefinitions: this._jobDefinitions });
const eligibleJobs = Object.values(this._jobDefinitions).filter(
(job) => job.trigger.name === options.name,
);
console.log({ options });
console.log(
'Eligible jobs:',
eligibleJobs.map((job) => job.name),
);
await Promise.all(
eligibleJobs.map(async (job) => {
// Ideally we will change this to a createMany with returning later once we upgrade Prisma
@ -177,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 =
@ -303,7 +295,7 @@ export class LocalJobProvider extends BaseJobProvider {
});
return result;
} catch {
} catch (err) {
task = await prisma.backgroundJobTask.update({
where: {
id: task.id,
@ -317,6 +309,8 @@ export class LocalJobProvider extends BaseJobProvider {
},
});
console.log(`[JOBS:${task.id}] Task failed`, err);
throw new BackgroundTaskFailedError('Task failed');
}
},

View File

@ -0,0 +1,169 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } 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 { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import { formatDocumentsPath } from '../../../utils/teams';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID = 'send.signing.rejected.emails';
const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
documentId: z.number(),
recipientId: z.number(),
});
export const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION = {
id: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID,
name: 'Send Rejection Emails',
version: '1.0.0',
trigger: {
name: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID,
schema: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const { documentId, recipientId } = payload;
const [document, recipient] = await Promise.all([
prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
User: true,
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
url: true,
teamGlobalSettings: true,
},
},
},
}),
prisma.recipient.findFirstOrThrow({
where: {
id: recipientId,
signingStatus: SigningStatus.REJECTED,
},
}),
]);
const { documentMeta, team, User: documentOwner } = document;
const isEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
if (!isEmailEnabled) {
return;
}
const i18n = await getI18nInstance(documentMeta?.language);
// Send confirmation email to the recipient who rejected
await io.runTask('send-rejection-confirmation-email', async () => {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
recipientName: recipient.name,
documentName: document.title,
documentOwnerName: document.User.name || document.User.email,
reason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(recipientTemplate, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Document "${document.title}" - Rejection Confirmed`),
html,
text,
});
});
// Send notification email to document owner
await io.runTask('send-owner-notification-email', async () => {
const ownerTemplate = createElement(DocumentRejectedEmail, {
recipientName: recipient.name,
documentName: document.title,
documentUrl: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(document.team?.url)}/${
document.id
}`,
rejectionReason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(ownerTemplate, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(ownerTemplate, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: documentOwner.name || '',
address: documentOwner.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Document "${document.title}" - Rejected by ${recipient.name}`),
html,
text,
});
});
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
});
},
} as const satisfies JobDefinition<
typeof SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID,
z.infer<typeof SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA>
>;

View File

@ -1,9 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { prisma } from '@documenso/prisma';
import {
@ -13,16 +13,20 @@ import {
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 {
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';
@ -62,6 +66,7 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
select: {
teamEmail: true,
name: true,
teamGlobalSettings: true,
},
},
},
@ -79,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;
@ -87,25 +100,43 @@ 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 = `Please ${recipientActionVerb} this document`;
let emailSubject = i18n._(msg`Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`;
emailSubject = `Please ${recipientActionVerb} your document`;
emailMessage = i18n._(
msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Please ${recipientActionVerb} your document`);
}
if (isDirectTemplate) {
emailMessage = `A document was created by your direct template that requires you to ${recipientActionVerb} it.`;
emailSubject = `Please ${recipientActionVerb} this document created by your direct template`;
emailMessage = i18n._(
msg`A document was created by your direct template that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(
msg`Please ${recipientActionVerb} this document created by your direct template`,
);
}
if (isTeamDocument && team) {
emailSubject = `${team.name} invited you to ${recipientActionVerb} a document`;
emailMessage = `${user.name} on behalf of ${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`;
emailSubject = i18n._(msg`${team.name} invited you to ${recipientActionVerb} a document`);
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 = {
@ -129,9 +160,23 @@ 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, branding }),
renderEmailWithI18N(template, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
@ -145,8 +190,8 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
documentMeta?.subject || emailSubject,
customEmailTemplate,
),
html: render(template),
text: render(template, { plainText: true }),
html,
text,
});
});

View File

@ -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,
});
});

View File

@ -1,13 +1,18 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
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';
@ -41,6 +46,7 @@ export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
user: true,
},
},
teamGlobalSettings: true,
},
});
@ -62,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 || '',
@ -71,15 +77,36 @@ 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, {
lang,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'A new member has joined your team',
html: render(emailContent),
text: render(emailContent, { plainText: true }),
subject: i18n._(msg`A new member has joined your team`),
html,
text,
});
},
);

View File

@ -1,13 +1,18 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
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';
@ -41,6 +46,7 @@ export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
user: true,
},
},
teamGlobalSettings: true,
},
});
@ -52,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 || '',
@ -61,15 +67,35 @@ 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, {
lang,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `A team member has left ${team.name}`,
html: render(emailContent),
text: render(emailContent, { plainText: true }),
subject: i18n._(msg`A team member has left ${team.name}`),
html,
text,
});
});
}

View File

@ -25,6 +25,9 @@
"@documenso/email": "*",
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@lingui/core": "^4.11.3",
"@lingui/macro": "^4.11.3",
"@lingui/react": "^4.11.3",
"@next-auth/prisma-adapter": "1.0.7",
"@noble/ciphers": "0.4.0",
"@noble/hashes": "1.3.2",

View File

@ -1,21 +1,25 @@
import { base32 } from '@scure/base';
import { TOTPController } from 'oslo/otp';
import { generateHOTP } from 'oslo/otp';
import type { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';
const totp = new TOTPController();
type VerifyTwoFactorAuthenticationTokenOptions = {
user: User;
totpCode: string;
// The number of windows to look back
window?: number;
// The duration that the token is valid for in seconds
period?: number;
};
export const verifyTwoFactorAuthenticationToken = async ({
user,
totpCode,
window = 1,
period = 30_000,
}: VerifyTwoFactorAuthenticationTokenOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
@ -27,7 +31,21 @@ export const verifyTwoFactorAuthenticationToken = async ({
'utf-8',
);
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
const decodedSecret = base32.decode(secret);
return isValidToken;
let now = Date.now();
for (let i = 0; i < window; i++) {
const counter = Math.floor(now / period);
const hotp = await generateHOTP(decodedSecret, counter);
if (totpCode === hotp) {
return true;
}
now -= period;
}
return false;
};

View File

@ -13,6 +13,7 @@ export const getRecipientsStats = async () => {
[ReadStatus.NOT_OPENED]: 0,
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};

View File

@ -1,11 +1,14 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendConfirmationEmailProps {
userId: number;
@ -45,6 +48,13 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
confirmationLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(confirmationTemplate),
renderEmailWithI18N(confirmationTemplate, { plainText: true }),
]);
const i18n = await getI18nInstance();
return mailer.sendMail({
to: {
address: user.email,
@ -54,8 +64,8 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
name: senderName,
address: senderAddress,
},
subject: 'Please confirm your email',
html: render(confirmationTemplate),
text: render(confirmationTemplate, { plainText: true }),
subject: i18n._(msg`Please confirm your email`),
html,
text,
});
};

View File

@ -1,11 +1,14 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendForgotPasswordOptions {
userId: number;
@ -39,6 +42,13 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
resetPasswordLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
return await mailer.sendMail({
to: {
address: user.email,
@ -48,8 +58,8 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Forgot Password?',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Forgot Password?`),
html,
text,
});
};

View File

@ -1,11 +1,11 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendResetPasswordOptions {
userId: number;
@ -26,6 +26,11 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
userName: user.name || '',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
return await mailer.sendMail({
to: {
address: user.email,
@ -36,7 +41,7 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Password Reset Success!',
html: render(template),
text: render(template, { plainText: true }),
html,
text,
});
};

View File

@ -7,7 +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;
@ -17,7 +20,11 @@ export type CreateDocumentMetaOptions = {
password?: string;
dateFormat?: string;
redirectUrl?: string;
emailSettings?: TDocumentEmailSettings;
signingOrder?: DocumentSigningOrder;
distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
userId: number;
requestMetadata: RequestMetadata;
};
@ -32,6 +39,10 @@ export const upsertDocumentMeta = async ({
userId,
redirectUrl,
signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled,
language,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const user = await prisma.user.findFirstOrThrow({
@ -82,6 +93,10 @@ export const upsertDocumentMeta = async ({
documentId,
redirectUrl,
signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled,
language,
},
update: {
subject,
@ -91,6 +106,10 @@ export const upsertDocumentMeta = async ({
timezone,
redirectUrl,
signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled,
language,
},
});

View File

@ -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,
},
},
},
});

View File

@ -2,18 +2,30 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
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;
@ -46,8 +58,9 @@ export const deleteDocument = async ({
Recipient: true,
documentMeta: true,
team: {
select: {
include: {
members: true,
teamGlobalSettings: true,
},
},
},
@ -70,6 +83,7 @@ export const deleteDocument = async ({
await handleDocumentOwnerDelete({
document,
user,
team: document.team,
requestMetadata,
});
}
@ -110,6 +124,11 @@ type HandleDocumentOwnerDeleteOptions = {
Recipient: Recipient[];
documentMeta: DocumentMeta | null;
};
team?:
| (Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
})
| null;
user: User;
requestMetadata?: RequestMetadata;
};
@ -117,6 +136,7 @@ type HandleDocumentOwnerDeleteOptions = {
const handleDocumentOwnerDelete = async ({
document,
user,
team,
requestMetadata,
}: HandleDocumentOwnerDeleteOptions) => {
if (document.deletedAt) {
@ -185,6 +205,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) => {
@ -201,6 +229,21 @@ const handleDocumentOwnerDelete = async ({
assetBaseUrl,
});
const branding = team?.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: recipient.email,
@ -210,9 +253,9 @@ const handleDocumentOwnerDelete = async ({
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);

View File

@ -67,6 +67,7 @@ export const findDocumentAuditLogs = async ({
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
],

View File

@ -2,8 +2,9 @@ import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { Prisma, RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import type { Document, Team, TeamEmail, User } from '@documenso/prisma/client';
import { RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import type { Document, DocumentSource, Team, TeamEmail, User } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DocumentVisibility } from '../../types/document-visibility';
@ -16,6 +17,8 @@ export type FindDocumentsOptions = {
userId: number;
teamId?: number;
term?: string;
templateId?: number;
source?: DocumentSource;
status?: ExtendedDocumentStatus;
page?: number;
perPage?: number;
@ -32,6 +35,8 @@ export const findDocuments = async ({
userId,
teamId,
term,
templateId,
source,
status = ExtendedDocumentStatus.ALL,
page = 1,
perPage = 10,
@ -40,40 +45,38 @@ export const findDocuments = async ({
senderIds,
search,
}: FindDocumentsOptions) => {
const { user, team } = await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
where: { id: userId },
});
let team = null;
if (teamId !== undefined) {
team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
teamEmail: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
}
return { user, team };
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
let team = null;
if (teamId !== undefined) {
team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
teamEmail: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
}
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const teamMemberRole = team?.members[0].role ?? null;
@ -113,11 +116,18 @@ export const findDocuments = async ({
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })),
{
Recipient: {
some: {
email: user.email,
OR: [
{
Recipient: {
some: {
email: user.email,
},
},
},
},
{
userId: user.id,
},
],
},
];
@ -137,10 +147,80 @@ export const findDocuments = async ({
};
}
let deletedFilter: Prisma.DocumentWhereInput = {
AND: {
OR: [
{
userId: user.id,
deletedAt: null,
},
{
Recipient: {
some: {
email: user.email,
documentDeletedAt: null,
},
},
},
],
},
};
if (team) {
deletedFilter = {
AND: {
OR: team.teamEmail
? [
{
teamId: team.id,
deletedAt: null,
},
{
User: {
email: team.teamEmail.email,
},
deletedAt: null,
},
{
Recipient: {
some: {
email: team.teamEmail.email,
documentDeletedAt: null,
},
},
},
]
: [
{
teamId: team.id,
deletedAt: null,
},
],
},
};
}
const whereAndClause: Prisma.DocumentWhereInput['AND'] = [
{ ...termFilters },
{ ...filters },
{ ...deletedFilter },
{ ...searchFilter },
];
if (templateId) {
whereAndClause.push({
templateId,
});
}
if (source) {
whereAndClause.push({
source,
});
}
const whereClause: Prisma.DocumentWhereInput = {
...termFilters,
...filters,
...searchFilter,
AND: whereAndClause,
};
if (period) {

View File

@ -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,
},
],
},
];

View File

@ -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'>;

View File

@ -112,6 +112,7 @@ export const isRecipientAuthorized = async ({
return await verifyTwoFactorAuthenticationToken({
user,
totpCode: token,
window: 10, // 5 minutes worth of tokens
});
})
.exhaustive();

View File

@ -0,0 +1,92 @@
import { SigningStatus } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type RejectDocumentWithTokenOptions = {
token: string;
documentId: number;
reason: string;
requestMetadata?: RequestMetadata;
};
export async function rejectDocumentWithToken({
token,
documentId,
reason,
requestMetadata,
}: RejectDocumentWithTokenOptions) {
// Find the recipient and document in a single query
const recipient = await prisma.recipient.findFirst({
where: {
token,
documentId,
},
include: {
Document: {
include: {
User: true,
},
},
},
});
const document = recipient?.Document;
if (!recipient || !document) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Document or recipient not found',
});
}
// Add the audit log entry before updating the recipient
// Update the recipient status to rejected
const [updatedRecipient] = await prisma.$transaction([
prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signedAt: new Date(),
signingStatus: SigningStatus.REJECTED,
rejectionReason: reason,
},
}),
prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
user: {
name: recipient.name,
email: recipient.email,
},
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
reason,
},
requestMetadata,
}),
}),
]);
// Send email notifications
await jobs.triggerJob({
name: 'send.signing.rejected.emails',
payload: {
recipientId: recipient.id,
documentId,
},
});
return updatedRecipient;
}

View File

@ -1,11 +1,12 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION_ENG,
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -16,7 +17,11 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
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 = {
@ -62,6 +67,7 @@ export const resendDocument = async ({
select: {
teamEmail: true,
name: true,
teamGlobalSettings: true,
},
},
},
@ -86,31 +92,50 @@ 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) {
return;
}
const i18n = await getI18nInstance(document.documentMeta?.language);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const recipientActionVerb =
RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].actionVerb.toLowerCase();
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = `Reminder: Please ${recipientActionVerb} this document`;
let emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`;
emailSubject = `Reminder: Please ${recipientActionVerb} your document`;
emailMessage = i18n._(
msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
}
if (isTeamDocument && document.team) {
emailSubject = `Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`;
emailMessage = `${user.name} on behalf of ${document.team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`;
emailSubject = i18n._(
msg`Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`,
);
emailMessage =
customEmail?.message ||
i18n._(
msg`${user.name} on behalf of ${document.team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
);
}
const customEmailTemplate = {
@ -135,8 +160,24 @@ 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,
branding,
}),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
address: email,
@ -147,10 +188,13 @@ export const resendDocument = async ({
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${customEmail.subject}`),
customEmailTemplate,
)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),
html,
text,
});
await tx.documentAuditLog.create({

View File

@ -10,6 +10,7 @@ import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
@ -45,6 +46,7 @@ export const sealDocument = async ({
},
include: {
documentData: true,
documentMeta: true,
Recipient: true,
},
});
@ -90,7 +92,9 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
const certificate = await getCertificatePdf({ documentId })
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const certificate = await getCertificatePdf({ documentId, language: documentLanguage })
.then(async (doc) => PDFDocument.load(doc))
.catch(() => null);

View File

@ -1,6 +1,10 @@
import { match } from 'ts-pattern';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
export type SearchDocumentsWithKeywordOptions = {
query: string;
@ -67,10 +71,40 @@ export const searchDocumentsWithKeyword = async ({
},
deletedAt: null,
},
{
title: {
contains: query,
mode: 'insensitive',
},
teamId: {
not: null,
},
team: {
members: {
some: {
userId: userId,
},
},
},
deletedAt: null,
},
],
},
include: {
Recipient: true,
team: {
select: {
url: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
},
},
orderBy: {
createdAt: 'desc',
@ -82,15 +116,48 @@ export const searchDocumentsWithKeyword = async ({
const getSigningLink = (recipients: Recipient[], user: User) =>
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
const maskedDocuments = documents.map((document) => {
const { Recipient, ...documentWithoutRecipient } = document;
const maskedDocuments = documents
.filter((document) => {
if (!document.teamId || isOwner(document, user)) {
return true;
}
return {
...documentWithoutRecipient,
path: isOwner(document, user) ? `/documents/${document.id}` : getSigningLink(Recipient, user),
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
};
});
const teamMemberRole = document.team?.members[0]?.role;
if (!teamMemberRole) {
return false;
}
const canAccessDocument = match([document.visibility, teamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
return canAccessDocument;
})
.map((document) => {
const { Recipient, ...documentWithoutRecipient } = document;
let documentPath;
if (isOwner(document, user)) {
documentPath = `${formatDocumentsPath(document.team?.url)}/${document.id}`;
} else if (document.teamId && document.team) {
documentPath = `${formatDocumentsPath(document.team.url)}/${document.id}`;
} else {
documentPath = getSigningLink(Recipient, user);
}
return {
...documentWithoutRecipient,
path: documentPath,
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
};
});
return maskedDocuments;
};

View File

@ -1,17 +1,23 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma';
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';
import { formatDocumentsPath } from '../../utils/teams';
export interface SendDocumentOptions {
documentId: number;
@ -32,6 +38,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
select: {
id: true,
url: true,
teamGlobalSettings: true,
},
},
},
@ -53,7 +60,9 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/documents/${document.id}`;
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(
document.team?.url,
)}/${document.id}`;
if (document.team?.url) {
documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/t/${document.team.url}/documents/${
@ -61,14 +70,36 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
}`;
}
// 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 i18n = await getI18nInstance(document.documentMeta?.language);
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, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: [
{
@ -80,9 +111,9 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Signing Complete!`),
html,
text,
attachments: [
{
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
@ -109,6 +140,10 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
});
}
if (!isDocumentCompletedEmailEnabled) {
return;
}
await Promise.all(
document.Recipient.map(async (recipient) => {
const customEmailTemplate = {
@ -129,6 +164,19 @@ 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, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: [
{
@ -143,9 +191,9 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
subject:
isDirectTemplate && document.documentMeta?.subject
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)
: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
: i18n._(msg`Signing Complete!`),
html,
text,
attachments: [
{
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',

View File

@ -1,11 +1,16 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
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;
@ -19,6 +24,12 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
},
include: {
User: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -26,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';
@ -36,6 +55,21 @@ 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, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance();
await mailer.sendMail({
to: {
address: email,
@ -45,8 +79,8 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Document Deleted!',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Document Deleted!`),
html,
text,
});
};

View File

@ -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) {

View File

@ -1,11 +1,16 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
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;
@ -28,6 +33,12 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
id: recipientId,
},
},
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -39,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;
@ -50,6 +69,21 @@ 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, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: email,
@ -59,8 +93,8 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Waiting for others to complete signing.',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Waiting for others to complete signing.`),
html,
text,
});
};

View File

@ -2,17 +2,22 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
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 SuperDeleteDocumentOptions = {
id: number;
@ -28,6 +33,11 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
Recipient: true,
documentMeta: true,
User: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -37,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) {
@ -53,6 +71,21 @@ 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, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: recipient.email,
@ -62,9 +95,9 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);

View File

@ -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,
});

View File

@ -2,13 +2,15 @@ import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetCertificatePdfOptions = {
documentId: number;
language?: SupportedLanguageCodes;
};
export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions) => {
export const getCertificatePdf = async ({ documentId, language }: GetCertificatePdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
@ -32,7 +34,19 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
);
}
const page = await browser.newPage();
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
if (language) {
await page.context().addCookies([
{
name: 'language',
value: language,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
]);
}
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle',
@ -43,6 +57,8 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
format: 'A4',
});
await browserContext.close();
void browser.close();
return result;

View File

@ -51,7 +51,7 @@ export const createApiToken = async ({
name: tokenName,
token: hashedToken,
expires: expiresIn ? DateTime.now().plus(timeConstantsRecords[expiresIn]).toJSDate() : null,
userId: teamId ? null : userId,
userId,
teamId,
},
});

View File

@ -25,8 +25,7 @@ export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOpt
return await prisma.apiToken.delete({
where: {
id,
userId: teamId ? null : userId,
teamId,
teamId: teamId ?? null,
},
});
};

View File

@ -8,6 +8,7 @@ export const getUserTokens = async ({ userId }: GetUserTokensOptions) => {
return await prisma.apiToken.findMany({
where: {
userId,
teamId: null,
},
select: {
id: true,

View File

@ -23,7 +23,8 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
throw new Error('Expired token');
}
if (apiToken.team) {
// Handle a silly choice from many moons ago
if (apiToken.team && !apiToken.user) {
apiToken.user = await prisma.user.findFirst({
where: {
id: apiToken.team.ownerUserId,
@ -33,9 +34,13 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
const { user } = apiToken;
// This will never happen but we need to narrow types
if (!user) {
throw new Error('Invalid token');
}
return { ...apiToken, user };
return {
...apiToken,
user,
};
};

View File

@ -1,8 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
@ -21,10 +22,14 @@ import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } 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 { 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;
@ -62,6 +67,12 @@ export const setRecipientsForDocument = async ({
},
include: {
Field: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -276,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;
}
@ -291,6 +306,17 @@ 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 }),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: recipient.email,
@ -300,9 +326,9 @@ export const setRecipientsForDocument = async ({
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'You have been removed from a document',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}),
);

View File

@ -42,7 +42,16 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
});
if (teamMemberInvite.status === TeamMemberInviteStatus.ACCEPTED) {
return;
const memberExists = await tx.teamMember.findFirst({
where: {
teamId: teamMemberInvite.teamId,
userId: user.id,
},
});
if (memberExists) {
return;
}
}
const { team } = teamMemberInvite;
@ -81,7 +90,7 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
await jobs.triggerJob({
name: 'send.team-member-joined.email',
payload: {
teamId: team.id,
teamId: teamMember.teamId,
memberId: teamMember.id,
},
});

View File

@ -1,9 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
@ -11,8 +11,13 @@ 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;
teamId: number;
@ -45,6 +50,7 @@ export const createTeamEmailVerification = async ({
include: {
teamEmail: true,
emailVerification: true,
teamGlobalSettings: true,
},
});
@ -77,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 },
);
@ -109,27 +115,47 @@ 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, { 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: `A request to use your email has been initiated by ${teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(
msg`A request to use your email has been initiated by ${team.name} on Documenso`,
),
html,
text,
});
};

View File

@ -1,10 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { nanoid } from 'nanoid';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
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,9 +11,14 @@ 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;
userName: string;
@ -56,6 +60,7 @@ export const createTeamMemberInvites = async ({
},
},
invites: true,
teamGlobalSettings: true,
},
});
@ -109,8 +114,7 @@ export const createTeamMemberInvites = async ({
sendTeamMemberInviteEmail({
email,
token,
teamName: team.name,
teamUrl: team.url,
team,
senderName: userName,
}),
),
@ -131,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;
};
};
/**
@ -140,22 +149,42 @@ 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, { lang: team.teamGlobalSettings?.documentLanguage, branding }),
renderEmailWithI18N(template, {
lang: team.teamGlobalSettings?.documentLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(team.teamGlobalSettings?.documentLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`You have been invited to join ${team.name} on Documenso`),
html,
text,
});
};

View File

@ -1,13 +1,18 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
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;
userEmail: string;
@ -50,6 +55,7 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
email: true,
},
},
teamGlobalSettings: true,
},
});
@ -73,6 +79,19 @@ 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, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {
address: team.owner.email,
@ -82,9 +101,9 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `Team email has been revoked for ${team.name}`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Team email has been revoked for ${team.name}`),
html,
text,
});
} catch (e) {
// Todo: Teams - Alert us.

View File

@ -1,16 +1,20 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
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,29 +86,43 @@ 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, { 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`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`),
html,
text,
});
};

View File

@ -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,

View File

@ -1,13 +1,17 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export type RequestTeamOwnershipTransferOptions = {
/**
* The ID of the user initiating the transfer.
@ -93,15 +97,24 @@ export const requestTeamOwnershipTransfer = async ({
token,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
await mailer.sendMail({
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(
msg`You have been requested to take ownership of team ${team.name} on Documenso`,
),
html,
text,
});
},
{ timeout: 30_000 },

View File

@ -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 },
);

View File

@ -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 },

View File

@ -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,
},
});
};

View File

@ -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,
},
});
};

View File

@ -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,
},
},
},
},
});

View File

@ -1,15 +1,16 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Field, Signature } from '@documenso/prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
FieldType,
@ -17,9 +18,11 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
@ -36,9 +39,12 @@ import {
createRecipientAuthOptions,
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';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentFromDirectTemplateOptions = {
directRecipientName?: string;
@ -86,6 +92,11 @@ export const createDocumentFromDirectTemplate = async ({
templateDocumentData: true,
templateMeta: true,
User: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -140,6 +151,9 @@ 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 ?? 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.
const createDirectRecipientFieldArgs = await Promise.all(
@ -230,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,
@ -254,6 +269,7 @@ export const createDocumentFromDirectTemplate = async ({
recipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: nanoid(),
};
}),
@ -265,6 +281,9 @@ export const createDocumentFromDirectTemplate = async ({
dateFormat: metaDateFormat,
message: metaEmailMessage,
subject: metaEmailSubject,
language: metaLanguage,
signingOrder: metaSigningOrder,
distributionMethod: template.templateMeta?.distributionMethod,
},
},
},
@ -328,6 +347,7 @@ export const createDocumentFromDirectTemplate = async ({
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
signedAt: initialRequestTime,
signingOrder: directTemplateRecipient.signingOrder,
Field: {
createMany: {
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => ({
@ -522,6 +542,17 @@ 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, branding }),
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(metaLanguage);
await mailer.sendMail({
to: [
{
@ -533,9 +564,9 @@ export const createDocumentFromDirectTemplate = async ({
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Document created from direct template',
html: render(emailTemplate),
text: render(emailTemplate, { plainText: true }),
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
return {
@ -553,6 +584,23 @@ export const createDocumentFromDirectTemplate = async ({
teamId: template.teamId || undefined,
requestMetadata,
});
const updatedDocument = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
Recipient: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
data: updatedDocument,
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
});
} catch (err) {
console.error('[CREATE_DOCUMENT_FROM_DIRECT_TEMPLATE]:', err);

View File

@ -46,6 +46,12 @@ export const createDocumentFromTemplateLegacy = async ({
Recipient: true,
Field: true,
templateDocumentData: true,
templateMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -68,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) => ({
@ -78,6 +85,18 @@ export const createDocumentFromTemplateLegacy = async ({
token: nanoid(),
})),
},
documentMeta: {
create: {
subject: template.templateMeta?.subject,
message: template.templateMeta?.message,
timezone: template.templateMeta?.timezone,
dateFormat: template.templateMeta?.dateFormat,
redirectUrl: template.templateMeta?.redirectUrl,
signingOrder: template.templateMeta?.signingOrder ?? undefined,
language:
template.templateMeta?.language || template.team?.teamGlobalSettings?.documentLanguage,
},
},
},
include: {

View File

@ -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,
@ -11,6 +12,7 @@ import {
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
@ -24,7 +26,10 @@ import {
} from '../../utils/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
type FinalRecipient = Pick<
Recipient,
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder'
> & {
templateRecipientId: number;
fields: Field[];
};
@ -57,6 +62,8 @@ export type CreateDocumentFromTemplateOptions = {
dateFormat?: string;
redirectUrl?: string;
signingOrder?: DocumentSigningOrder;
language?: SupportedLanguageCodes;
distributionMethod?: DocumentDistributionMethod;
};
requestMetadata?: RequestMetadata;
};
@ -103,6 +110,11 @@ export const createDocumentFromTemplate = async ({
},
templateDocumentData: true,
templateMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -164,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,
@ -172,10 +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 ||
template.team?.teamGlobalSettings?.documentLanguage,
},
},
Recipient: {
@ -197,6 +217,7 @@ export const createDocumentFromTemplate = async ({
recipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: nanoid(),
};
}),

View File

@ -1,3 +1,5 @@
import { omit } from 'remeda';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
@ -38,6 +40,7 @@ export const duplicateTemplate = async ({
Recipient: true,
Field: true,
templateDocumentData: true,
templateMeta: true,
},
});
@ -53,6 +56,17 @@ export const duplicateTemplate = async ({
},
});
let templateMeta: Prisma.TemplateCreateArgs['data']['templateMeta'] | undefined = undefined;
if (template.templateMeta) {
templateMeta = {
create: {
...omit(template.templateMeta, ['id', 'templateId']),
emailSettings: template.templateMeta.emailSettings || undefined,
},
};
}
const duplicatedTemplate = await prisma.template.create({
data: {
userId,
@ -66,8 +80,8 @@ export const duplicateTemplate = async ({
token: nanoid(),
})),
},
templateMeta,
},
include: {
Recipient: true,
},

View File

@ -51,6 +51,12 @@ export const findTemplates = async ({
},
Field: true,
Recipient: true,
templateMeta: {
select: {
signingOrder: true,
distributionMethod: true,
},
},
directLink: {
select: {
token: true,

View File

@ -42,6 +42,13 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
templateMeta: true,
Recipient: true,
Field: true,
User: {
select: {
id: true,
name: true,
email: true,
},
},
},
});

View File

@ -112,9 +112,11 @@ export const updateTemplateSettings = async ({
},
create: {
...meta,
emailSettings: meta?.emailSettings || undefined,
},
update: {
...meta,
emailSettings: meta?.emailSettings || undefined,
},
},
},

View File

@ -3,7 +3,7 @@ import { hash } from '@node-rs/bcrypt';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client';
import { IdentityProvider, TeamMemberInviteStatus } from '@documenso/prisma/client';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { SALT_ROUNDS } from '../../constants/auth';
@ -59,11 +59,11 @@ export const createUser = async ({ name, email, password, signature, url }: Crea
const acceptedTeamInvites = await prisma.teamMemberInvite.findMany({
where: {
status: TeamMemberInviteStatus.ACCEPTED,
email: {
equals: email,
mode: Prisma.QueryMode.insensitive,
mode: 'insensitive',
},
status: TeamMemberInviteStatus.ACCEPTED,
},
});

View File

@ -39,7 +39,7 @@ export const sendConfirmationToken = async ({
mostRecentToken?.createdAt &&
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
return;
// return;
}
const createdToken = await prisma.verificationToken.create({
@ -64,6 +64,7 @@ export const sendConfirmationToken = async ({
return { success: true };
} catch (err) {
console.log(err);
throw new Error(`Failed to send the confirmation email`);
}
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-10-08 12:05\n"
"PO-Revision-Date: 2024-11-20 11:56\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@ -160,10 +160,6 @@ msgstr "Dokumentation"
msgid "Easily embed Documenso into your product. Simply copy and paste our react widget into your application."
msgstr "Betten Sie Documenso ganz einfach in Ihr Produkt ein. Kopieren und fügen Sie einfach unser React-Widget in Ihre Anwendung ein."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:42
#~ msgid "Easy Sharing (Soon)."
#~ msgstr "Easy Sharing (Soon)."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:46
msgid "Easy Sharing."
msgstr "Einfaches Teilen."
@ -377,18 +373,10 @@ msgstr "Unsere benutzerdefinierten Vorlagen verfügen über intelligente Regeln,
msgid "Our Enterprise License is great for large organizations looking to switch to Documenso for all their signing needs. It's available for our cloud offering as well as self-hosted setups and offers a wide range of compliance and Adminstration Features."
msgstr "Unsere Enterprise-Lizenz ist ideal für große Organisationen, die auf Documenso für all ihre Signaturanforderungen umsteigen möchten. Sie ist sowohl für unser Cloud-Angebot als auch für selbstgehostete Setups verfügbar und bietet eine breite Palette an Compliance- und Verwaltungsfunktionen."
#: apps/marketing/src/components/(marketing)/enterprise.tsx:20
#~ msgid "Our Enterprise License is great large organizations looking to switch to Documenso for all their signing needs. It's availible for our cloud offering as well as self-hosted setups and offer a wide range of compliance and Adminstration Features."
#~ msgstr "Our Enterprise License is great large organizations looking to switch to Documenso for all their signing needs. It's availible for our cloud offering as well as self-hosted setups and offer a wide range of compliance and Adminstration Features."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:65
msgid "Our self-hosted option is great for small teams and individuals who need a simple solution. You can use our docker based setup to get started in minutes. Take control with full customizability and data ownership."
msgstr "Unsere selbstgehostete Option ist ideal für kleine Teams und Einzelpersonen, die eine einfache Lösung benötigen. Sie können unser docker-basiertes Setup verwenden, um in wenigen Minuten loszulegen. Übernehmen Sie die Kontrolle mit vollständiger Anpassbarkeit und Datenhoheit."
#: apps/marketing/src/app/(marketing)/open/data.ts:25
#~ msgid "Part-Time"
#~ msgstr "Part-Time"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:151
msgid "Premium Profile Name"
msgstr "Premium Profilname"
@ -430,10 +418,6 @@ msgstr "Gehalt"
msgid "Save $60 or $120"
msgstr "Sparen Sie $60 oder $120"
#: apps/marketing/src/components/(marketing)/i18n-switcher.tsx:47
#~ msgid "Search languages..."
#~ msgstr "Search languages..."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:109
msgid "Securely. Our data centers are located in Frankfurt (Germany), giving us the best local privacy laws. We are very aware of the sensitive nature of our data and follow best practices to ensure the security and integrity of the data entrusted to us."
msgstr "Sicher. Unsere Rechenzentren befinden sich in Frankfurt (Deutschland) und bieten uns die besten lokalen Datenschutzgesetze. Uns ist die sensible Natur unserer Daten sehr bewusst und wir folgen bewährten Praktiken, um die Sicherheit und Integrität der uns anvertrauten Daten zu gewährleisten."
@ -618,3 +602,4 @@ msgstr "Sie können Documenso kostenlos selbst hosten oder unsere sofort einsatz
#: apps/marketing/src/components/(marketing)/carousel.tsx:272
msgid "Your browser does not support the video tag."
msgstr "Ihr Browser unterstützt das Video-Tag nicht."

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -155,10 +155,6 @@ msgstr "Documentation"
msgid "Easily embed Documenso into your product. Simply copy and paste our react widget into your application."
msgstr "Easily embed Documenso into your product. Simply copy and paste our react widget into your application."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:42
#~ msgid "Easy Sharing (Soon)."
#~ msgstr "Easy Sharing (Soon)."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:46
msgid "Easy Sharing."
msgstr "Easy Sharing."
@ -372,18 +368,10 @@ msgstr "Our custom templates come with smart rules that can help you save time a
msgid "Our Enterprise License is great for large organizations looking to switch to Documenso for all their signing needs. It's available for our cloud offering as well as self-hosted setups and offers a wide range of compliance and Adminstration Features."
msgstr "Our Enterprise License is great for large organizations looking to switch to Documenso for all their signing needs. It's available for our cloud offering as well as self-hosted setups and offers a wide range of compliance and Adminstration Features."
#: apps/marketing/src/components/(marketing)/enterprise.tsx:20
#~ msgid "Our Enterprise License is great large organizations looking to switch to Documenso for all their signing needs. It's availible for our cloud offering as well as self-hosted setups and offer a wide range of compliance and Adminstration Features."
#~ msgstr "Our Enterprise License is great large organizations looking to switch to Documenso for all their signing needs. It's availible for our cloud offering as well as self-hosted setups and offer a wide range of compliance and Adminstration Features."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:65
msgid "Our self-hosted option is great for small teams and individuals who need a simple solution. You can use our docker based setup to get started in minutes. Take control with full customizability and data ownership."
msgstr "Our self-hosted option is great for small teams and individuals who need a simple solution. You can use our docker based setup to get started in minutes. Take control with full customizability and data ownership."
#: apps/marketing/src/app/(marketing)/open/data.ts:25
#~ msgid "Part-Time"
#~ msgstr "Part-Time"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:151
msgid "Premium Profile Name"
msgstr "Premium Profile Name"
@ -425,10 +413,6 @@ msgstr "Salary"
msgid "Save $60 or $120"
msgstr "Save $60 or $120"
#: apps/marketing/src/components/(marketing)/i18n-switcher.tsx:47
#~ msgid "Search languages..."
#~ msgstr "Search languages..."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:109
msgid "Securely. Our data centers are located in Frankfurt (Germany), giving us the best local privacy laws. We are very aware of the sensitive nature of our data and follow best practices to ensure the security and integrity of the data entrusted to us."
msgstr "Securely. Our data centers are located in Frankfurt (Germany), giving us the best local privacy laws. We are very aware of the sensitive nature of our data and follow best practices to ensure the security and integrity of the data entrusted to us."

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,605 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2024-07-24 13:01+1000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: es\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-11-20 11:56\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: documenso-app\n"
"X-Crowdin-Project-ID: 694691\n"
"X-Crowdin-Language: es-ES\n"
"X-Crowdin-File: marketing.po\n"
"X-Crowdin-File-ID: 6\n"
#: apps/marketing/src/app/(marketing)/blog/page.tsx:45
msgid "{0}"
msgstr "{0}"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:100
msgid "5 standard documents per month"
msgstr "5 documentos estándar por mes"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:198
msgid "5 Users Included"
msgstr "5 Usuarios incluidos"
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:34
msgid "A 10x better signing experience."
msgstr "Una experiencia de firma 10 veces mejor."
#: apps/marketing/src/app/(marketing)/singleplayer/client.tsx:51
msgid "Add document"
msgstr "Agregar documento"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:201
msgid "Add More Users for {0}"
msgstr "Agregar más usuarios por {0}"
#: apps/marketing/src/app/(marketing)/open/page.tsx:165
msgid "All our metrics, finances, and learnings are public. We believe in transparency and want to share our journey with you. You can read more about why here: <0>Announcing Open Metrics</0>"
msgstr "Todos nuestros métricas, finanzas y aprendizajes son públicos. Creemos en la transparencia y queremos compartir nuestro viaje contigo. Puedes leer más sobre por qué aquí: <0>Anunciando Métricas Abiertas</0>"
#: apps/marketing/src/app/(marketing)/open/funding-raised.tsx:58
#: apps/marketing/src/app/(marketing)/open/funding-raised.tsx:65
msgid "Amount Raised"
msgstr "Monto recaudado"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:145
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:189
msgid "API Access"
msgstr "Acceso a API"
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:67
msgid "Beautiful."
msgstr "Hermoso."
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:69
msgid "Because signing should be celebrated. Thats why we care about the smallest detail in our product."
msgstr "Porque la firma debe ser celebrada. Por eso nos importa el más mínimo detalle en nuestro producto."
#: apps/marketing/src/components/(marketing)/footer.tsx:35
#: apps/marketing/src/components/(marketing)/header.tsx:57
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:36
msgid "Blog"
msgstr "Blog"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:64
msgid "Build on top."
msgstr "Construir sobre."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:163
msgid "Can I use Documenso commercially?"
msgstr "¿Puedo usar Documenso comercialmente?"
#: apps/marketing/src/components/(marketing)/footer.tsx:42
msgid "Careers"
msgstr "Carreras"
#: apps/marketing/src/components/(marketing)/footer.tsx:36
msgid "Changelog"
msgstr "Registro de cambios"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:85
msgid "Choose a template from the community app store. Or submit your own template for others to use."
msgstr "Elige una plantilla de la tienda de aplicaciones de la comunidad. O envía tu propia plantilla para que otros la usen."
#: apps/marketing/src/app/(marketing)/open/page.tsx:219
msgid "Community"
msgstr "Comunidad"
#: apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx:55
msgid "Completed Documents"
msgstr "Documentos Completados"
#: apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx:33
msgid "Completed Documents per Month"
msgstr "Documentos Completados por Mes"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:65
msgid "Connections"
msgstr "Conexiones"
#: apps/marketing/src/components/(marketing)/enterprise.tsx:35
msgid "Contact Us"
msgstr "Contáctanos"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:67
msgid "Create connections and automations with Zapier and more to integrate with your favorite tools."
msgstr "Crea conexiones y automatizaciones con Zapier y más para integrarte con tus herramientas favoritas."
#: apps/marketing/src/components/(marketing)/call-to-action.tsx:23
msgid "Create your account and start using state-of-the-art document signing. Open and beautiful signing is within your grasp."
msgstr "Crea tu cuenta y comienza a usar la firma de documentos de última generación. La firma abierta y hermosa está a tu alcance."
#: apps/marketing/src/app/(marketing)/open/tooltip.tsx:35
msgid "Customers with an Active Subscriptions."
msgstr "Clientes con suscripciones activas."
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:33
msgid "Customise and expand."
msgstr "Personaliza y expande."
#: apps/marketing/src/components/(marketing)/footer.tsx:38
msgid "Design"
msgstr "Diseño"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:44
msgid "Designed for every stage of your journey."
msgstr "Diseñado para cada etapa de tu viaje."
#: apps/marketing/src/components/(marketing)/carousel.tsx:40
msgid "Direct Link"
msgstr "Enlace Directo"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:181
msgid "Documenso is a community effort to create an open and vibrant ecosystem around a tool, everybody is free to use and adapt. By being truly open we want to create trusted infrastructure for the future of the internet."
msgstr "Documenso es un esfuerzo comunitario para crear un ecosistema abierto y vibrante alrededor de una herramienta que todos son libres de usar y adaptar. Al ser verdaderamente abierto, queremos crear una infraestructura confiable para el futuro de internet."
#: apps/marketing/src/app/(marketing)/open/typefully.tsx:28
msgid "Documenso on X"
msgstr "Documenso en X"
#: apps/marketing/src/components/(marketing)/hero.tsx:104
msgid "Document signing,<0/>finally open source."
msgstr "Firma de documentos,<0/>finalmente código abierto."
#: apps/marketing/src/components/(marketing)/footer.tsx:33
#: apps/marketing/src/components/(marketing)/header.tsx:50
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:28
msgid "Documentation"
msgstr "Documentación"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:110
msgid "Easily embed Documenso into your product. Simply copy and paste our react widget into your application."
msgstr "Incrusta fácilmente Documenso en tu producto. Simplemente copia y pega nuestro widget de react en tu aplicación."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:46
msgid "Easy Sharing."
msgstr "Compartición fácil."
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:148
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:192
msgid "Email and Discord Support"
msgstr "Soporte por correo electrónico y Discord"
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:43
msgid "Engagement"
msgstr "Compromiso"
#: apps/marketing/src/app/(marketing)/singleplayer/client.tsx:64
msgid "Enter your details."
msgstr "Ingresa tus detalles."
#: apps/marketing/src/components/(marketing)/enterprise.tsx:16
msgid "Enterprise Compliance, License or Technical Needs?"
msgstr "¿Cumplimiento, licencia o necesidades técnicas de la empresa?"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:128
msgid "Everything you need for a great signing experience."
msgstr "Todo lo que necesitas para una gran experiencia de firma."
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:45
msgid "Fast."
msgstr "Rápido."
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:36
msgid "Faster, smarter and more beautiful."
msgstr "Más rápido, más inteligente y más hermoso."
#: apps/marketing/src/app/(marketing)/open/page.tsx:210
msgid "Finances"
msgstr "Finanzas"
#: apps/marketing/src/app/(marketing)/open/typefully.tsx:38
msgid "Follow us on X"
msgstr "Síguenos en X"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:172
msgid "For companies looking to scale across multiple teams."
msgstr "Para empresas que buscan escalar en múltiples equipos."
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:85
msgid "For small teams and individuals with basic needs."
msgstr "Para pequeños equipos e individuos con necesidades básicas."
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:80
msgid "Free"
msgstr "Gratis"
#: apps/marketing/src/app/(marketing)/blog/page.tsx:26
msgid "From the blog"
msgstr "Del blog"
#: apps/marketing/src/app/(marketing)/open/data.ts:9
#: apps/marketing/src/app/(marketing)/open/data.ts:17
#: apps/marketing/src/app/(marketing)/open/data.ts:25
#: apps/marketing/src/app/(marketing)/open/data.ts:33
#: apps/marketing/src/app/(marketing)/open/data.ts:41
#: apps/marketing/src/app/(marketing)/open/data.ts:49
msgid "Full-Time"
msgstr "A tiempo completo"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:87
msgid "Get paid (Soon)."
msgstr "Recibe pago (Pronto)."
#: apps/marketing/src/components/(marketing)/call-to-action.tsx:31
msgid "Get started"
msgstr "Empezar"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:75
msgid "Get Started"
msgstr "Comienza"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:47
msgid "Get started today."
msgstr "Comienza hoy."
#: apps/marketing/src/app/(marketing)/blog/page.tsx:30
msgid "Get the latest news from Documenso, including product updates, team announcements and more!"
msgstr "¡Obtén las últimas noticias de Documenso, incluidas actualizaciones de productos, anuncios del equipo y más!"
#: apps/marketing/src/app/(marketing)/open/page.tsx:233
msgid "GitHub: Total Merged PRs"
msgstr "GitHub: Total de PRs fusionados"
#: apps/marketing/src/app/(marketing)/open/page.tsx:251
msgid "GitHub: Total Open Issues"
msgstr "GitHub: Total de problemas abiertos"
#: apps/marketing/src/app/(marketing)/open/page.tsx:225
msgid "GitHub: Total Stars"
msgstr "GitHub: Total de estrellas"
#: apps/marketing/src/app/(marketing)/open/salary-bands.tsx:23
msgid "Global Salary Bands"
msgstr "Bandas salariales globales"
#: apps/marketing/src/app/(marketing)/open/page.tsx:261
msgid "Growth"
msgstr "Crecimiento"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:134
msgid "How can I contribute?"
msgstr "¿Cómo puedo contribuir?"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:105
msgid "How do you handle my data?"
msgstr "¿Cómo manejan mis datos?"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:118
msgid "Individual"
msgstr "Individual"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:89
msgid "Integrated payments with Stripe so you dont have to worry about getting paid."
msgstr "Pagos integrados con Stripe para que no tengas que preocuparte por cobrar."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:35
msgid "Integrates with all your favourite tools."
msgstr "Se integra con todas tus herramientas favoritas."
#: apps/marketing/src/app/(marketing)/open/page.tsx:289
msgid "Is there more?"
msgstr "¿Hay más?"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:44
msgid "Its up to you. Either clone our repository or rely on our easy to use hosting solution."
msgstr "Depende de ti. O bien clona nuestro repositorio o confía en nuestra solución de hosting fácil de usar."
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:49
msgid "Join Date"
msgstr "Fecha de ingreso"
#: apps/marketing/src/components/(marketing)/call-to-action.tsx:19
msgid "Join the Open Signing Movement"
msgstr "Únete al Movimiento de Firma Abierta"
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:46
msgid "Location"
msgstr "Ubicación"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:66
msgid "Make it your own through advanced customization and adjustability."
msgstr "Hazlo tuyo a través de personalización y ajustabilidad avanzadas."
#: apps/marketing/src/app/(marketing)/open/page.tsx:199
msgid "Merged PR's"
msgstr "PRs fusionados"
#: apps/marketing/src/app/(marketing)/open/page.tsx:234
msgid "Merged PRs"
msgstr "PRs fusionados"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:40
msgid "Monthly"
msgstr "Mensual"
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:34
msgid "Name"
msgstr "Nombre"
#: apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx:30
#: apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx:43
#: apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx:52
msgid "New Users"
msgstr "Nuevos Usuarios"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:106
msgid "No credit card required"
msgstr "No se requiere tarjeta de crédito"
#: apps/marketing/src/components/(marketing)/callout.tsx:29
#: apps/marketing/src/components/(marketing)/hero.tsx:125
msgid "No Credit Card required"
msgstr "No se requiere tarjeta de crédito"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:61
msgid "None of these work for you? Try self-hosting!"
msgstr "¿Ninguna de estas opciones funciona para ti? ¡Prueba el autoalojamiento!"
#: apps/marketing/src/app/(marketing)/open/page.tsx:194
#: apps/marketing/src/app/(marketing)/open/page.tsx:252
msgid "Open Issues"
msgstr "Problemas Abiertos"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:42
msgid "Open Source or Hosted."
msgstr "Código Abierto o Alojado."
#: apps/marketing/src/app/(marketing)/open/page.tsx:161
#: apps/marketing/src/components/(marketing)/footer.tsx:37
#: apps/marketing/src/components/(marketing)/header.tsx:64
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:40
msgid "Open Startup"
msgstr "Startup Abierta"
#: apps/marketing/src/components/(marketing)/footer.tsx:41
msgid "OSS Friends"
msgstr "Amigos OSS"
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:91
msgid "Our custom templates come with smart rules that can help you save time and energy."
msgstr "Nuestras plantillas personalizadas vienen con reglas inteligentes que pueden ayudarte a ahorrar tiempo y energía."
#: apps/marketing/src/components/(marketing)/enterprise.tsx:20
msgid "Our Enterprise License is great for large organizations looking to switch to Documenso for all their signing needs. It's available for our cloud offering as well as self-hosted setups and offers a wide range of compliance and Adminstration Features."
msgstr "Nuestra Licencia Empresarial es excelente para grandes organizaciones que buscan cambiar a Documenso para todas sus necesidades de firma. Está disponible para nuestra oferta en la nube, así como para configuraciones autoalojadas y ofrece una amplia gama de funciones de cumplimiento y administración."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:65
msgid "Our self-hosted option is great for small teams and individuals who need a simple solution. You can use our docker based setup to get started in minutes. Take control with full customizability and data ownership."
msgstr "Nuestra opción de autoalojamiento es excelente para pequeños equipos e individuos que necesitan una solución simple. Puedes usar nuestra configuración basada en docker para empezar en minutos. Toma el control con total personalización y propiedad de los datos."
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:151
msgid "Premium Profile Name"
msgstr "Nombre de Perfil Premium"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:40
#: apps/marketing/src/components/(marketing)/footer.tsx:31
#: apps/marketing/src/components/(marketing)/header.tsx:42
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:24
msgid "Pricing"
msgstr "Precios"
#: apps/marketing/src/components/(marketing)/footer.tsx:43
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:53
msgid "Privacy"
msgstr "Privacidad"
#: apps/marketing/src/components/(marketing)/carousel.tsx:58
msgid "Profile"
msgstr "Perfil"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:108
msgid "React Widget (Soon)."
msgstr "Widget de React (Pronto)."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:48
msgid "Receive your personal link to share with everyone you care about."
msgstr "Recibe tu enlace personal para compartirlo con todos los que te importan."
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:37
msgid "Role"
msgstr "Rol"
#: apps/marketing/src/app/(marketing)/open/salary-bands.tsx:37
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:40
msgid "Salary"
msgstr "Salario"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:62
msgid "Save $60 or $120"
msgstr "Ahorra $60 o $120"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:109
msgid "Securely. Our data centers are located in Frankfurt (Germany), giving us the best local privacy laws. We are very aware of the sensitive nature of our data and follow best practices to ensure the security and integrity of the data entrusted to us."
msgstr "De manera segura. Nuestros centros de datos están ubicados en Frankfurt (Alemania), dándonos las mejores leyes de privacidad locales. Somos muy conscientes de la naturaleza sensible de nuestros datos y seguimos las mejores prácticas para garantizar la seguridad y la integridad de los datos que se nos confían."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:37
msgid "Send, connect, receive and embed everywhere."
msgstr "Enviar, conectar, recibir e incrustar en todas partes."
#: apps/marketing/src/app/(marketing)/open/salary-bands.tsx:34
msgid "Seniority"
msgstr "Antigüedad"
#: apps/marketing/src/components/(marketing)/footer.tsx:39
msgid "Shop"
msgstr "Tienda"
#: apps/marketing/src/app/(marketing)/singleplayer/client.tsx:63
msgid "Sign"
msgstr "Firmar"
#: apps/marketing/src/components/(marketing)/header.tsx:72
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:61
msgid "Sign in"
msgstr "Iniciar sesión"
#: apps/marketing/src/components/(marketing)/header.tsx:77
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:57
msgid "Sign up"
msgstr "Registrarse"
#: apps/marketing/src/components/(marketing)/carousel.tsx:22
msgid "Signing Process"
msgstr "Proceso de firma"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:94
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:136
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:180
msgid "Signup Now"
msgstr "Regístrate ahora"
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:89
msgid "Smart."
msgstr "Inteligente."
#: apps/marketing/src/components/(marketing)/hero.tsx:132
msgid "Star on GitHub"
msgstr "Estrella en GitHub"
#: apps/marketing/src/app/(marketing)/open/page.tsx:226
msgid "Stars"
msgstr "Estrellas"
#: apps/marketing/src/components/(marketing)/footer.tsx:40
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:44
msgid "Status"
msgstr "Estado"
#: apps/marketing/src/components/(marketing)/footer.tsx:34
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:48
msgid "Support"
msgstr "Soporte"
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:26
msgid "Team"
msgstr "Equipo"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:195
msgid "Team Inbox"
msgstr "Bandeja de entrada del equipo"
#: apps/marketing/src/components/(marketing)/carousel.tsx:28
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:162
msgid "Teams"
msgstr "Equipos"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:83
msgid "Template Store (Soon)."
msgstr "Tienda de Plantillas (Pronto)."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:138
msgid "That's awesome. You can take a look at the current <0>Issues</0> and join our <1>Discord Community</1> to keep up to date, on what the current priorities are. In any case, we are an open community and welcome all input, technical and non-technical ❤️"
msgstr "Eso es increíble. Puedes echar un vistazo a los <0>Problemas</0> actuales y unirte a nuestra <1>Comunidad de Discord</1> para mantenerte al día sobre cuáles son las prioridades actuales. En cualquier caso, somos una comunidad abierta y agradecemos todas las aportaciones, técnicas y no técnicas ❤️"
#: apps/marketing/src/app/(marketing)/open/page.tsx:293
msgid "This page is evolving as we learn what makes a great signing company. We'll update it when we have more to share."
msgstr "Esta página está evolucionando a medida que aprendemos lo que hace una gran empresa de firmas. La actualizaremos cuando tengamos más para compartir."
#: apps/marketing/src/app/(marketing)/open/salary-bands.tsx:31
msgid "Title"
msgstr "Título"
#: apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx:30
#: apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx:55
msgid "Total Completed Documents"
msgstr "Total de Documentos Completados"
#: apps/marketing/src/app/(marketing)/open/page.tsx:267
#: apps/marketing/src/app/(marketing)/open/page.tsx:268
msgid "Total Customers"
msgstr "Total de Clientes"
#: apps/marketing/src/app/(marketing)/open/funding-raised.tsx:29
msgid "Total Funding Raised"
msgstr "Total de Fondos Recaudados"
#: apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx:30
#: apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx:43
#: apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx:52
msgid "Total Users"
msgstr "Total de Usuarios"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:31
msgid "Truly your own."
msgstr "Realmente tuyo."
#: apps/marketing/src/components/(marketing)/callout.tsx:27
#: apps/marketing/src/components/(marketing)/hero.tsx:123
msgid "Try our Free Plan"
msgstr "Prueba nuestro Plan Gratuito"
#: apps/marketing/src/app/(marketing)/open/typefully.tsx:20
msgid "Twitter Stats"
msgstr "Estadísticas de Twitter"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:142
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:186
msgid "Unlimited Documents per Month"
msgstr "Documentos ilimitados por mes"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:103
msgid "Up to 10 recipients per document"
msgstr "Hasta 10 destinatarios por documento"
#: apps/marketing/src/app/(marketing)/singleplayer/client.tsx:52
msgid "Upload a document and add fields."
msgstr "Sube un documento y agrega campos."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:123
msgid "Using our hosted version is the easiest way to get started, you can simply subscribe and start signing your documents. We take care of the infrastructure, so you can focus on your business. Additionally, when using our hosted version you benefit from our trusted signing certificates which helps you to build trust with your customers."
msgstr "Usar nuestra versión alojada es la forma más fácil de comenzar, simplemente puedes suscribirte y comenzar a firmar tus documentos. Nos ocupamos de la infraestructura, para que puedas concentrarte en tu negocio. Además, al utilizar nuestra versión alojada, te beneficias de nuestros certificados de firma confiables, lo que te ayuda a generar confianza con tus clientes."
#: apps/marketing/src/app/(marketing)/open/typefully.tsx:33
msgid "View all stats"
msgstr "Ver todas las estadísticas"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:195
msgid "We are happy to assist you at <0>support@documenso.com</0> or <1>in our Discord-Support-Channel</1> please message either Lucas or Timur to get added to the channel if you are not already a member."
msgstr "Estamos felices de ayudarte en <0>support@documenso.com</0> o <1>en nuestro canal de soporte de Discord</1>. Mensaje a Lucas o Timur para que te agreguen al canal si aún no eres miembro."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:89
msgid "What is the difference between the plans?"
msgstr "¿Cuál es la diferencia entre los planes?"
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:47
msgid "When it comes to sending or receiving a contract, you can count on lightning-fast speeds."
msgstr "Cuando se trata de enviar o recibir un contrato, puedes contar con velocidades ultrarrápidas."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:191
msgid "Where can I get support?"
msgstr "¿Dónde puedo obtener soporte?"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:177
msgid "Why should I prefer Documenso over DocuSign or some other signing tool?"
msgstr "¿Por qué debería preferir Documenso sobre DocuSign u otra herramienta de firma?"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:119
msgid "Why should I use your hosting service?"
msgstr "¿Por qué debería usar su servicio de alojamiento?"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:60
msgid "Yearly"
msgstr "Anual"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:167
msgid "Yes! Documenso is offered under the GNU AGPL V3 open source license. This means you can use it for free and even modify it to fit your needs, as long as you publish your changes under the same license."
msgstr "¡Sí! Documenso se ofrece bajo la licencia de código abierto GNU AGPL V3. Esto significa que puedes usarlo de forma gratuita e incluso modificarlo para adaptarlo a tus necesidades, siempre que publiques tus cambios bajo la misma licencia."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:93
msgid "You can self-host Documenso for free or use our ready-to-use hosted version. The hosted version comes with additional support, painless scalability and more. Early adopters will get access to all features we build this year, for no additional cost! Forever! Yes, that includes multiple users per account later. If you want Documenso for your enterprise, we are happy to talk about your needs."
msgstr "Puedes autoalojar Documenso de forma gratuita o usar nuestra versión alojada lista para usar. La versión alojada viene con soporte adicional, escalabilidad sin problemas y más. Los primeros adoptantes tendrán acceso a todas las funciones que construimos este año, sin costo adicional. ¡Para siempre! Sí, eso incluye múltiples usuarios por cuenta más adelante. Si quieres Documenso para tu empresa, estaremos encantados de hablar sobre tus necesidades."
#: apps/marketing/src/components/(marketing)/carousel.tsx:272
msgid "Your browser does not support the video tag."
msgstr "Tu navegador no soporta la etiqueta de video."

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-10-08 12:05\n"
"PO-Revision-Date: 2024-11-20 11:56\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
@ -160,10 +160,6 @@ msgstr "Documentation"
msgid "Easily embed Documenso into your product. Simply copy and paste our react widget into your application."
msgstr "Intégrez facilement Documenso dans votre produit. Il vous suffit de copier et coller notre widget React dans votre application."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:42
#~ msgid "Easy Sharing (Soon)."
#~ msgstr "Easy Sharing (Soon)."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:46
msgid "Easy Sharing."
msgstr "Partage facile."
@ -377,18 +373,10 @@ msgstr "Nos modèles personnalisés sont dotés de règles intelligentes qui peu
msgid "Our Enterprise License is great for large organizations looking to switch to Documenso for all their signing needs. It's available for our cloud offering as well as self-hosted setups and offers a wide range of compliance and Adminstration Features."
msgstr "Notre licence entreprise est idéale pour les grandes organisations cherchant à passer à Documenso pour tous leurs besoins de signature. Elle est disponible pour notre offre cloud ainsi que pour des configurations auto-hébergées et propose un large éventail de fonctionnalités de conformité et d'administration."
#: apps/marketing/src/components/(marketing)/enterprise.tsx:20
#~ msgid "Our Enterprise License is great large organizations looking to switch to Documenso for all their signing needs. It's availible for our cloud offering as well as self-hosted setups and offer a wide range of compliance and Adminstration Features."
#~ msgstr "Our Enterprise License is great large organizations looking to switch to Documenso for all their signing needs. It's availible for our cloud offering as well as self-hosted setups and offer a wide range of compliance and Adminstration Features."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:65
msgid "Our self-hosted option is great for small teams and individuals who need a simple solution. You can use our docker based setup to get started in minutes. Take control with full customizability and data ownership."
msgstr "Notre option auto-hébergée est idéale pour les petites équipes et les individus qui ont besoin d'une solution simple. Vous pouvez utiliser notre configuration basée sur Docker pour commencer en quelques minutes. Prenez le contrôle avec une personnalisation complète et une propriété des données."
#: apps/marketing/src/app/(marketing)/open/data.ts:25
#~ msgid "Part-Time"
#~ msgstr "Part-Time"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:151
msgid "Premium Profile Name"
msgstr "Nom de profil premium"
@ -430,10 +418,6 @@ msgstr "Salaire"
msgid "Save $60 or $120"
msgstr "Économisez 60 $ ou 120 $"
#: apps/marketing/src/components/(marketing)/i18n-switcher.tsx:47
#~ msgid "Search languages..."
#~ msgstr "Search languages..."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:109
msgid "Securely. Our data centers are located in Frankfurt (Germany), giving us the best local privacy laws. We are very aware of the sensitive nature of our data and follow best practices to ensure the security and integrity of the data entrusted to us."
msgstr "De manière sécurisée. Nos centres de données sont situés à Francfort (Allemagne), ce qui nous permet de bénéficier des meilleures lois locales sur la confidentialité. Nous sommes très conscients de la nature sensible de nos données et suivons les meilleures pratiques pour garantir la sécurité et l'intégrité des données qui nous sont confiées."
@ -618,3 +602,4 @@ msgstr "Vous pouvez auto-héberger Documenso gratuitement ou utiliser notre vers
#: apps/marketing/src/components/(marketing)/carousel.tsx:272
msgid "Your browser does not support the video tag."
msgstr "Votre navigateur ne prend pas en charge la balise vidéo."

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,605 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2024-07-24 13:01+1000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: pl\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-11-20 11:56\n"
"Last-Translator: \n"
"Language-Team: Polish\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
"X-Crowdin-Project: documenso-app\n"
"X-Crowdin-Project-ID: 694691\n"
"X-Crowdin-Language: pl\n"
"X-Crowdin-File: marketing.po\n"
"X-Crowdin-File-ID: 6\n"
#: apps/marketing/src/app/(marketing)/blog/page.tsx:45
msgid "{0}"
msgstr "{0}"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:100
msgid "5 standard documents per month"
msgstr "5 standard documents per month"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:198
msgid "5 Users Included"
msgstr "5 użytkowników w cenie"
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:34
msgid "A 10x better signing experience."
msgstr "10 razy lepsze doświadczenie podpisywania."
#: apps/marketing/src/app/(marketing)/singleplayer/client.tsx:51
msgid "Add document"
msgstr "Dodaj dokument"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:201
msgid "Add More Users for {0}"
msgstr "Dodaj więcej użytkowników za {0}"
#: apps/marketing/src/app/(marketing)/open/page.tsx:165
msgid "All our metrics, finances, and learnings are public. We believe in transparency and want to share our journey with you. You can read more about why here: <0>Announcing Open Metrics</0>"
msgstr "Wszystkie nasze metryki, finanse i nauki są publiczne. Wierzymy w przejrzystość i chcemy dzielić się naszą podróżą z Tobą. Możesz przeczytać więcej o tym, dlaczego tutaj: <0>Ogłaszamy otwarte metryki</0>"
#: apps/marketing/src/app/(marketing)/open/funding-raised.tsx:58
#: apps/marketing/src/app/(marketing)/open/funding-raised.tsx:65
msgid "Amount Raised"
msgstr "Zebrana kwota"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:145
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:189
msgid "API Access"
msgstr "Dostęp do API"
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:67
msgid "Beautiful."
msgstr "Piękny."
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:69
msgid "Because signing should be celebrated. Thats why we care about the smallest detail in our product."
msgstr "Ponieważ podpisywanie powinno być celebrowane. Dlatego dbamy o najmniejszy detal w naszym produkcie."
#: apps/marketing/src/components/(marketing)/footer.tsx:35
#: apps/marketing/src/components/(marketing)/header.tsx:57
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:36
msgid "Blog"
msgstr "Blog"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:64
msgid "Build on top."
msgstr "Buduj na szczycie."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:163
msgid "Can I use Documenso commercially?"
msgstr "Czy mogę używać Documenso komercyjnie?"
#: apps/marketing/src/components/(marketing)/footer.tsx:42
msgid "Careers"
msgstr "Kariera"
#: apps/marketing/src/components/(marketing)/footer.tsx:36
msgid "Changelog"
msgstr "Dziennik zmian"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:85
msgid "Choose a template from the community app store. Or submit your own template for others to use."
msgstr "Wybierz szablon z sklepu aplikacji społeczności. Lub przekaż swój własny szablon, aby inni mogli z niego korzystać."
#: apps/marketing/src/app/(marketing)/open/page.tsx:219
msgid "Community"
msgstr "Społeczność"
#: apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx:55
msgid "Completed Documents"
msgstr "Zakończone dokumenty"
#: apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx:33
msgid "Completed Documents per Month"
msgstr "Zakończone dokumenty na miesiąc"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:65
msgid "Connections"
msgstr "Połączenia"
#: apps/marketing/src/components/(marketing)/enterprise.tsx:35
msgid "Contact Us"
msgstr "Skontaktuj się z nami"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:67
msgid "Create connections and automations with Zapier and more to integrate with your favorite tools."
msgstr "Twórz połączenia i automatyzacje z Zapier i innymi, aby zintegrować z ulubionymi narzędziami."
#: apps/marketing/src/components/(marketing)/call-to-action.tsx:23
msgid "Create your account and start using state-of-the-art document signing. Open and beautiful signing is within your grasp."
msgstr "Utwórz konto i rozpocznij korzystanie z nowoczesnego podpisywania dokumentów. Otwarty i piękny podpis jest w Twoim zasięgu."
#: apps/marketing/src/app/(marketing)/open/tooltip.tsx:35
msgid "Customers with an Active Subscriptions."
msgstr "Klienci z aktywnymi subskrypcjami."
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:33
msgid "Customise and expand."
msgstr "Dostosuj i rozwijaj."
#: apps/marketing/src/components/(marketing)/footer.tsx:38
msgid "Design"
msgstr "Projekt"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:44
msgid "Designed for every stage of your journey."
msgstr "Zaprojektowane na każdy etap Twojej podróży."
#: apps/marketing/src/components/(marketing)/carousel.tsx:40
msgid "Direct Link"
msgstr "Bezpośredni link"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:181
msgid "Documenso is a community effort to create an open and vibrant ecosystem around a tool, everybody is free to use and adapt. By being truly open we want to create trusted infrastructure for the future of the internet."
msgstr "Documenso to społeczny wysiłek na rzecz stworzenia otwartego i żywotnego ekosystemu wokół narzędzia, z którego każdy może korzystać i dostosowywać. Bycie naprawdę otwartym oznacza tworzenie zaufanej infrastruktury dla przyszłości internetu."
#: apps/marketing/src/app/(marketing)/open/typefully.tsx:28
msgid "Documenso on X"
msgstr "Documenso na X"
#: apps/marketing/src/components/(marketing)/hero.tsx:104
msgid "Document signing,<0/>finally open source."
msgstr "Podpisywanie dokumentów,<0/>w końcu open source."
#: apps/marketing/src/components/(marketing)/footer.tsx:33
#: apps/marketing/src/components/(marketing)/header.tsx:50
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:28
msgid "Documentation"
msgstr "Dokumentacja"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:110
msgid "Easily embed Documenso into your product. Simply copy and paste our react widget into your application."
msgstr "Łatwe osadzenie Documenso w Twoim produkcie. Po prostu skopiuj i wklej nasz widget react do swojej aplikacji."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:46
msgid "Easy Sharing."
msgstr "Łatwe udostępnianie."
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:148
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:192
msgid "Email and Discord Support"
msgstr "Wsparcie E-mail i Discord"
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:43
msgid "Engagement"
msgstr "Zaangażowanie"
#: apps/marketing/src/app/(marketing)/singleplayer/client.tsx:64
msgid "Enter your details."
msgstr "Wprowadź swoje dane."
#: apps/marketing/src/components/(marketing)/enterprise.tsx:16
msgid "Enterprise Compliance, License or Technical Needs?"
msgstr "Zgodność z przepisami dotyczącymi przedsiębiorstw, licencje lub potrzeby techniczne?"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:128
msgid "Everything you need for a great signing experience."
msgstr "Wszystko, czego potrzebujesz, aby mieć wspaniałe doświadczenie podpisywania."
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:45
msgid "Fast."
msgstr "Szybkie."
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:36
msgid "Faster, smarter and more beautiful."
msgstr "Szybszy, mądrzejszy i piękniejszy."
#: apps/marketing/src/app/(marketing)/open/page.tsx:210
msgid "Finances"
msgstr "Finanse"
#: apps/marketing/src/app/(marketing)/open/typefully.tsx:38
msgid "Follow us on X"
msgstr "Śledź nas na X"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:172
msgid "For companies looking to scale across multiple teams."
msgstr "Dla firm, które chcą się rozwijać w wielu zespołach."
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:85
msgid "For small teams and individuals with basic needs."
msgstr "Dla małych zespołów i osób z podstawowymi potrzebami."
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:80
msgid "Free"
msgstr "Darmowy"
#: apps/marketing/src/app/(marketing)/blog/page.tsx:26
msgid "From the blog"
msgstr "Z bloga"
#: apps/marketing/src/app/(marketing)/open/data.ts:9
#: apps/marketing/src/app/(marketing)/open/data.ts:17
#: apps/marketing/src/app/(marketing)/open/data.ts:25
#: apps/marketing/src/app/(marketing)/open/data.ts:33
#: apps/marketing/src/app/(marketing)/open/data.ts:41
#: apps/marketing/src/app/(marketing)/open/data.ts:49
msgid "Full-Time"
msgstr "Pełnoetatowy"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:87
msgid "Get paid (Soon)."
msgstr "Zapłać (Wkrótce)."
#: apps/marketing/src/components/(marketing)/call-to-action.tsx:31
msgid "Get started"
msgstr "Rozpocznij"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:75
msgid "Get Started"
msgstr "Rozpocznij"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:47
msgid "Get started today."
msgstr "Rozpocznij dzisiaj."
#: apps/marketing/src/app/(marketing)/blog/page.tsx:30
msgid "Get the latest news from Documenso, including product updates, team announcements and more!"
msgstr "Otrzymaj najnowsze wiadomości od Documenso, w tym aktualizacje produktów, ogłoszenia zespołowe i inne!"
#: apps/marketing/src/app/(marketing)/open/page.tsx:233
msgid "GitHub: Total Merged PRs"
msgstr "GitHub: Całkowita liczba scalonych PR-ów"
#: apps/marketing/src/app/(marketing)/open/page.tsx:251
msgid "GitHub: Total Open Issues"
msgstr "GitHub: Całkowita liczba otwartych problemów"
#: apps/marketing/src/app/(marketing)/open/page.tsx:225
msgid "GitHub: Total Stars"
msgstr "GitHub: Całkowita liczba gwiazdek"
#: apps/marketing/src/app/(marketing)/open/salary-bands.tsx:23
msgid "Global Salary Bands"
msgstr "Globalne stawki płacy"
#: apps/marketing/src/app/(marketing)/open/page.tsx:261
msgid "Growth"
msgstr "Wzrost"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:134
msgid "How can I contribute?"
msgstr "Jak mogę się przyczynić?"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:105
msgid "How do you handle my data?"
msgstr "Jak radzisz sobie z moimi danymi?"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:118
msgid "Individual"
msgstr "Indywidualny"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:89
msgid "Integrated payments with Stripe so you dont have to worry about getting paid."
msgstr "Zintegrowane płatności z Stripe, więc nie musisz się martwić o to, aby otrzymać zapłatę."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:35
msgid "Integrates with all your favourite tools."
msgstr "Integruje się ze wszystkimi Twoimi ulubionymi narzędziami."
#: apps/marketing/src/app/(marketing)/open/page.tsx:289
msgid "Is there more?"
msgstr "Czy jest więcej?"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:44
msgid "Its up to you. Either clone our repository or rely on our easy to use hosting solution."
msgstr "To zależy od Ciebie. Albo sklonuj nasze repozytorium, albo polegaj na naszym łatwym w użyciu rozwiązaniu hostingowym."
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:49
msgid "Join Date"
msgstr "Data przystąpienia"
#: apps/marketing/src/components/(marketing)/call-to-action.tsx:19
msgid "Join the Open Signing Movement"
msgstr "Dołącz do ruchu otwartego podpisywania"
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:46
msgid "Location"
msgstr "Lokalizacja"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:66
msgid "Make it your own through advanced customization and adjustability."
msgstr "Spraw, aby było to Twoje dzięki zaawansowanej personalizacji i elastyczności."
#: apps/marketing/src/app/(marketing)/open/page.tsx:199
msgid "Merged PR's"
msgstr "Scalone PR-y"
#: apps/marketing/src/app/(marketing)/open/page.tsx:234
msgid "Merged PRs"
msgstr "Scalone PR"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:40
msgid "Monthly"
msgstr "Miesięcznie"
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:34
msgid "Name"
msgstr "Nazwa"
#: apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx:30
#: apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx:43
#: apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx:52
msgid "New Users"
msgstr "Nowi użytkownicy"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:106
msgid "No credit card required"
msgstr "Nie jest wymagana karta kredytowa"
#: apps/marketing/src/components/(marketing)/callout.tsx:29
#: apps/marketing/src/components/(marketing)/hero.tsx:125
msgid "No Credit Card required"
msgstr "Nie jest wymagana karta kredytowa"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:61
msgid "None of these work for you? Try self-hosting!"
msgstr "Żaden z tych wariantów nie działa dla Ciebie? Spróbuj samodzielnego hostingu!"
#: apps/marketing/src/app/(marketing)/open/page.tsx:194
#: apps/marketing/src/app/(marketing)/open/page.tsx:252
msgid "Open Issues"
msgstr "Otwarte problemy"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:42
msgid "Open Source or Hosted."
msgstr "Oprogramowanie otwarte lub hostowane."
#: apps/marketing/src/app/(marketing)/open/page.tsx:161
#: apps/marketing/src/components/(marketing)/footer.tsx:37
#: apps/marketing/src/components/(marketing)/header.tsx:64
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:40
msgid "Open Startup"
msgstr "Otwarte startupy"
#: apps/marketing/src/components/(marketing)/footer.tsx:41
msgid "OSS Friends"
msgstr "Przyjaciele OSS"
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:91
msgid "Our custom templates come with smart rules that can help you save time and energy."
msgstr "Nasze niestandardowe szablony mają inteligentne zasady, które mogą pomóc Ci zaoszczędzić czas i energię."
#: apps/marketing/src/components/(marketing)/enterprise.tsx:20
msgid "Our Enterprise License is great for large organizations looking to switch to Documenso for all their signing needs. It's available for our cloud offering as well as self-hosted setups and offers a wide range of compliance and Adminstration Features."
msgstr "Nasza licencja dla przedsiębiorstw jest doskonała dla dużych organizacji szukających zmiany na Documenso dla wszystkich swoich potrzeb związanych z podpisywaniem. Jest dostępna zarówno w naszej chmurze, jak i w ustawieniach do samodzielnego hostingu i oferuje szeroki zakres funkcji zgodności i administracyjnych."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:65
msgid "Our self-hosted option is great for small teams and individuals who need a simple solution. You can use our docker based setup to get started in minutes. Take control with full customizability and data ownership."
msgstr "Nasza opcja hostingu własnego jest świetna dla małych zespołów i osób, które potrzebują prostego rozwiązania. Możesz użyć naszego zestawu opartego na dockerze, aby rozpocząć w ciągu kilku minut. Przejmij kontrolę z pełną możliwością dostosowania i własnością danych."
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:151
msgid "Premium Profile Name"
msgstr "Nazwa profilu premium"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:40
#: apps/marketing/src/components/(marketing)/footer.tsx:31
#: apps/marketing/src/components/(marketing)/header.tsx:42
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:24
msgid "Pricing"
msgstr "Cennik"
#: apps/marketing/src/components/(marketing)/footer.tsx:43
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:53
msgid "Privacy"
msgstr "Prywatność"
#: apps/marketing/src/components/(marketing)/carousel.tsx:58
msgid "Profile"
msgstr "Profil"
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:108
msgid "React Widget (Soon)."
msgstr "Widget React (Wkrótce)."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:48
msgid "Receive your personal link to share with everyone you care about."
msgstr "Otrzymaj link do osobistego udostępnienia wszystkim, na których Ci zależy."
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:37
msgid "Role"
msgstr "Rola"
#: apps/marketing/src/app/(marketing)/open/salary-bands.tsx:37
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:40
msgid "Salary"
msgstr "Wynagrodzenie"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:62
msgid "Save $60 or $120"
msgstr "Zaoszczędź 60 $ lub 120 $"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:109
msgid "Securely. Our data centers are located in Frankfurt (Germany), giving us the best local privacy laws. We are very aware of the sensitive nature of our data and follow best practices to ensure the security and integrity of the data entrusted to us."
msgstr "Bezpiecznie. Nasze centra danych znajdują się we Frankfurcie (Niemcy), co daje nam najlepsze lokalne przepisy o prywatności. Jesteśmy bardzo świadomi wrażliwego charakteru naszych danych i przestrzegamy najlepszych praktyk, aby zapewnić bezpieczeństwo i integralność danych, które nam powierzono."
#: apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx:37
msgid "Send, connect, receive and embed everywhere."
msgstr "Wysyłaj, łącz, odbieraj i osadzaj wszędzie."
#: apps/marketing/src/app/(marketing)/open/salary-bands.tsx:34
msgid "Seniority"
msgstr "Poziom"
#: apps/marketing/src/components/(marketing)/footer.tsx:39
msgid "Shop"
msgstr "Sklep"
#: apps/marketing/src/app/(marketing)/singleplayer/client.tsx:63
msgid "Sign"
msgstr "Podpisz"
#: apps/marketing/src/components/(marketing)/header.tsx:72
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:61
msgid "Sign in"
msgstr "Zaloguj się"
#: apps/marketing/src/components/(marketing)/header.tsx:77
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:57
msgid "Sign up"
msgstr "Zarejestruj się"
#: apps/marketing/src/components/(marketing)/carousel.tsx:22
msgid "Signing Process"
msgstr "Proces podpisywania"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:94
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:136
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:180
msgid "Signup Now"
msgstr "Zapisz się teraz"
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:89
msgid "Smart."
msgstr "Inteligentny."
#: apps/marketing/src/components/(marketing)/hero.tsx:132
msgid "Star on GitHub"
msgstr "Dodaj gwiazdkę na GitHubie"
#: apps/marketing/src/app/(marketing)/open/page.tsx:226
msgid "Stars"
msgstr "Gwiazdy"
#: apps/marketing/src/components/(marketing)/footer.tsx:40
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:44
msgid "Status"
msgstr "Status"
#: apps/marketing/src/components/(marketing)/footer.tsx:34
#: apps/marketing/src/components/(marketing)/mobile-navigation.tsx:48
msgid "Support"
msgstr "Wsparcie"
#: apps/marketing/src/app/(marketing)/open/team-members.tsx:26
msgid "Team"
msgstr "Zespół"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:195
msgid "Team Inbox"
msgstr "Skrzynka zespołowa"
#: apps/marketing/src/components/(marketing)/carousel.tsx:28
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:162
msgid "Teams"
msgstr "Zespoły"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:83
msgid "Template Store (Soon)."
msgstr "Sklep z szablonami (Wkrótce)."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:138
msgid "That's awesome. You can take a look at the current <0>Issues</0> and join our <1>Discord Community</1> to keep up to date, on what the current priorities are. In any case, we are an open community and welcome all input, technical and non-technical ❤️"
msgstr "To niesamowite. Możesz spojrzeć na aktualne <0>Problemy</0> i dołączyć do naszej <1>społeczności Discord</1>, aby być na bieżąco z aktualnymi priorytetami. W każdym razie jesteśmy otwartą społecznością i witamy wszelkie opinie, techniczne i nietechniczne ❤️"
#: apps/marketing/src/app/(marketing)/open/page.tsx:293
msgid "This page is evolving as we learn what makes a great signing company. We'll update it when we have more to share."
msgstr "Ta strona ewoluuje, gdy uczymy się, co czyni firmę podpisującą doskonałą. Zaktualizujemy ją, gdy będziemy mieli więcej do podzielenia się."
#: apps/marketing/src/app/(marketing)/open/salary-bands.tsx:31
msgid "Title"
msgstr "Tytuł"
#: apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx:30
#: apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx:55
msgid "Total Completed Documents"
msgstr "Całkowita liczba zakończonych dokumentów"
#: apps/marketing/src/app/(marketing)/open/page.tsx:267
#: apps/marketing/src/app/(marketing)/open/page.tsx:268
msgid "Total Customers"
msgstr "Całkowita liczba klientów"
#: apps/marketing/src/app/(marketing)/open/funding-raised.tsx:29
msgid "Total Funding Raised"
msgstr "Całkowita kwota zebrana"
#: apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx:30
#: apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx:43
#: apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx:52
msgid "Total Users"
msgstr "Całkowita liczba użytkowników"
#: apps/marketing/src/components/(marketing)/open-build-template-bento.tsx:31
msgid "Truly your own."
msgstr "Naprawdę twoje."
#: apps/marketing/src/components/(marketing)/callout.tsx:27
#: apps/marketing/src/components/(marketing)/hero.tsx:123
msgid "Try our Free Plan"
msgstr "Wypróbuj nasz plan darmowy"
#: apps/marketing/src/app/(marketing)/open/typefully.tsx:20
msgid "Twitter Stats"
msgstr "Statystyki Twittera"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:142
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:186
msgid "Unlimited Documents per Month"
msgstr "Nieograniczone dokumenty miesięcznie"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:103
msgid "Up to 10 recipients per document"
msgstr "Do 10 odbiorców na dokument"
#: apps/marketing/src/app/(marketing)/singleplayer/client.tsx:52
msgid "Upload a document and add fields."
msgstr "Prześlij dokument i dodaj pola."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:123
msgid "Using our hosted version is the easiest way to get started, you can simply subscribe and start signing your documents. We take care of the infrastructure, so you can focus on your business. Additionally, when using our hosted version you benefit from our trusted signing certificates which helps you to build trust with your customers."
msgstr "Korzystanie z naszej wersji hostowanej jest najłatwiejszym sposobem na rozpoczęcie, możesz po prostu subskrybować i zacząć podpisywanie swoich dokumentów. Zajmujemy się infrastrukturą, abyś mógł skupić się na swoim biznesie. Dodatkowo, korzystając z naszej wersji hostowanej, korzystasz z naszych zaufanych certyfikatów podpisujących, co pomaga budować zaufanie u Twoich klientów."
#: apps/marketing/src/app/(marketing)/open/typefully.tsx:33
msgid "View all stats"
msgstr "Zobacz wszystkie statystyki"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:195
msgid "We are happy to assist you at <0>support@documenso.com</0> or <1>in our Discord-Support-Channel</1> please message either Lucas or Timur to get added to the channel if you are not already a member."
msgstr "Cieszymy się, że możemy Ci pomóc pod adresem <0>support@documenso.com</0> lub <1>w naszym kanale wsparcia na Discordzie</1>, proszę napisz do Lucasa lub Timura, aby zostać dodanym do kanału, jeśli jeszcze nie jesteś członkiem."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:89
msgid "What is the difference between the plans?"
msgstr "Jaka jest różnica między planami?"
#: apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx:47
msgid "When it comes to sending or receiving a contract, you can count on lightning-fast speeds."
msgstr "Jeśli chodzi o wysyłanie lub odbieranie umowy, możesz liczyć na błyskawiczne prędkości."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:191
msgid "Where can I get support?"
msgstr "Gdzie mogę uzyskać pomoc?"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:177
msgid "Why should I prefer Documenso over DocuSign or some other signing tool?"
msgstr "Dlaczego powinienem preferować Documenso zamiast DocuSign lub innego narzędzia do podpisywania?"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:119
msgid "Why should I use your hosting service?"
msgstr "Dlaczego powinienem korzystać z usługi hostingu?"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:60
msgid "Yearly"
msgstr "Rocznie"
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:167
msgid "Yes! Documenso is offered under the GNU AGPL V3 open source license. This means you can use it for free and even modify it to fit your needs, as long as you publish your changes under the same license."
msgstr "Tak! Documenso jest oferowane na podstawie licencji GNU AGPL V3 open source. Oznacza to, że możesz z niego korzystać bezpłatnie, a nawet modyfikować je zgodnie ze swoimi potrzebami, o ile opublikujesz swoje zmiany na tej samej licencji."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:93
msgid "You can self-host Documenso for free or use our ready-to-use hosted version. The hosted version comes with additional support, painless scalability and more. Early adopters will get access to all features we build this year, for no additional cost! Forever! Yes, that includes multiple users per account later. If you want Documenso for your enterprise, we are happy to talk about your needs."
msgstr "Możesz samodzielnie hostować Documenso za darmo lub skorzystać z naszej gotowej wersji hostowanej. Wersja hostowana zapewnia dodatkowe wsparcie, łatwą skalowalność i inne. Wczesni użytkownicy będą mieli dostęp do wszystkich funkcji, które budujemy w tym roku, bez dodatkowych kosztów! Na zawsze! Tak, to obejmuje wielu użytkowników na konto później. Jeśli chcesz Documenso dla swojej firmy, chętnie porozmawiamy o Twoich potrzebach."
#: apps/marketing/src/components/(marketing)/carousel.tsx:272
msgid "Your browser does not support the video tag."
msgstr "Twoja przeglądarka nie obsługuje tagu wideo."

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated.
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
'DOCUMENT_OPENED', // When the document is opened by a recipient.
'DOCUMENT_RECIPIENT_REJECTED', // When a recipient rejects the document.
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
@ -374,6 +375,16 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
}),
});
/**
* Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document).
*/
export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED),
data: ZBaseRecipientDataSchema.extend({
reason: z.string(),
}),
});
/**
* Event: Document sent.
*/
@ -499,6 +510,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
ZDocumentAuditLogEventDocumentOpenedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
ZDocumentAuditLogEventDocumentSentSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,

View File

@ -0,0 +1,52 @@
import { z } from 'zod';
import type { DocumentMeta } from '@documenso/prisma/client';
import { DocumentDistributionMethod } from '@documenso/prisma/client';
export enum DocumentEmailEvents {
RecipientSigningRequest = 'recipientSigningRequest',
RecipientRemoved = 'recipientRemoved',
DocumentPending = 'documentPending',
DocumentCompleted = 'documentCompleted',
DocumentDeleted = 'documentDeleted',
}
export const ZDocumentEmailSettingsSchema = z
.object({
recipientSigningRequest: z.boolean().default(true),
recipientRemoved: z.boolean().default(true),
documentPending: z.boolean().default(true),
documentCompleted: z.boolean().default(true),
documentDeleted: z.boolean().default(true),
})
.strip()
.catch(() => ({
recipientSigningRequest: true,
recipientRemoved: true,
documentPending: true,
documentCompleted: true,
documentDeleted: true,
}));
export type TDocumentEmailSettings = z.infer<typeof ZDocumentEmailSettingsSchema>;
export const extractDerivedDocumentEmailSettings = (
documentMeta?: DocumentMeta | null,
): TDocumentEmailSettings => {
const emailSettings = ZDocumentEmailSettingsSchema.parse(documentMeta?.emailSettings ?? {});
if (
!documentMeta?.distributionMethod ||
documentMeta?.distributionMethod === DocumentDistributionMethod.EMAIL
) {
return emailSettings;
}
return {
recipientSigningRequest: false,
recipientRemoved: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
};
};

View File

@ -1,14 +1,10 @@
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/macro';
import { match } from 'ts-pattern';
import type {
DocumentAuditLog,
DocumentMeta,
Field,
Recipient,
RecipientRole,
} from '@documenso/prisma/client';
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '../constants/recipient-roles';
import type {
TDocumentAuditLog,
TDocumentAuditLogDocumentMetaDiffSchema,
@ -254,133 +250,133 @@ export const diffDocumentMetaChanges = (
*
* Provide a userId to prefix the action with the user, example 'X did Y'.
*/
export const formatDocumentAuditLogActionString = (
export const formatDocumentAuditLogAction = (
_: I18n['_'],
auditLog: TDocumentAuditLog,
userId?: number,
) => {
const { prefix, description } = formatDocumentAuditLogAction(auditLog, userId);
return prefix ? `${prefix} ${description}` : description;
};
/**
* Formats the audit log into a description of the action.
*
* Provide a userId to prefix the action with the user, example 'X did Y'.
*/
// Todo: Translations.
export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId?: number) => {
let prefix = userId === auditLog.userId ? 'You' : auditLog.name || auditLog.email || '';
const prefix = userId === auditLog.userId ? _(msg`You`) : auditLog.name || auditLog.email || '';
const description = match(auditLog)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
anonymous: 'A field was added',
identified: 'added a field',
anonymous: msg`A field was added`,
identified: msg`${prefix} added a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
anonymous: 'A field was removed',
identified: 'removed a field',
anonymous: msg`A field was removed`,
identified: msg`${prefix} removed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
anonymous: 'A field was updated',
identified: 'updated a field',
anonymous: msg`A field was updated`,
identified: msg`${prefix} updated a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
anonymous: 'A recipient was added',
identified: 'added a recipient',
anonymous: msg`A recipient was added`,
identified: msg`${prefix} added a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
anonymous: 'A recipient was removed',
identified: 'removed a recipient',
anonymous: msg`A recipient was removed`,
identified: msg`${prefix} removed a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
anonymous: 'A recipient was updated',
identified: 'updated a recipient',
anonymous: msg`A recipient was updated`,
identified: msg`${prefix} updated a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
anonymous: 'Document created',
identified: 'created the document',
anonymous: msg`Document created`,
identified: msg`${prefix} created the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
anonymous: 'Document deleted',
identified: 'deleted the document',
anonymous: msg`Document deleted`,
identified: msg`${prefix} deleted the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED }, () => ({
anonymous: 'Document restored',
identified: 'restored the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
anonymous: 'Field signed',
identified: 'signed a field',
anonymous: msg`Field signed`,
identified: msg`${prefix} signed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
anonymous: 'Field unsigned',
identified: 'unsigned a field',
anonymous: msg`Field unsigned`,
identified: msg`${prefix} unsigned a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
anonymous: 'Document visibility updated',
identified: 'updated the document visibility',
anonymous: msg`Document visibility updated`,
identified: msg`${prefix} updated the document visibility`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
anonymous: 'Document access auth updated',
identified: 'updated the document access auth requirements',
anonymous: msg`Document access auth updated`,
identified: msg`${prefix} updated the document access auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
anonymous: 'Document signing auth updated',
identified: 'updated the document signing auth requirements',
anonymous: msg`Document signing auth updated`,
identified: msg`${prefix} updated the document signing auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
anonymous: 'Document updated',
identified: 'updated the document',
anonymous: msg`Document updated`,
identified: msg`${prefix} updated the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
anonymous: 'Document opened',
identified: 'opened the document',
anonymous: msg`Document opened`,
identified: msg`${prefix} opened the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
anonymous: 'Document title updated',
identified: 'updated the document title',
anonymous: msg`Document title updated`,
identified: msg`${prefix} updated the document title`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
anonymous: 'Document external ID updated',
identified: 'updated the document external ID',
anonymous: msg`Document external ID updated`,
identified: msg`${prefix} updated the document external ID`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
anonymous: 'Document sent',
identified: 'sent the document',
anonymous: msg`Document sent`,
identified: msg`${prefix} sent the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
anonymous: 'Document moved to team',
identified: 'moved the document to team',
anonymous: msg`Document moved to team`,
identified: msg`${prefix} moved the document to team`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const action = RECIPIENT_ROLES_DESCRIPTION_ENG[data.recipientRole as RecipientRole]?.actioned;
const userName = prefix || _(msg`Recipient`);
const value = action ? `${action.toLowerCase()} the document` : 'completed their task';
const result = match(data.recipientRole)
.with(RecipientRole.SIGNER, () => msg`${userName} signed the document`)
.with(RecipientRole.VIEWER, () => msg`${userName} viewed the document`)
.with(RecipientRole.APPROVER, () => msg`${userName} approved the document`)
.with(RecipientRole.CC, () => msg`${userName} CC'd the document`)
.otherwise(() => msg`${userName} completed their task`);
return {
anonymous: `Recipient ${value}`,
identified: value,
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} rejected the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: `Email ${data.isResending ? 'resent' : 'sent'}`,
identified: `${data.isResending ? 'resent' : 'sent'} an email to ${data.recipientEmail}`,
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending
? msg`${prefix} resent an email to ${data.recipientEmail}`
: msg`${prefix} sent an email to ${data.recipientEmail}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => ({
anonymous: msg`Document completed`,
identified: msg`Document completed`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => {
// Clear the prefix since this should be considered an 'anonymous' event.
prefix = '';
return {
anonymous: 'Document completed',
identified: 'Document completed',
};
})
.exhaustive();
return {
prefix,
description: prefix ? description.identified : description.anonymous,
description: _(prefix ? description.identified : description.anonymous),
};
};

View File

@ -1,6 +1,6 @@
import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies';
import type { I18n } from '@lingui/core';
import type { I18n, MessageDescriptor } from '@lingui/core';
import { IS_APP_WEB, IS_APP_WEB_I18N_ENABLED } from '../constants/app';
import type { I18nLocaleData, SupportedLanguageCodes } from '../constants/i18n';
@ -10,7 +10,17 @@ export async function dynamicActivate(i18nInstance: I18n, locale: string) {
const extension = process.env.NODE_ENV === 'development' ? 'po' : 'js';
const context = IS_APP_WEB ? 'web' : 'marketing';
const { messages } = await import(`../translations/${locale}/${context}.${extension}`);
let { messages } = await import(`../translations/${locale}/${context}.${extension}`);
// Dirty way to load common messages for development since it's not compiled.
if (process.env.NODE_ENV === 'development') {
const commonMessages = await import(`../translations/${locale}/common.${extension}`);
messages = {
...messages,
...commonMessages.messages,
};
}
i18nInstance.loadAndActivate({ locale, messages });
}
@ -106,3 +116,7 @@ export const extractLocaleData = ({
locales,
};
};
export const parseMessageDescriptor = (_: I18n['_'], value: string | MessageDescriptor) => {
return typeof value === 'string' ? value : _(value);
};

View File

@ -4,7 +4,6 @@ export const isValidRedirectUrl = (value: string) => {
try {
const url = new URL(value);
console.log({ protocol: url.protocol });
if (!ALLOWED_PROTOCOLS.includes(url.protocol.slice(0, -1).toLowerCase())) {
return false;
}

View File

@ -1,5 +1,9 @@
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}`;
/**
* Whether a recipient can be modified by the document owner.
*/

View File

@ -0,0 +1,18 @@
declare global {
// eslint-disable-next-line no-var, @typescript-eslint/no-explicit-any
var __documenso_util_remember: Map<string, any>;
}
export function remember<T>(name: string, getValue: () => T): T {
const thusly = globalThis;
if (!thusly.__documenso_util_remember) {
thusly.__documenso_util_remember = new Map();
}
if (!thusly.__documenso_util_remember.has(name)) {
thusly.__documenso_util_remember.set(name, getValue());
}
return thusly.__documenso_util_remember.get(name);
}

View File

@ -0,0 +1,34 @@
import { I18nProvider } from '@lingui/react';
import type { RenderOptions } from '@documenso/email/render';
import { render } from '@documenso/email/render';
import { getI18nInstance } from '../client-only/providers/i18n.server';
import {
APP_I18N_OPTIONS,
type SupportedLanguageCodes,
isValidLanguageCode,
} from '../constants/i18n';
export const renderEmailWithI18N = async (
component: React.ReactElement,
options?: RenderOptions & {
// eslint-disable-next-line @typescript-eslint/ban-types
lang?: SupportedLanguageCodes | (string & {});
},
) => {
try {
const { lang: providedLang, ...otherOptions } = options ?? {};
const lang = isValidLanguageCode(providedLang) ? providedLang : APP_I18N_OPTIONS.sourceLang;
const i18n = await getI18nInstance(lang);
i18n.activate(lang);
return render(<I18nProvider i18n={i18n}>{component}</I18nProvider>, otherOptions);
} catch (err) {
console.error(err);
throw new Error('Failed to render email');
}
};

View File

@ -0,0 +1,13 @@
import type { TeamGlobalSettings } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export const teamGlobalSettingsToBranding = (teamGlobalSettings: TeamGlobalSettings) => {
return {
...teamGlobalSettings,
brandingLogo:
teamGlobalSettings.brandingEnabled && teamGlobalSettings.brandingLogo
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${teamGlobalSettings.teamId}`
: '',
};
};