diff --git a/.github/workflows/translations-extract.yml b/.github/workflows/translations-extract.yml
deleted file mode 100644
index 7f1262cfc..000000000
--- a/.github/workflows/translations-extract.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-# Extract and compile translations for all PRs.
-
-name: 'Extract and compile translations'
-
-on:
- workflow_call:
- pull_request:
- branches: ['main']
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
- cancel-in-progress: true
-
-jobs:
- extract_translations:
- name: Extract and compile translations
- runs-on: ubuntu-latest
- permissions:
- contents: write
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- ref: ${{ github.event.pull_request.head.ref }}
-
- - uses: ./.github/actions/node-install
-
- - name: Extract and compile translations
- run: |
- npm run translate:extract
- npm run translate:compile
-
- - name: Check and commit any files created
- run: |
- git config --global user.name 'github-actions'
- git config --global user.email 'github-actions@documenso.com'
- git add packages/lib/translations
- git diff --staged --quiet --exit-code || (git commit -m "chore: extract translations" && git push)
diff --git a/.github/workflows/translations-upload.yml b/.github/workflows/translations-upload.yml
index d40a3217a..65fd276f6 100644
--- a/.github/workflows/translations-upload.yml
+++ b/.github/workflows/translations-upload.yml
@@ -25,10 +25,8 @@ jobs:
- uses: ./.github/actions/node-install
- - name: Extract and compile translations
- run: |
- npm run translate:extract
- npm run translate:compile
+ - name: Extract translations
+ run: npm run translate:extract
- name: Check and commit any files created
run: |
diff --git a/.husky/pre-commit b/.husky/pre-commit
index cacfc7e37..3d805e3cf 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -13,9 +13,4 @@ node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
git add "$MONOREPO_ROOT/apps/web/public/"
git add "$MONOREPO_ROOT/apps/marketing/public/"
-echo "Extract and compile translations"
-npm run translate:extract
-npm run translate:compile
-git add "$MONOREPO_ROOT/packages/lib/translations/"
-
npx lint-staged
diff --git a/apps/documentation/pages/developers/contributing/_meta.json b/apps/documentation/pages/developers/contributing/_meta.json
new file mode 100644
index 000000000..262c5231c
--- /dev/null
+++ b/apps/documentation/pages/developers/contributing/_meta.json
@@ -0,0 +1,4 @@
+{
+ "index": "Getting Started",
+ "contributing-translations": "Contributing Translations"
+}
\ No newline at end of file
diff --git a/apps/documentation/pages/developers/contributing/contributing-translations.mdx b/apps/documentation/pages/developers/contributing/contributing-translations.mdx
new file mode 100644
index 000000000..b944aa858
--- /dev/null
+++ b/apps/documentation/pages/developers/contributing/contributing-translations.mdx
@@ -0,0 +1,69 @@
+---
+title: Contributing Translations
+description: Learn how to contribute translations to Documenso and become part of our community.
+---
+
+import { Callout, Steps } from 'nextra/components';
+
+# Contributing Translations
+
+We are always open for help with translations! Currently we utilise AI to generate the initial translations for new languages, which are then improved over time by our awesome community.
+
+If you are looking for development notes on translations, you can find them [here](/developers/local-development/translations).
+
+
{recipient.email}
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx
index 69e7d1142..95621c760 100644
--- a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx
+++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx
@@ -1,6 +1,9 @@
import React from 'react';
-import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
+import {
+ getExtraRecipientsType,
+ getRecipientType,
+} from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
@@ -13,20 +16,27 @@ export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
const remainingItems = recipients.length - itemsToRender.length;
return itemsToRender.map((recipient: Recipient, index: number) => {
- const first = index === 0 ? true : false;
+ const first = index === 0;
- const lastItemText =
- index === itemsToRender.length - 1 && remainingItems > 0
- ? `+${remainingItems + 1}`
- : undefined;
+ if (index === 4 && remainingItems > 0) {
+ return (
+
+
{field.Signature?.typedSignature}
), @@ -122,7 +122,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl {field.Recipient.signingStatus === SigningStatus.NOT_SIGNED && ( {
+ const team = await seedTeam();
+ const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
+ const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
+ const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
+
+ await seedDocuments([
+ {
+ sender: team.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'EVERYONE',
+ title: 'Searchable Document for Everyone',
+ },
+ },
+ {
+ sender: team.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'MANAGER_AND_ABOVE',
+ title: 'Searchable Document for Managers',
+ },
+ },
+ {
+ sender: team.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'ADMIN',
+ title: 'Searchable Document for Admins',
+ },
+ },
+ ]);
+
+ const testCases = [
+ { user: adminUser, visibleDocs: 3 },
+ { user: managerUser, visibleDocs: 2 },
+ { user: memberUser, visibleDocs: 1 },
+ ];
+
+ for (const { user, visibleDocs } of testCases) {
+ await apiSignin({
+ page,
+ email: user.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Searchable');
+ await page.waitForURL(/search=Searchable/);
+
+ await checkDocumentTabCount(page, 'All', visibleDocs);
+
+ await apiSignout({ page });
+ }
+});
+
+test('[TEAMS]: search does not reveal documents from other teams', async ({ page }) => {
+ const { team: teamA, teamMember2: teamAMember } = await seedTeamDocuments();
+ const { team: teamB } = await seedTeamDocuments();
+
+ await seedDocuments([
+ {
+ sender: teamA.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: teamA.id,
+ visibility: 'EVERYONE',
+ title: 'Unique Team A Document',
+ },
+ },
+ {
+ sender: teamB.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: teamB.id,
+ visibility: 'EVERYONE',
+ title: 'Unique Team B Document',
+ },
+ },
+ ]);
+
+ await apiSignin({
+ page,
+ email: teamAMember.email,
+ redirectPath: `/t/${teamA.url}/documents`,
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Unique');
+ await page.waitForURL(/search=Unique/);
+
+ await checkDocumentTabCount(page, 'All', 1);
+ await expect(page.getByRole('link', { name: 'Unique Team A Document' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Unique Team B Document' })).not.toBeVisible();
+
+ await apiSignout({ page });
+});
+
+test('[PERSONAL]: search does not reveal team documents in personal account', async ({ page }) => {
+ const { team, teamMember2 } = await seedTeamDocuments();
+
+ await seedDocuments([
+ {
+ sender: teamMember2,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: null,
+ title: 'Personal Unique Document',
+ },
+ },
+ {
+ sender: team.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'EVERYONE',
+ title: 'Team Unique Document',
+ },
+ },
+ ]);
+
+ await apiSignin({
+ page,
+ email: teamMember2.email,
+ redirectPath: '/documents',
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Unique');
+ await page.waitForURL(/search=Unique/);
+
+ await checkDocumentTabCount(page, 'All', 1);
+ await expect(page.getByRole('link', { name: 'Personal Unique Document' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Team Unique Document' })).not.toBeVisible();
+
+ await apiSignout({ page });
+});
+
+test('[TEAMS]: search respects recipient visibility regardless of team visibility', async ({
+ page,
+}) => {
+ const team = await seedTeam();
+ const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
+
+ await seedDocuments([
+ {
+ sender: team.owner,
+ recipients: [memberUser],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'ADMIN',
+ title: 'Admin Document with Member Recipient',
+ },
+ },
+ ]);
+
+ await apiSignin({
+ page,
+ email: memberUser.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Admin Document');
+ await page.waitForURL(/search=Admin(%20|\+|\s)Document/);
+
+ await checkDocumentTabCount(page, 'All', 1);
+ await expect(
+ page.getByRole('link', { name: 'Admin Document with Member Recipient' }),
+ ).toBeVisible();
+
+ await apiSignout({ page });
+});
+
+test('[TEAMS]: search by recipient name respects visibility', async ({ page }) => {
+ const team = await seedTeam();
+ const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
+ const memberUser = await seedTeamMember({
+ teamId: team.id,
+ role: TeamMemberRole.MEMBER,
+ name: 'Team Member',
+ });
+
+ const uniqueRecipient = await seedUser();
+
+ await seedDocuments([
+ {
+ sender: team.owner,
+ recipients: [uniqueRecipient],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'ADMIN',
+ title: 'Admin Document for Unique Recipient',
+ },
+ },
+ ]);
+
+ // Admin should see the document when searching by recipient name
+ await apiSignin({
+ page,
+ email: adminUser.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Unique Recipient');
+ await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/);
+
+ await checkDocumentTabCount(page, 'All', 1);
+ await expect(
+ page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
+ ).toBeVisible();
+
+ await apiSignout({ page });
+
+ // Member should not see the document when searching by recipient name
+ await apiSignin({
+ page,
+ email: memberUser.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Unique Recipient');
+ await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/);
+
+ await checkDocumentTabCount(page, 'All', 0);
+ await expect(
+ page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
+ ).not.toBeVisible();
+
+ await apiSignout({ page });
+});
diff --git a/packages/lib/client-only/hooks/use-field-page-coords.ts b/packages/lib/client-only/hooks/use-field-page-coords.ts
index 518e8b84a..3c55c6e61 100644
--- a/packages/lib/client-only/hooks/use-field-page-coords.ts
+++ b/packages/lib/client-only/hooks/use-field-page-coords.ts
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
-import { Field } from '@documenso/prisma/client';
+import type { Field } from '@documenso/prisma/client';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({
diff --git a/packages/lib/client-only/providers/i18n.server.tsx b/packages/lib/client-only/providers/i18n.server.tsx
index fc0432b38..8ee89f3de 100644
--- a/packages/lib/client-only/providers/i18n.server.tsx
+++ b/packages/lib/client-only/providers/i18n.server.tsx
@@ -15,9 +15,10 @@ type SupportedLanguages = (typeof SUPPORTED_LANGUAGE_CODES)[number];
async function loadCatalog(lang: SupportedLanguages): Promise<{
[k: string]: Messages;
}> {
- const { messages } = await import(
- `../../translations/${lang}/${IS_APP_WEB ? 'web' : 'marketing'}.js`
- );
+ const extension = process.env.NODE_ENV === 'development' ? 'po' : 'js';
+ const context = IS_APP_WEB ? 'web' : 'marketing';
+
+ const { messages } = await import(`../../translations/${lang}/${context}.${extension}`);
return {
[lang]: messages,
diff --git a/packages/lib/client-only/recipient-type.ts b/packages/lib/client-only/recipient-type.ts
index 44993796a..4e2069087 100644
--- a/packages/lib/client-only/recipient-type.ts
+++ b/packages/lib/client-only/recipient-type.ts
@@ -1,12 +1,19 @@
import type { Recipient } from '@documenso/prisma/client';
import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
+export enum RecipientStatusType {
+ COMPLETED = 'completed',
+ OPENED = 'opened',
+ WAITING = 'waiting',
+ UNSIGNED = 'unsigned',
+}
+
export const getRecipientType = (recipient: Recipient) => {
if (
recipient.role === RecipientRole.CC ||
(recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED)
) {
- return 'completed';
+ return RecipientStatusType.COMPLETED;
}
if (
@@ -14,12 +21,33 @@ export const getRecipientType = (recipient: Recipient) => {
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
) {
- return 'opened';
+ return RecipientStatusType.OPENED;
}
- if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED') {
- return 'waiting';
+ if (
+ recipient.sendStatus === SendStatus.SENT &&
+ recipient.signingStatus === SigningStatus.NOT_SIGNED
+ ) {
+ return RecipientStatusType.WAITING;
}
- return 'unsigned';
+ return RecipientStatusType.UNSIGNED;
+};
+
+export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
+ const types = extraRecipients.map((r) => getRecipientType(r));
+
+ if (types.includes(RecipientStatusType.UNSIGNED)) {
+ return RecipientStatusType.UNSIGNED;
+ }
+
+ if (types.includes(RecipientStatusType.OPENED)) {
+ return RecipientStatusType.OPENED;
+ }
+
+ if (types.includes(RecipientStatusType.WAITING)) {
+ return RecipientStatusType.WAITING;
+ }
+
+ return RecipientStatusType.COMPLETED;
};
diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts
index 2a1831c56..79ae7fd9b 100644
--- a/packages/lib/server-only/document/find-documents.ts
+++ b/packages/lib/server-only/document/find-documents.ts
@@ -25,6 +25,7 @@ export type FindDocumentsOptions = {
};
period?: PeriodSelectorValue;
senderIds?: number[];
+ search?: string;
};
export const findDocuments = async ({
@@ -37,6 +38,7 @@ export const findDocuments = async ({
orderBy,
period,
senderIds,
+ search,
}: FindDocumentsOptions) => {
const { user, team } = await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
@@ -85,6 +87,14 @@ export const findDocuments = async ({
}))
.otherwise(() => undefined);
+ const searchFilter: Prisma.DocumentWhereInput = {
+ OR: [
+ { title: { contains: search, mode: 'insensitive' } },
+ { Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
+ { Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
+ ],
+ };
+
const visibilityFilters = [
match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
@@ -130,6 +140,7 @@ export const findDocuments = async ({
const whereClause: Prisma.DocumentWhereInput = {
...termFilters,
...filters,
+ ...searchFilter,
};
if (period) {
diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts
index d73baa4ad..62899c1fa 100644
--- a/packages/lib/server-only/document/get-stats.ts
+++ b/packages/lib/server-only/document/get-stats.ts
@@ -15,9 +15,10 @@ export type GetStatsInput = {
user: User;
team?: Omit