Compare commits

...

27 Commits

Author SHA1 Message Date
Ephraim Duncan f2757232e8 Merge branch 'main' into chore/block-po-files-locally 2026-01-20 13:49:12 +00:00
Ted Liang 158b36a9b7 fix: security CVE-2026-22817 CVE-2026-22818 (#2390) 2026-01-15 18:27:04 +11:00
Lucas Smith fabd69bd62 build: upgrade simplewebauthn packages from v9 to v13 (#2389)
The v9 packages are deprecated. This updates to v13 which includes
breaking API changes: optionsJSON wrapper for auth functions,
renamed properties (authenticator→credential), and base64 encoding
for credential IDs via isoBase64URL helper.
2026-01-15 14:22:37 +11:00
Lucas Smith c976e747e3 fix: dont flatten forms for templates (#2386)
Templates shouldn't have their form flattened until they're
converted to a document.
2026-01-14 12:06:28 +11:00
Lucas Smith 34f512bd55 docs: add OpenCode AI-assisted development guide (#2384)
Adds OpenCode support for AI-assisted development, including custom
commands and skills to help contributors maintain consistency and
streamline common workflows.

#### Changes
- Added "AI-Assisted Development with OpenCode" section to
CONTRIBUTING.md with:
  - Installation instructions and provider configuration
- Documentation for 8 custom commands (/implement, /continue,
/interview, /document, /commit, /create-plan, /create-scratch,
/create-justification)
  - Typical workflow guide
- Clear policy that AI-generated code must be reviewed before submission
- Added .agents/ directory for plans, scratches, and justifications
- Added .opencode/ commands and skills for the agent
- Added helper scripts for creating agent files
2026-01-14 10:10:20 +11:00
Karlo db913e95b6 fix: downgrade pdfjs-dist to version 5.4.296 and update react-pdf to version 10.3.0 (#2383) 2026-01-13 21:01:29 +11:00
Catalin Pit bb3e9583e4 feat: add default recipients for teams and orgs (#2248) 2026-01-13 20:32:00 +11:00
Lucas Smith 5bc73a7471 chore: npm audit fix (#2367) 2026-01-13 16:39:10 +11:00
Lucas Smith 06d7849146 chore: add translations (#2373) 2026-01-13 14:34:26 +11:00
Lucas Smith cef7987a72 feat: add audit logs to document details page (#2379)
- Add collapsible audit logs section with paginated table
- Add View JSON button to inspect raw audit log entries
- Display legacy document ID and recipient roles
- Add admin TRPC endpoint for fetching audit logs
- Add database index on envelopeId for DocumentAuditLog table

<img width="887" height="724" alt="image"
src="https://github.com/user-attachments/assets/aeb904c9-515f-49e1-9f8f-513aef455678"
/>
2026-01-13 14:18:10 +11:00
github-actions[bot] cf6f6bcea0 chore: extract translations (#2363) 2026-01-13 12:49:05 +11:00
Catalin Pit 2f27304750 refactor: simplify field dialog component (#2369) 2026-01-13 12:38:10 +11:00
Konrad 912530ca17 fix: mark document visibility options for translation (#2330) 2026-01-12 10:17:03 +11:00
Konrad a995961c4e fix: mark document auth types for translation (#2331) 2026-01-12 09:28:16 +11:00
Lucas Smith 6b041c23b4 v2.4.0 2026-01-08 15:16:57 +11:00
Ted Liang 7b6e948aa2 refactor: reuse svgToPng function (#2365) 2026-01-08 11:30:45 +11:00
Catalin Pit f6d81b22bd docs: update field coordinates documentation and improve devmode (#2359) 2026-01-06 10:29:21 +02:00
Lucas Smith c861dd2ee2 chore: add translations (#2362) 2026-01-06 15:54:54 +11:00
github-actions[bot] 7eabae4b4b chore: extract translations (#2351) 2026-01-06 15:36:46 +11:00
Lucas Smith ae4272a6b6 fix: remove logo from embedded signing v2 page (#2361) 2026-01-06 15:10:58 +11:00
Dylan Tarre fd672943d1 fix: replace hardcoded #7AC455 with text-documenso-700 token (#2358)
Standardizes navigation link colors by replacing hardcoded `#7AC455` hex
values with the existing `text-documenso-700` design token.
2026-01-06 14:58:45 +11:00
David Nguyen c2ea5e5859 fix: migrate certificate generation (#2251)
Generate certificates and audit logs using Konva instead of browserless.

This should:
- Reduce the changes of generations failing
- Improve sealing speed
2026-01-06 14:26:19 +11:00
Grégoire Bécue c1217c5a58 docs: ensure cert directory exists before generating PKCS12 (#2354) 2026-01-03 11:43:55 +11:00
Ted Liang 27eb2d65d4 feat: upgrade alpine and support chromium path (#2353)
Upgrade alpine to 3.22
Support chromium executable path
2026-01-03 11:31:56 +11:00
Catalin Pit ef407cb0b4 refactor: simplify form validation and enhance recipient handling (#2317) 2026-01-02 13:16:45 +11:00
Ephraim Atta-Duncan af492cbaaa chore: cleanup 2025-10-20 11:38:09 +00:00
Ephraim Atta-Duncan 7868527fa0 chore: add pre-commit hook to block .po file commits 2025-10-20 11:31:19 +00:00
153 changed files with 7445 additions and 1597 deletions
View File
View File
@@ -0,0 +1,186 @@
---
date: 2026-01-14
title: Simplewebauthn V13 Upgrade
---
## Overview
Upgrade SimpleWebAuthn packages from v9.x to v13.x to address the deprecation of `@simplewebauthn/types` and take advantage of new features and improvements.
## Current State
The codebase currently uses:
- `@simplewebauthn/browser@9.x`
- `@simplewebauthn/server@9.x`
- `@simplewebauthn/types@9.x`
## Breaking Changes Summary (v9 → v13)
### v10.0.0 Breaking Changes
1. **Minimum Node version raised to Node v20**
2. **`generateRegistrationOptions()` now expects `Base64URLString` for `excludeCredentials` IDs** (no more `type: 'public-key'` needed)
3. **`generateAuthenticationOptions()` now expects `Base64URLString` for `allowCredentials` IDs**
4. **`credentialID` returned from verification methods is now `Base64URLString`** instead of `Uint8Array`
5. **`AuthenticatorDevice.credentialID` is now `Base64URLString`**
6. **`rpID` is now required when calling `generateAuthenticationOptions()`**
7. **`generateRegistrationOptions()` will generate random user IDs** if not provided
8. **`user.id` is treated as base64url string in `startRegistration()`**
9. **`userHandle` is treated as base64url string in `startAuthentication()`**
### v11.0.0 Breaking Changes
1. **Positional arguments in `startRegistration()` and `startAuthentication()` replaced by object**
- Before: `startRegistration(options)`
- After: `startRegistration({ optionsJSON: options })`
- Before: `startAuthentication(options)`
- After: `startAuthentication({ optionsJSON: options })`
2. **`AuthenticatorDevice` type renamed to `WebAuthnCredential`**
- `credentialID``credential.id`
- `credentialPublicKey``credential.publicKey`
3. **`verifyRegistrationResponse()` returns `registrationInfo.credential` instead of individual properties**
- `credentialID``credential.id`
- `credentialPublicKey``credential.publicKey`
- `counter``credential.counter`
- `transports` are now in `credential.transports`
4. **`verifyAuthenticationResponse()` uses `credential` argument instead of `authenticator`**
### v13.0.0 Breaking Changes
1. **`@simplewebauthn/types` package is retired**
- Types are now exported from `@simplewebauthn/browser` and `@simplewebauthn/server`
- Import types from `@simplewebauthn/server` instead
## Files to Update
### Package Changes
1. Remove `@simplewebauthn/types` dependency
2. Update `@simplewebauthn/browser` to `^13.2.2`
3. Update `@simplewebauthn/server` to `^13.2.2`
### Server-side Files
#### 1. `packages/lib/server-only/auth/create-passkey-registration-options.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
- Remove `type: 'public-key'` from `excludeCredentials` items
- Update `userID` to use `isoUint8Array.fromUTF8String()` for proper encoding
#### 2. `packages/lib/server-only/auth/create-passkey-authentication-options.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
- Remove `type: 'public-key'` from `allowCredentials` items
#### 3. `packages/lib/server-only/auth/create-passkey-signin-options.ts`
- No changes needed (already using correct options)
#### 4. `packages/lib/server-only/auth/create-passkey.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
- Update to use new `registrationInfo.credential` structure:
- `credentialID``credential.id`
- `credentialPublicKey``credential.publicKey`
- `counter``credential.counter`
- Note: `credential.id` is now a `Base64URLString`, so `Buffer.from(credentialID)` needs updating
#### 5. `packages/lib/server-only/document/is-recipient-authorized.ts`
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`:
- Change `authenticator: { credentialID, credentialPublicKey, counter }` to `credential: { id, publicKey, counter }`
- Since `credential.id` is now base64url string, convert stored `credentialId` buffer to base64url
#### 6. `packages/auth/server/routes/passkey.ts`
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`
- Same changes as `is-recipient-authorized.ts`
#### 7. `packages/trpc/server/auth-router/create-passkey.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
### Browser-side Files
#### 8. `apps/remix/app/components/dialogs/passkey-create-dialog.tsx`
- Update `startRegistration()` call:
- Before: `startRegistration(passkeyRegistrationOptions)`
- After: `startRegistration({ optionsJSON: passkeyRegistrationOptions })`
#### 9. `apps/remix/app/components/forms/signin.tsx`
- Update `startAuthentication()` call:
- Before: `startAuthentication(options)`
- After: `startAuthentication({ optionsJSON: options })`
#### 10. `apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx`
- Update `startAuthentication()` call:
- Before: `startAuthentication(options)`
- After: `startAuthentication({ optionsJSON: options })`
### Database/Schema Considerations
The database stores `credentialId` as `Bytes`. The new API returns `credential.id` as `Base64URLString`. We need to:
1. When **storing** a new passkey: Convert from `Base64URLString` to `Buffer`
2. When **passing to verification**: Convert from `Buffer` to `Base64URLString`
Use `isoBase64URL` helper from `@simplewebauthn/server/helpers` for these conversions.
## Implementation Steps
### Step 1: Update package.json dependencies
```bash
npm uninstall @simplewebauthn/types
npm install @simplewebauthn/browser@^13.2.2 @simplewebauthn/server@^13.2.2
```
### Step 2: Update type imports
Replace all `@simplewebauthn/types` imports with `@simplewebauthn/server`
### Step 3: Update browser-side API calls
- `startRegistration(options)``startRegistration({ optionsJSON: options })`
- `startAuthentication(options)``startAuthentication({ optionsJSON: options })`
### Step 4: Update server-side registration
- Update `excludeCredentials` format (remove `type: 'public-key'`)
- Update `userID` encoding if needed
- Update `verifyRegistrationResponse()` result handling for new `credential` structure
### Step 5: Update server-side authentication
- Update `allowCredentials` format (remove `type: 'public-key'`)
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`
- Handle `Base64URLString` for `credential.id`
### Step 6: Update credential storage/retrieval
- When storing: Convert `Base64URLString` to `Buffer`
- When reading: Convert `Buffer` to `Base64URLString`
### Step 7: Test passkey flows
1. Test passkey creation
2. Test passkey sign-in
3. Test passkey authentication for document signing
4. Test passkey deletion
## Code Examples
### Converting stored Buffer to Base64URLString for verification
```typescript
import { isoBase64URL } from '@simplewebauthn/server/helpers';
// When reading from database (Buffer) and passing to verification
const credential = {
id: isoBase64URL.fromBuffer(passkey.credentialId),
publicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
transports: passkey.transports,
};
```
### Converting Base64URLString to Buffer for storage
```typescript
import { isoBase64URL } from '@simplewebauthn/server/helpers';
// When storing from registration response
const credentialIdBuffer = Buffer.from(
isoBase64URL.toBuffer(registrationInfo.credential.id)
);
```
## Risks and Mitigations
1. **Database compatibility**: The `credentialId` is stored as `Bytes` in the database. The new API uses `Base64URLString`. We need proper conversion functions.
- **Mitigation**: Use `isoBase64URL.fromBuffer()` and `isoBase64URL.toBuffer()` for conversions
2. **Existing passkeys**: Existing passkeys should continue to work as long as conversion is done correctly.
- **Mitigation**: Test with existing passkeys after upgrade
3. **Browser compatibility**: v10+ requires newer browser APIs.
- **Mitigation**: `browserSupportsWebAuthn()` already handles this check
View File
+2
View File
@@ -1,6 +1,8 @@
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
node "$MONOREPO_ROOT/scripts/check-po-files.cjs" || exit 1
echo "Copying .well-known/ contents"
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
+80
View File
@@ -0,0 +1,80 @@
---
description: Add and commit changes using conventional commits
allowed-tools: Bash, Read, Glob, Grep
---
Create a git commit for the current changes using the Conventional Commits standard.
## Process
1. **Analyze the changes** by running:
- `git status` to see all modified/untracked files
- `git diff` to see unstaged changes
- `git diff --staged` to see already-staged changes
- `git log --oneline -5` to see recent commit style
2. **Stage appropriate files**:
- Stage all related changes with `git add`
- Do NOT stage files that appear to contain secrets (.env, credentials, API keys, tokens)
- If you detect potential secrets, warn the user and skip those files
3. **Determine the commit type** based on the changes:
- `feat`: New feature or capability
- `fix`: Bug fix
- `docs`: Documentation only
- `style`: Formatting, whitespace (not CSS)
- `refactor`: Code restructuring without behavior change
- `perf`: Performance improvement
- `test`: Adding or updating tests
- `build`: Build system or dependencies
- `ci`: CI/CD configuration
- `chore`: Maintenance tasks, tooling, config
NOTE: Do not use a scope for commits
4. **Write the commit message**:
- **Subject line**: `<type>: <description>`
- Use imperative mood ("add" not "added")
- Lowercase, no period at end
- Max 50 characters if possible, 72 hard limit
- **Body** (if needed): Explain _why_, not _what_
- Wrap at 72 characters
- Separate from subject with blank line
## Commit Format
```
<type>[scope]: <subject>
[optional body explaining WHY this change was made]
```
## Examples
Simple change:
```
fix: handle empty input in parser without throwing
```
With body:
```
feat: add streaming response support
Large responses were causing memory issues in production.
Streaming allows processing chunks incrementally.
```
## Rules
- NEVER commit files that may contain secrets
- NEVER use `git commit --amend` unless the user explicitly requests it
- NEVER use `--no-verify` to skip hooks
- If the pre-commit hook fails, fix the issues and create a NEW commit
- If there are no changes to commit, inform the user and stop
- Use a HEREDOC to pass the commit message to ensure proper formatting
## Execute
Run the git commands to analyze, stage, and commit the changes now.
+112
View File
@@ -0,0 +1,112 @@
---
description: Continue implementing a spec from a previous session
argument-hint: <spec-file-path>
---
You are continuing implementation of a specification that was started in a previous session. Work autonomously until the feature is complete and tests pass.
## Your Task
1. **Read the spec** at `$ARGUMENTS`
2. **Read CODE_STYLE.md** for formatting conventions
3. **Assess current state**:
- Check git status for uncommitted changes
- Run tests to see what's passing/failing (if E2E tests exist)
- Review any existing implementation
4. **Determine what remains** by comparing the spec to the current state
5. **Plan remaining work** using TodoWrite
6. **Continue implementing** until complete
## Assessing Current State
Run these commands to understand where the previous session left off:
```bash
git status # See uncommitted changes
git log --oneline -10 # See recent commits
npm run typecheck -w @documenso/remix # Check for type errors
npm run lint:fix # Check for linting issues
```
Review the code that's already been written to understand:
- What's already implemented
- What's partially done
- What's not started yet
## Implementation Guidelines
### During Implementation
- Follow CODE_STYLE.md strictly (2-space indent, double quotes, braces always, etc.)
- Follow workspace rules for TypeScript, React, TRPC patterns, and Remix conventions
- Mark todos complete as you finish each task
- Commit logical chunks of work
### Code Quality
- No stubbed implementations
- Handle edge cases and error conditions
- Include descriptive error messages with context
- Use async/await for all I/O operations
- Use AppError class when throwing errors
- Use Zod for validation and react-hook-form for forms
### Testing
**Important**: E2E tests are time-consuming. Only write tests for non-trivial functionality.
- Write E2E tests in `packages/app-tests/e2e/` using Playwright
- Test critical user flows and edge cases
- Follow existing E2E test patterns in the codebase
- Use descriptive test names that explain what is being tested
- Skip tests for trivial changes (simple UI tweaks, minor refactors, etc.)
## Autonomous Workflow
Work continuously through these steps:
1. **Implement** - Write the code for the current task
2. **Typecheck** - Run `npm run typecheck -w @documenso/remix` to verify types
3. **Lint** - Run `npm run lint:fix` to fix linting issues
4. **Test** - If non-trivial, run E2E tests: `npm run test:dev -w @documenso/app-tests`
5. **Fix** - If tests fail, fix and re-run
6. **Repeat** - Move to next task
## Stopping Conditions
**Stop and report success when:**
- All spec requirements are implemented
- Typecheck passes
- Lint passes
- E2E tests pass (if written for non-trivial functionality)
**Stop and ask for help when:**
- The spec is ambiguous and you need clarification
- You encounter a blocking issue you cannot resolve
- You need to make a decision that significantly deviates from the spec
- External dependencies are missing
## Commands
```bash
# Type checking
npm run typecheck -w @documenso/remix
# Linting
npm run lint:fix
# E2E Tests (only for non-trivial work)
npm run test:dev -w @documenso/app-tests # Run E2E tests in dev mode
npm run test-ui:dev -w @documenso/app-tests # Run E2E tests with UI
npm run test:e2e # Run full E2E test suite
# Development
npm run dev # Start dev server
```
## Begin
Read the spec file and CODE_STYLE.md, assess the current implementation state, then continue where the previous session left off. Use TodoWrite to track your progress throughout.
@@ -0,0 +1,75 @@
---
description: Create a new justification file in .agents/justifications/
argument-hint: <justification-slug> [content]
---
You are creating a new justification file in the `.agents/justifications/` directory.
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the justification content
3. **Create the file** - Use the create-justification script to generate the file
## Usage
The script will automatically:
- Generate a unique three-word ID (e.g., `swift-emerald-river`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/justifications/`
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-justification.ts "$ARGUMENTS" "Your justification content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-justification.ts "$ARGUMENTS" << HEREDOC
Your multi-line
justification content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-justification.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Justification Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `architecture-decision``Architecture Decision`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `tech-stack-choice`, `api-design-rationale`)
- Include clear reasoning and context for the decision
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
## Begin
Create a justification file using the slug from `$ARGUMENTS` and appropriate content documenting the reasoning or justification.
+76
View File
@@ -0,0 +1,76 @@
---
description: Create a new plan file in .agents/plans/
argument-hint: <plan-slug> [content]
---
You are creating a new plan file in the `.agents/plans/` directory.
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the plan content
3. **Create the file** - Use the create-plan script to generate the file
## Usage
The script will automatically:
- Generate a unique three-word ID (e.g., `happy-blue-moon`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/plans/`
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-plan.ts "$ARGUMENTS" "Your plan content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-plan.ts "$ARGUMENTS" << HEREDOC
Your multi-line
plan content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-plan.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Plan Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `my-feature``My Feature`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `user-authentication`, `api-integration`)
- Include clear, actionable plan content
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
## Begin
Create a plan file using the slug from `$ARGUMENTS` and appropriate content for the planning task.
+75
View File
@@ -0,0 +1,75 @@
---
description: Create a new scratch file in .agents/scratches/
argument-hint: <scratch-slug> [content]
---
You are creating a new scratch file in the `.agents/scratches/` directory.
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the scratch content
3. **Create the file** - Use the create-scratch script to generate the file
## Usage
The script will automatically:
- Generate a unique three-word ID (e.g., `calm-teal-cloud`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/scratches/`
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-scratch.ts "$ARGUMENTS" "Your scratch content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-scratch.ts "$ARGUMENTS" << HEREDOC
Your multi-line
scratch content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-scratch.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Scratch Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `quick-notes``Quick Notes`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `exploration-ideas`, `temporary-notes`)
- Scratch files are for temporary notes, explorations, or ideas
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
## Begin
Create a scratch file using the slug from `$ARGUMENTS` and appropriate content for notes or exploration.
+201
View File
@@ -0,0 +1,201 @@
---
description: Generate MDX documentation for a module or feature
argument-hint: <module-path-or-feature>
---
You are creating proper MDX documentation for a module or feature in Documenso using Nextra.
## Your Task
1. **Identify the scope** - What does `$ARGUMENTS` refer to? (file, directory, or feature name)
2. **Read the source code** - Understand the public API, types, and behavior
3. **Read existing docs** - Check if there's documentation to update or reference
4. **Write comprehensive documentation** - Create or update MDX docs in the appropriate location
5. **Update navigation** - Add entry to `_meta.js` if creating a new page
## Documentation Structure
Create documentation in the appropriate location:
- **Developer docs**: `apps/documentation/pages/developers/`
- **User docs**: `apps/documentation/pages/users/`
### File Format
All documentation files must be `.mdx` files with frontmatter:
```mdx
---
title: Page Title
description: Brief description for SEO and meta tags
---
# Page Title
Content starts here...
```
### Navigation
Each directory should have a `_meta.js` file that defines the navigation structure:
```javascript
export default {
index: 'Introduction',
'feature-name': 'Feature Name',
'another-feature': 'Another Feature',
};
```
If creating a new page, add it to the appropriate `_meta.js` file.
### Documentation Format
````mdx
---
title: <Module|Feature Name>
description: Brief description of what this does and when to use it
---
# <Module|Feature Name>
Brief description of what this module/feature does and when to use it.
## Installation
If there are specific packages or imports needed:
```bash
npm install @documenso/package-name
```
## Quick Start
```jsx
// Minimal working example
import { Component } from '@documenso/package';
const Example = () => {
return <Component />;
};
```
## API Reference
### Component/Function Name
Description of what it does.
#### Props/Parameters
| Prop/Param | Type | Description |
| ---------- | -------------------- | ------------------------- |
| prop | `string` | Description of the prop |
| optional | `boolean` (optional) | Optional prop description |
#### Example
```jsx
import { Component } from '@documenso/package';
<Component prop="value" optional={true} />;
```
### Types
#### `TypeName`
```typescript
type TypeName = {
property: string;
optional?: boolean;
};
```
## Examples
### Common Use Case
```jsx
// Full working example
```
### Advanced Usage
```jsx
// More complex example
```
## Related
- [Link to related documentation](/developers/path)
- [Another related page](/users/path)
````
## Guidelines
### Content Quality
- **Be accurate** - Verify behavior by reading the code
- **Be complete** - Document all public API surface
- **Be practical** - Include real, working examples
- **Be concise** - Don't over-explain obvious things
- **Be user-focused** - Write for the target audience (developers or users)
### Code Examples
- Use appropriate language tags: `jsx`, `tsx`, `typescript`, `bash`, `json`
- Show imports when not obvious
- Include expected output in comments where helpful
- Progress from simple to complex
- Use real examples from the codebase when possible
### Formatting
- Always include frontmatter with `title` and `description`
- Use proper markdown headers (h1 for title, h2 for sections)
- Use tables for props/parameters documentation (matching existing style)
- Use code fences with appropriate language tags
- Use Nextra components when appropriate:
- `<Callout type="info">` for notes
- `<Steps>` for step-by-step instructions
- Use relative links for internal documentation (e.g., `/developers/embedding/react`)
### Nextra Components
You can import and use Nextra components:
```jsx
import { Callout, Steps } from 'nextra/components';
<Callout type="info">
This is an informational note.
</Callout>
<Steps>
<Steps.Step>First step</Steps.Step>
<Steps.Step>Second step</Steps.Step>
</Steps>
```
### Maintenance
- Include types inline so docs don't get stale
- Reference source file locations for complex behavior
- Keep examples up-to-date with the codebase
- Update `_meta.js` when adding new pages
## Process
1. **Explore the code** - Read source files to understand the API
2. **Identify the audience** - Is this for developers or users?
3. **Check existing docs** - Look for similar pages to match style
4. **Draft the structure** - Outline sections before writing
5. **Write content** - Fill in each section with frontmatter
6. **Add examples** - Create working code samples
7. **Update navigation** - Add to `_meta.js` if needed
8. **Review** - Read through for clarity and accuracy
## Begin
Analyze `$ARGUMENTS`, read the relevant source code, check existing documentation patterns, and create comprehensive MDX documentation following the Documenso documentation style.
+100
View File
@@ -0,0 +1,100 @@
---
description: Implement a spec from the plans directory
argument-hint: <spec-file-path>
---
You are implementing a specification from the `.agents/plans/` directory. Work autonomously until the feature is complete and tests pass.
## Your Task
1. **Read the spec** at `$ARGUMENTS`
2. **Read CODE_STYLE.md** for formatting conventions
3. **Plan the implementation** using the TodoWrite tool to break down the work
4. **Implement the feature** following the spec and code style
5. **Write E2E tests** only for non-trivial functionality (E2E tests are time-consuming)
6. **Run tests** and fix any failures
7. **Run typecheck and lint** and fix any issues
## Implementation Guidelines
### Before Coding
- Understand the spec's goals and scope
- Identify the desired API from usage examples in the spec
- Review related existing code to understand patterns
- Break the work into discrete tasks using TodoWrite
### During Implementation
- Follow CODE_STYLE.md strictly (2-space indent, double quotes, braces always, etc.)
- Follow workspace rules for TypeScript, React, TRPC patterns, and Remix conventions
- Mark todos complete as you finish each task
- Commit logical chunks of work
### Code Quality
- No stubbed implementations
- Handle edge cases and error conditions
- Include descriptive error messages with context
- Use async/await for all I/O operations
- Use AppError class when throwing errors
- Use Zod for validation and react-hook-form for forms
### Testing
**Important**: E2E tests are time-consuming. Only write tests for non-trivial functionality.
- Write E2E tests in `packages/app-tests/e2e/` using Playwright
- Test critical user flows and edge cases
- Follow existing E2E test patterns in the codebase
- Use descriptive test names that explain what is being tested
- Skip tests for trivial changes (simple UI tweaks, minor refactors, etc.)
## Autonomous Workflow
Work continuously through these steps:
1. **Implement** - Write the code for the current task
2. **Typecheck** - Run `npm run typecheck -w @documenso/remix` to verify types
3. **Lint** - Run `npm run lint:fix` to fix linting issues
4. **Test** - If non-trivial, run E2E tests: `npm run test:dev -w @documenso/app-tests`
5. **Fix** - If tests fail, fix and re-run
6. **Repeat** - Move to next task
## Stopping Conditions
**Stop and report success when:**
- All spec requirements are implemented
- Typecheck passes
- Lint passes
- E2E tests pass (if written for non-trivial functionality)
**Stop and ask for help when:**
- The spec is ambiguous and you need clarification
- You encounter a blocking issue you cannot resolve
- You need to make a decision that significantly deviates from the spec
- External dependencies are missing
## Commands
```bash
# Type checking
npm run typecheck -w @documenso/remix
# Linting
npm run lint:fix
# E2E Tests (only for non-trivial work)
npm run test:dev -w @documenso/app-tests # Run E2E tests in dev mode
npm run test-ui:dev -w @documenso/app-tests # Run E2E tests with UI
npm run test:e2e # Run full E2E test suite
# Development
npm run dev # Start dev server
```
## Begin
Read the spec file and CODE_STYLE.md, then start implementing. Use TodoWrite to track your progress throughout.
+57
View File
@@ -0,0 +1,57 @@
---
description: Deep-dive interview to flesh out a spec or design document
agent: build
argument-hint: <file-path>
---
You are conducting a thorough interview to help flesh out and complete a specification or design document.
## Your Task
1. **Read the document** at `$ARGUMENTS`
2. **Analyze it deeply** - identify gaps, ambiguities, unexplored edge cases, and areas needing clarification
3. **Interview the user** by providing a question with some pre-determined options
4. **Write the completed spec** back to the file when the interview is complete
## Interview Guidelines
### Question Quality
- Ask **non-obvious, insightful questions** - avoid surface-level queries
- Focus on: technical implementation details, architectural decisions, edge cases, error handling, UX implications, security considerations, performance tradeoffs, integration points, migration strategies, rollback plans
- Each question should reveal something that would otherwise be missed
- Challenge assumptions embedded in the document
- Explore second and third-order consequences of design decisions
- Use the Web Search and other tools where required to ground questions (e.g. package recommendations)
### Question Strategy
- Start by identifying the 3-5 most critical unknowns or ambiguities
- Use the AskUserQuestion tool with well-crafted options that represent real tradeoffs
- When appropriate, offer multiple valid approaches with their pros/cons as options
- Don't ask about things that are already clearly specified
- Probe deeper when answers reveal new areas of uncertainty
### Topics to Explore (as relevant)
- **Technical**: Data models, API contracts, state management, concurrency, caching, validation
- **UX**: Error states, loading states, empty states, edge cases, accessibility, mobile considerations
- **Operations**: Deployment, monitoring, alerting, debugging, logging, feature flags
- **Security**: Auth, authz, input validation, rate limiting, audit trails
- **Scale**: Performance bottlenecks, data growth, traffic spikes, graceful degradation
- **Integration**: Dependencies, backwards compatibility, versioning, migration path
- **Failure modes**: What happens when X fails? How do we recover? What's the blast radius?
### Interview Flow
1. Ask 2-4 questions at a time (use multiple questions in one when they're related)
2. After each round, incorporate answers and identify follow-up questions
3. Continue until all critical areas are addressed
4. Signal when you believe the interview is complete, but offer to go deeper
## Output
When the interview is complete:
1. Synthesize all gathered information
2. Rewrite/expand the original document with the new details
3. Preserve the document's original structure where sensible, but reorganize if needed
4. Add new sections for areas that weren't originally covered
5. Write the completed spec back to `$ARGUMENTS`
Begin by reading the file and identifying your first set of deep questions.
@@ -0,0 +1,56 @@
---
name: create-justification
description: Create a new justification file in .agents/justifications/ with a unique three-word ID, frontmatter, and formatted title
license: MIT
compatibility: opencode
metadata:
audience: agents
workflow: decision-making
---
## What I do
I help you create new justification files in the `.agents/justifications/` directory. Each justification file gets:
- A unique three-word identifier (e.g., `swift-emerald-river`)
- Frontmatter with the current date and formatted title
- Content you provide
## How to use
Run the script with a slug and content:
```bash
npx tsx scripts/create-justification.ts "decision-name" "Justification content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-justification.ts "decision-name" << HEREDOC
Multi-line
justification content
goes here
HEREDOC
```
## File format
Files are created as: `{three-word-id}-{slug}.md`
Example: `swift-emerald-river-decision-name.md`
The file includes frontmatter:
```markdown
---
date: 2026-01-13
title: Decision Name
---
Your content here
```
## When to use me
Use this skill when you need to document the reasoning or justification for a decision, approach, or architectural choice. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
+56
View File
@@ -0,0 +1,56 @@
---
name: create-plan
description: Create a new plan file in .agents/plans/ with a unique three-word ID, frontmatter, and formatted title
license: MIT
compatibility: opencode
metadata:
audience: agents
workflow: planning
---
## What I do
I help you create new plan files in the `.agents/plans/` directory. Each plan file gets:
- A unique three-word identifier (e.g., `happy-blue-moon`)
- Frontmatter with the current date and formatted title
- Content you provide
## How to use
Run the script with a slug and content:
```bash
npx tsx scripts/create-plan.ts "feature-name" "Plan content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-plan.ts "feature-name" << HEREDOC
Multi-line
plan content
goes here
HEREDOC
```
## File format
Files are created as: `{three-word-id}-{slug}.md`
Example: `happy-blue-moon-feature-name.md`
The file includes frontmatter:
```markdown
---
date: 2026-01-13
title: Feature Name
---
Your content here
```
## When to use me
Use this skill when you need to create a new plan document for a feature, task, or project. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
+56
View File
@@ -0,0 +1,56 @@
---
name: create-scratch
description: Create a new scratch file in .agents/scratches/ with a unique three-word ID, frontmatter, and formatted title
license: MIT
compatibility: opencode
metadata:
audience: agents
workflow: exploration
---
## What I do
I help you create new scratch files in the `.agents/scratches/` directory. Each scratch file gets:
- A unique three-word identifier (e.g., `calm-teal-cloud`)
- Frontmatter with the current date and formatted title
- Content you provide
## How to use
Run the script with a slug and content:
```bash
npx tsx scripts/create-scratch.ts "note-name" "Scratch content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-scratch.ts "note-name" << HEREDOC
Multi-line
scratch content
goes here
HEREDOC
```
## File format
Files are created as: `{three-word-id}-{slug}.md`
Example: `calm-teal-cloud-note-name.md`
The file includes frontmatter:
```markdown
---
date: 2026-01-13
title: Note Name
---
Your content here
```
## When to use me
Use this skill when you need to create a temporary note, exploration document, or scratch pad for ideas. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
+2
View File
@@ -11,6 +11,8 @@
- `npm run format` - Format code with Prettier
- `npm run dev` - Start development server for Remix app
**Important:** Do not run `npm run build` to verify changes unless explicitly asked. Builds take a long time (~2 minutes). Use `npx tsc --noEmit` for type checking specific packages if needed.
## Code Style Guidelines
- Use TypeScript for all code; prefer `type` over `interface`
+50
View File
@@ -52,3 +52,53 @@ You can build the project with:
```bash
npm run build
```
## AI-Assisted Development with OpenCode
We use [OpenCode](https://opencode.ai) for AI-assisted development. OpenCode provides custom commands and skills to help maintain consistency and streamline common workflows.
OpenCode works with most major AI providers (Anthropic, OpenAI, Google, etc.) or you can use [Zen](https://opencode.ai/zen) for optimized coding models. Configure your preferred provider in the OpenCode settings.
> **Important**: All AI-generated code must be thoroughly reviewed by the contributor before submitting a PR. You are responsible for understanding and validating every line of code you submit. If we detect that contributors are simply throwing AI-generated code over the wall without proper review, they will be blocked from the repository.
### Getting Started
1. Install OpenCode (see [opencode.ai](https://opencode.ai) for other install methods):
```bash
curl -fsSL https://opencode.ai/install | bash
```
2. Configure your AI provider (or use Zen for optimized models)
3. Run `opencode` in the project root
### Available Commands
Use these commands in OpenCode by typing the command name:
| Command | Description |
| ------------------------------ | -------------------------------------------------------- |
| `/implement <spec-path>` | Implement a spec from `.agents/plans/` autonomously |
| `/continue <spec-path>` | Continue implementing a spec from a previous session |
| `/interview <file-path>` | Deep-dive interview to flesh out a spec or design |
| `/document <module-path>` | Generate MDX documentation for a module or feature |
| `/commit` | Create a conventional commit for staged changes |
| `/create-plan <slug>` | Create a new plan file in `.agents/plans/` |
| `/create-scratch <slug>` | Create a scratch file for notes in `.agents/scratches/` |
| `/create-justification <slug>` | Create a justification file in `.agents/justifications/` |
### Typical Workflow
1. **Create a plan**: Use `/create-plan my-feature` to draft a spec for a new feature
2. **Flesh out the spec**: Use `/interview .agents/plans/<file>.md` to refine requirements
3. **Implement**: Use `/implement .agents/plans/<file>.md` to build the feature
4. **Continue if needed**: Use `/continue .agents/plans/<file>.md` to pick up where you left off
5. **Commit**: Use `/commit` to create a conventional commit
### Agent Files
The `.agents/` directory stores AI-generated artifacts:
- **`.agents/plans/`** - Feature specs and implementation plans
- **`.agents/scratches/`** - Temporary notes and explorations
- **`.agents/justifications/`** - Decision rationale and technical justifications
These files use a unique ID format (`{word}-{word}-{word}-{slug}.md`) to prevent conflicts.
@@ -5,14 +5,22 @@ description: Learn how to get the coordinates of a field in a document.
## Field Coordinates
Field coordinates represent the position of a field in a document. They are returned in the `pageX` and `pageY` properties of the field.
Field coordinates represent the position of a field in a document. They are returned in the `pageX`, `pageY`, `width` and `height` properties of the field.
To enable field coordinates, you can use the `devmode` query parameter.
```bash
https://app.documenso.com/documents/<document-id>/edit?devmode=true
# Legacy editor
https://app.documenso.com/t/<team-url>/documents/<envelope-id>/legacy_editor?devmode=true
```
You should then see the coordinates on top of each field.
![Field Coordinates Legacy Editor](/developer-mode/field-coordinates-legacy-editor.webp)
![Field Coordinates](/developer-mode/field-coordinates.webp)
```bash
# New editor
https://app.documenso.com/t/<team-url>/documents/<envelope-id>/edit?step=addFields&devmode=true
```
![Field Coordinates New Editor](/developer-mode/field-coordinates-new-editor.webp)
@@ -148,6 +148,7 @@ This method avoids file permission issues by creating the certificate directly i
# Generate certificate inside container using environment variable
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
mkdir -p /app/certs && \
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /tmp/private.key \
-out /tmp/certificate.crt \
Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

@@ -73,7 +73,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
try {
const passkeyRegistrationOptions = await createPasskeyRegistrationOptions();
const registrationResult = await startRegistration(passkeyRegistrationOptions);
const registrationResult = await startRegistration({
optionsJSON: passkeyRegistrationOptions,
});
await createPasskey({
passkeyName,
@@ -82,9 +82,7 @@ export const SignFieldCheckboxDialog = createCallable<
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Sign Checkbox Field</Trans>
</DialogTitle>
<DialogTitle>{fieldMeta.label || <Trans>Select Options</Trans>}</DialogTitle>
<DialogDescription
className={cn('mt-4', {
@@ -143,7 +141,7 @@ export const SignFieldCheckboxDialog = createCallable<
<div className="flex items-center">
<Checkbox
id={`checkbox-value-${index}`}
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
checked={field.value.checked}
onCheckedChange={(checked) => {
field.onChange({
@@ -154,7 +152,7 @@ export const SignFieldCheckboxDialog = createCallable<
/>
<label
className="text-muted-foreground ml-2 w-full text-sm"
className="ml-2 w-full text-sm text-muted-foreground"
htmlFor={`checkbox-value-${index}`}
>
{value.value}
@@ -174,7 +172,7 @@ export const SignFieldCheckboxDialog = createCallable<
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Confirm</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -50,11 +50,11 @@ export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, st
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Email</Trans>
<Trans>Enter Email</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Sign your email into the field</Trans>
<Trans>Please enter your email address</Trans>
</DialogDescription>
</DialogHeader>
@@ -83,7 +83,7 @@ export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, st
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -48,11 +48,11 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Initials</Trans>
<Trans>Enter Initials</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Sign your initials into the field</Trans>
<Trans>Please enter your initials</Trans>
</DialogDescription>
</DialogHeader>
@@ -84,7 +84,7 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -47,11 +47,11 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Name</Trans>
<Trans>Enter Name</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Sign your full name into the field</Trans>
<Trans>Please enter your full name</Trans>
</DialogDescription>
</DialogHeader>
@@ -80,7 +80,7 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -22,7 +22,6 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
@@ -107,12 +106,10 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Number Field</Trans>
</DialogTitle>
<DialogTitle>{fieldMeta.label || <Trans>Enter Number</Trans>}</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Insert a value into the number field</Trans>
<Trans>Please enter a number</Trans>
</DialogDescription>
</DialogHeader>
@@ -127,8 +124,6 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
name="number"
render={({ field, fieldState }) => (
<FormItem>
{fieldMeta.label && <FormLabel>{fieldMeta.label}</FormLabel>}
<FormControl>
<Input
placeholder={fieldMeta.placeholder ?? t`Enter your number here`}
@@ -150,7 +145,7 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -22,7 +22,6 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Textarea } from '@documenso/ui/primitives/textarea';
@@ -52,12 +51,10 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Text Field</Trans>
</DialogTitle>
<DialogTitle>{fieldMeta?.label || <Trans>Enter Text</Trans>}</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Insert a value into the text field</Trans>
<Trans>Please enter a value</Trans>
</DialogDescription>
</DialogHeader>
@@ -72,8 +69,6 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
name="text"
render={({ field, fieldState }) => (
<FormItem>
{fieldMeta?.label && <FormLabel>{fieldMeta?.label}</FormLabel>}
<FormControl>
<Textarea
id="custom-text"
@@ -89,7 +84,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
{fieldMeta?.characterLimit !== undefined &&
fieldMeta?.characterLimit > 0 &&
!fieldState.error && (
<div className="text-muted-foreground text-sm">
<div className="text-sm text-muted-foreground">
<Plural
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
one="# character remaining"
@@ -107,7 +102,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -3,7 +3,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, OrganisationType } from '@prisma/client';
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -17,14 +17,19 @@ import {
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import {
type TDocumentMetaDateFormat,
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
@@ -45,6 +50,10 @@ import {
SelectValue,
} from '@documenso/ui/primitives/select';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
/**
* Can't infer this from the schema since we need to keep the schema inside the component to allow
* it to be dynamic.
@@ -58,6 +67,7 @@ export type TDocumentPreferencesFormSchema = {
includeSigningCertificate: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null;
};
@@ -74,6 +84,7 @@ type SettingsSubset = Pick<
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
>;
@@ -94,6 +105,7 @@ export const DocumentPreferencesForm = ({
const { t } = useLingui();
const { user, organisations } = useSession();
const currentOrganisation = useCurrentOrganisation();
const optionalTeam = useOptionalCurrentTeam();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
@@ -111,6 +123,7 @@ export const DocumentPreferencesForm = ({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
});
@@ -128,6 +141,9 @@ export const DocumentPreferencesForm = ({
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
},
@@ -519,6 +535,94 @@ export const DocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="defaultRecipients"
render={({ field }) => {
const recipients = field.value ?? [];
return (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Recipients</Trans>
</FormLabel>
{canInherit && (
<Select
value={field.value === null ? '-1' : '0'}
onValueChange={(value) => field.onChange(value === '-1' ? null : [])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
<SelectItem value={'0'}>
<Trans>Override organisation settings</Trans>
</SelectItem>
</SelectContent>
</Select>
)}
{(field.value !== null || !canInherit) && (
<div className="space-y-4">
<DefaultRecipientsMultiSelectCombobox
listValues={recipients}
onChange={field.onChange}
organisationId={!canInherit ? currentOrganisation.id : undefined}
teamId={canInherit ? optionalTeam?.id : undefined}
/>
{recipients.map((recipient, index) => {
return (
<div
key={recipient.email}
className="flex items-center justify-between gap-3 rounded-lg border p-3"
>
<AvatarWithText
avatarFallback={recipientAbbreviation(recipient)}
primaryText={
<span className="text-sm font-medium">
{recipient.name || recipient.email}
</span>
}
secondaryText={
recipient.name ? (
<span className="text-xs text-muted-foreground">
{recipient.email}
</span>
) : undefined
}
className="flex-1"
/>
<div className="flex items-center gap-2">
<RecipientRoleSelect
value={recipient.role}
onValueChange={(role: RecipientRole) => {
field.onChange(
recipients.map((recipient, idx) =>
idx === index ? { ...recipient, role } : recipient,
),
);
}}
/>
</div>
</div>
);
})}
</div>
)}
<FormDescription>
<Trans>Recipients that will be automatically added to new documents.</Trans>
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="delegateDocumentOwnership"
+1 -1
View File
@@ -171,7 +171,7 @@ export const SignInForm = ({
const { options, sessionId } = await createPasskeySigninOptions();
const credential = await startAuthentication(options);
const credential = await startAuthentication({ optionsJSON: options });
await authClient.passkey.signIn({
credential: JSON.stringify(credential),
+4 -4
View File
@@ -212,12 +212,12 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
/>
<div>
<FormLabel className="text-muted-foreground mt-2">
<FormLabel className="mt-2 text-muted-foreground">
<Trans>Never expire</Trans>
</FormLabel>
<div className="block md:py-1.5">
<Switch
className="bg-background mt-2"
className="mt-2 bg-background"
checked={noExpirationDate}
onCheckedChange={setNoExpirationDate}
/>
@@ -254,14 +254,14 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
>
<Card gradient>
<CardContent className="p-4">
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-sm text-muted-foreground">
<Trans>
Your token was created successfully! Make sure to copy it because you won't be
able to see it again!
</Trans>
</p>
<p className="bg-muted-foreground/10 my-4 rounded-md px-2.5 py-1 font-mono text-sm">
<p className="my-4 rounded-md bg-muted-foreground/10 px-2.5 py-1 font-mono text-sm">
{newlyCreatedToken.token}
</p>
@@ -0,0 +1,90 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { RecipientRole } from '@prisma/client';
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
type DefaultRecipientsMultiSelectComboboxProps = {
listValues: TDefaultRecipient[];
onChange: (_values: TDefaultRecipient[]) => void;
teamId?: number;
organisationId?: string;
};
export const DefaultRecipientsMultiSelectCombobox = ({
listValues,
onChange,
teamId,
organisationId,
}: DefaultRecipientsMultiSelectComboboxProps) => {
const { _ } = useLingui();
const { data: organisationData, isLoading: isLoadingOrganisation } =
trpc.organisation.member.find.useQuery(
{
organisationId: organisationId!,
query: '',
page: 1,
perPage: 100,
},
{
enabled: !!organisationId,
},
);
const { data: teamData, isLoading: isLoadingTeam } = trpc.team.member.find.useQuery(
{
teamId: teamId!,
query: '',
page: 1,
perPage: 100,
},
{
enabled: !!teamId,
},
);
const members = organisationId ? organisationData?.data : teamData?.data;
const isLoading = organisationId ? isLoadingOrganisation : isLoadingTeam;
const options = members?.map((member) => ({
value: member.email,
label: member.name ? `${member.name} (${member.email})` : member.email,
}));
const value = listValues.map((recipient) => ({
value: recipient.email,
label: recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email,
}));
const onSelectionChange = (selected: Option[]) => {
const updatedRecipients = selected.map((option) => {
const existingRecipient = listValues.find((r) => r.email === option.value);
const member = members?.find((m) => m.email === option.value);
return {
email: option.value,
name: member?.name || option.value,
role: existingRecipient?.role ?? RecipientRole.CC,
};
});
onChange(updatedRecipients);
};
return (
<MultiSelect
commandProps={{ label: _(msg`Select recipients`) }}
options={options}
value={value}
onChange={onSelectionChange}
placeholder={_(msg`Select recipients`)}
hideClearAllButton
hidePlaceholderWhenSelected
loadingIndicator={isLoading ? <p className="text-center text-sm">Loading...</p> : undefined}
emptyIndicator={<p className="text-center text-sm">No members found</p>}
/>
);
};
@@ -1,6 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
@@ -57,8 +55,6 @@ export const DirectTemplateConfigureForm = ({
initialEmail,
onSubmit,
}: DirectTemplateConfigureFormProps) => {
const { _ } = useLingui();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
@@ -77,17 +73,7 @@ export const DirectTemplateConfigureForm = ({
});
const form = useForm<TDirectTemplateConfigureFormSchema>({
resolver: zodResolver(
ZDirectTemplateConfigureFormSchema.superRefine((items, ctx) => {
if (template.recipients.map((recipient) => recipient.email).includes(items.email)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: _(msg`Email cannot already exist in the template`),
path: ['email'],
});
}
}),
),
resolver: zodResolver(ZDirectTemplateConfigureFormSchema),
defaultValues: {
email: initialEmail || '',
},
@@ -138,7 +124,7 @@ export const DirectTemplateConfigureForm = ({
</FormControl>
{!fieldState.error && (
<p className="text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground">
<Trans>Enter your email address to receive the completed document.</Trans>
</p>
)}
@@ -90,7 +90,7 @@ export const DocumentSigningAuthPasskey = ({
preferredPasskeyId: passkeyId,
});
const authenticationResponse = await startAuthentication(options);
const authenticationResponse = await startAuthentication({ optionsJSON: options });
await onReauthFormSubmit({
type: DocumentAuth.PASSKEY,
@@ -146,7 +146,7 @@ export const DocumentSigningAuthPasskey = ({
if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) {
return (
<div className="flex h-28 items-center justify-center">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
@@ -35,7 +35,7 @@ export const DocumentSigningMobileWidget = () => {
return (
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
<div className="pointer-events-auto w-full max-w-[760px]">
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
<div className="overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
{/* Main Header Bar */}
<div className="flex items-center justify-between gap-4 p-4">
<div className="flex-1">
@@ -48,15 +48,15 @@ export const DocumentSigningMobileWidget = () => {
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<LucideChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
<LucideChevronDown className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
) : (
<LucideChevronUp className="text-muted-foreground h-5 w-5 flex-shrink-0" />
<LucideChevronUp className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
)}
</Button>
)}
<div>
<h2 className="text-foreground text-lg font-semibold">
<h2 className="text-lg font-semibold text-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
@@ -65,7 +65,7 @@ export const DocumentSigningMobileWidget = () => {
.otherwise(() => null)}
</h2>
<p className="text-muted-foreground -mt-0.5 text-sm">
<p className="-mt-0.5 text-sm text-muted-foreground">
{recipientFieldsRemaining.length === 0 ? (
match(recipient.role)
.with(RecipientRole.VIEWER, () => (
@@ -102,11 +102,11 @@ export const DocumentSigningMobileWidget = () => {
{recipient.role !== RecipientRole.VIEWER &&
recipient.role !== RecipientRole.ASSISTANT && (
<div className="px-4 pb-3">
<div className="bg-muted relative h-[4px] rounded-md">
<div className="relative h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-signing-mobile-widget-progress-bar"
className="bg-primary absolute inset-y-0 left-0"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
@@ -117,11 +117,11 @@ export const DocumentSigningMobileWidget = () => {
{/* Expandable Content */}
{isExpanded && (
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
<div className="border-t border-border p-4 duration-200 animate-in slide-in-from-bottom-2">
<EnvelopeSignerForm />
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground mt-2 inline-block rounded px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:hidden">
<div className="mt-2 inline-block rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:hidden">
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
@@ -22,7 +22,7 @@ export const DocumentPageViewRecentActivity = ({
documentId,
userId,
}: DocumentPageViewRecentActivityProps) => {
const { _ } = useLingui();
const { _, i18n } = useLingui();
const {
data,
@@ -48,9 +48,9 @@ export const DocumentPageViewRecentActivity = ({
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="text-foreground font-medium">
<h1 className="font-medium text-foreground">
<Trans>Recent activity</Trans>
</h1>
@@ -59,18 +59,18 @@ export const DocumentPageViewRecentActivity = ({
{isLoading && (
<div className="flex h-full items-center justify-center py-16">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-foreground/80 text-sm">
<p className="text-sm text-foreground/80">
<Trans>Unable to load document history</Trans>
</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
className="mt-2 text-sm text-foreground/70 hover:text-muted-foreground"
>
<Trans>Click here to retry</Trans>
</button>
@@ -83,16 +83,16 @@ export const DocumentPageViewRecentActivity = ({
{hasNextPage && (
<li className="relative flex gap-x-4">
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
<div className="bg-border w-px" />
<div className="w-px bg-border" />
</div>
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget">
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<button
onClick={async () => fetchNextPage()}
className="text-foreground/70 hover:text-muted-foreground text-xs"
className="text-xs text-foreground/70 hover:text-muted-foreground"
>
{isFetchingNextPage ? _(msg`Loading...`) : _(msg`Load older activity`)}
</button>
@@ -101,7 +101,7 @@ export const DocumentPageViewRecentActivity = ({
{documentAuditLogs.length === 0 && (
<div className="flex items-center justify-center py-4">
<p className="text-muted-foreground/70 text-sm">
<p className="text-sm text-muted-foreground/70">
<Trans>No recent activity</Trans>
</p>
</div>
@@ -115,44 +115,44 @@ export const DocumentPageViewRecentActivity = ({
'absolute left-0 top-0 flex w-6 justify-center',
)}
>
<div className="bg-border w-px" />
<div className="w-px bg-border" />
</div>
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget text-foreground/40">
{match(auditLog.type)
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<CheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<MailOpen className="h-3 w-3" aria-hidden="true" />
</div>
))
.otherwise(() => (
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
))}
</div>
<p
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
title={formatDocumentAuditLogAction(_, auditLog, userId).description}
className="flex-auto truncate py-0.5 text-xs leading-5 text-muted-foreground dark:text-muted-foreground/70"
title={formatDocumentAuditLogAction(i18n, auditLog, userId).description}
>
{formatDocumentAuditLogAction(_, auditLog, userId).description}
{formatDocumentAuditLogAction(i18n, auditLog, userId).description}
</p>
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
<time className="flex-none py-0.5 text-xs leading-5 text-muted-foreground dark:text-muted-foreground/70">
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
</time>
</li>
@@ -339,9 +339,11 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */}
<div className="bg-accent/20 flex w-80 flex-col border-r">
<div className="flex w-80 flex-col border-r bg-accent/20">
<DialogHeader className="p-6 pb-4">
<DialogTitle>Document Settings</DialogTitle>
<DialogTitle>
<Trans>Document Settings</Trans>
</DialogTitle>
</DialogHeader>
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
@@ -390,7 +392,7 @@ export const EnvelopeEditorSettingsDialog = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
Controls the language for the document, including the language
to be used for email notifications, and the final certificate
@@ -441,7 +443,7 @@ export const EnvelopeEditorSettingsDialog = ({
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
@@ -518,7 +520,7 @@ export const EnvelopeEditorSettingsDialog = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add an external ID to the document. This can be used to identify
the document in external systems.
@@ -548,7 +550,7 @@ export const EnvelopeEditorSettingsDialog = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
@@ -576,7 +578,7 @@ export const EnvelopeEditorSettingsDialog = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
@@ -735,14 +737,14 @@ export const EnvelopeEditorSettingsDialog = ({
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground p-4">
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="bg-background h-16 resize-none" {...field} />
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
@@ -306,7 +306,7 @@ export const EnvelopeEditorUploadPage = () => {
ref={provided.innerRef}
{...provided.draggableProps}
style={provided.draggableProps.style}
className={`bg-accent/50 flex items-center justify-between rounded-lg p-3 transition-shadow ${
className={`flex items-center justify-between rounded-lg bg-accent/50 p-3 transition-shadow ${
snapshot.isDragging ? 'shadow-md' : ''
}`}
>
@@ -332,7 +332,7 @@ export const EnvelopeEditorUploadPage = () => {
<p className="text-sm font-medium">{localFile.title}</p>
)}
<div className="text-muted-foreground text-xs">
<div className="text-xs text-muted-foreground">
{localFile.isUploading ? (
<Trans>Uploading</Trans>
) : localFile.isError ? (
@@ -345,13 +345,13 @@ export const EnvelopeEditorUploadPage = () => {
<div className="flex items-center space-x-2">
{localFile.isUploading && (
<div className="flex h-6 w-10 items-center justify-center">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{localFile.isError && (
<div className="flex h-6 w-10 items-center justify-center">
<FileWarningIcon className="text-destructive h-4 w-4" />
<FileWarningIcon className="h-4 w-4 text-destructive" />
</div>
)}
@@ -28,36 +28,40 @@ export const EnvelopeSignerHeader = () => {
const { envelopeData, envelope, recipientFieldsRemaining, recipient } =
useRequiredEnvelopeSigningContext();
const isEmbedSigning = useEmbedSigningContext() !== null;
return (
<nav className="embed--DocumentWidgetHeader bg-background border-border max-w-screen flex flex-row justify-between border-b px-4 py-3 md:px-6">
<nav className="embed--DocumentWidgetHeader max-w-screen flex flex-row justify-between border-b border-border bg-background px-4 py-3 md:px-6">
{/* Left side - Logo and title */}
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
<Link to="/" className="flex-shrink-0">
{envelopeData.settings.brandingEnabled && envelopeData.settings.brandingLogo ? (
<img
src={`/api/branding/logo/team/${envelope.teamId}`}
alt={`${envelope.team.name}'s Logo`}
className="h-6 w-auto"
/>
) : (
<>
<BrandingLogo className="hidden h-6 w-auto md:block" />
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
</>
)}
</Link>
{!isEmbedSigning && (
<Link to="/" className="flex-shrink-0">
{envelopeData.settings.brandingEnabled && envelopeData.settings.brandingLogo ? (
<img
src={`/api/branding/logo/team/${envelope.teamId}`}
alt={`${envelope.team.name}'s Logo`}
className="h-6 w-auto"
/>
) : (
<>
<BrandingLogo className="hidden h-6 w-auto md:block" />
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
</>
)}
</Link>
)}
<h1
title={envelope.title}
className="text-foreground min-w-0 truncate text-base font-semibold md:hidden"
className="min-w-0 truncate text-base font-semibold text-foreground md:hidden"
>
{envelope.title}
</h1>
<Separator orientation="vertical" className="hidden h-6 md:block" />
{!isEmbedSigning && <Separator orientation="vertical" className="hidden h-6 md:block" />}
<div className="hidden items-center space-x-2 md:flex">
<h1 className="text-foreground whitespace-nowrap text-sm font-medium">
<h1 className="whitespace-nowrap text-sm font-medium text-foreground">
{envelope.title}
</h1>
@@ -74,7 +78,7 @@ export const EnvelopeSignerHeader = () => {
{/* Right side - Desktop content */}
<div className="hidden items-center space-x-2 lg:flex">
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
<p className="mr-2 flex-shrink-0 text-sm text-muted-foreground">
<Plural
one="1 Field Remaining"
other="# Fields Remaining"
@@ -7,7 +7,7 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton';
export default function DocumentEditSkeleton() {
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link to="/" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
<Link to="/" className="flex grow-0 items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
@@ -21,17 +21,17 @@ export default function DocumentEditSkeleton() {
</div>
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
<div className="col-span-12 rounded-xl border-2 border-border bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7 dark:bg-background">
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<Loader className="h-12 w-12 animate-spin text-documenso" />
<p className="text-muted-foreground mt-4">
<p className="mt-4 text-muted-foreground">
<Trans>Loading document...</Trans>
</p>
</div>
</div>
<div className="bg-background border-border col-span-12 rounded-xl border-2 before:rounded-xl lg:col-span-6 xl:col-span-5" />
<div className="col-span-12 rounded-xl border-2 border-border bg-background before:rounded-xl lg:col-span-6 xl:col-span-5" />
</div>
</div>
);
@@ -0,0 +1,210 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { useSearchParams } from 'react-router';
import { UAParser } from 'ua-parser-js';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminDocumentLogsTableProps = {
envelopeId: string;
};
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
export const AdminDocumentLogsTable = ({ envelopeId }: AdminDocumentLogsTableProps) => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [selectedAuditLog, setSelectedAuditLog] = useState<TDocumentAuditLog | null>(null);
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.admin.document.findAuditLogs.useQuery(
{
envelopeId,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
placeholderData: (previousData) => previousData,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
const parser = new UAParser();
return [
{
header: _(msg`Time`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt, dateFormat),
},
{
header: _(msg`User`),
accessorKey: 'name',
cell: ({ row }) =>
row.original.name || row.original.email ? (
<div>
{row.original.name && (
<p className="truncate" title={row.original.name}>
{row.original.name}
</p>
)}
{row.original.email && (
<p className="truncate" title={row.original.email}>
{row.original.email}
</p>
)}
</div>
) : (
<p>N/A</p>
),
},
{
header: _(msg`Action`),
accessorKey: 'type',
cell: ({ row }) => (
<span>{formatDocumentAuditLogAction(i18n, row.original).description}</span>
),
},
{
header: _(msg`IP Address`),
accessorKey: 'ipAddress',
},
{
header: _(msg`Browser`),
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => (
<Button variant="link" size="sm" onClick={() => setSelectedAuditLog(row.original)}>
<Trans>View JSON</Trans>
</Button>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell className="w-1/2 py-4 pr-4">
<div className="ml-2 flex flex-grow flex-col">
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-8 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
<Dialog open={selectedAuditLog !== null} onOpenChange={() => setSelectedAuditLog(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
<Trans>Audit Log Details</Trans>
</DialogTitle>
</DialogHeader>
{selectedAuditLog && (
<div className="group relative">
<div className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">
<CopyTextButton
value={JSON.stringify(selectedAuditLog, null, 2)}
onCopySuccess={() => toast({ title: _(msg`Copied to clipboard`) })}
/>
</div>
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap break-all rounded-lg border border-border bg-muted/50 p-4 font-mono text-xs leading-relaxed text-foreground">
{JSON.stringify(selectedAuditLog, null, 2)}
</pre>
</div>
)}
</DialogContent>
</Dialog>
</>
);
};
@@ -95,7 +95,7 @@ export const DocumentLogsTable = ({ documentId, userId }: DocumentLogsTableProps
header: _(msg`Action`),
accessorKey: 'type',
cell: ({ row }) => (
<span>{formatDocumentAuditLogAction(_, row.original, userId).description}</span>
<span>{formatDocumentAuditLogAction(i18n, row.original, userId).description}</span>
),
},
{
@@ -65,7 +65,7 @@ const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UA
};
export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
const { _ } = useLingui();
const { _, i18n } = useLingui();
const parser = new UAParser();
@@ -73,7 +73,7 @@ export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
<div className="space-y-4">
{logs.map((log, index) => {
parser.setUA(log.userAgent || '');
const formattedAction = formatDocumentAuditLogAction(_, log);
const formattedAction = formatDocumentAuditLogAction(i18n, log);
const userAgentInfo = parser.getResult();
return (
@@ -95,17 +95,17 @@ export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
/>
<div>
<div className="text-muted-foreground text-sm font-medium uppercase tracking-wide print:text-[8pt]">
<div className="text-sm font-medium uppercase tracking-wide text-muted-foreground print:text-[8pt]">
{log.type.replace(/_/g, ' ')}
</div>
<div className="text-foreground text-sm font-medium print:text-[8pt]">
<div className="text-sm font-medium text-foreground print:text-[8pt]">
{formattedAction.description}
</div>
</div>
</div>
<div className="text-muted-foreground text-sm print:text-[8pt]">
<div className="text-sm text-muted-foreground print:text-[8pt]">
{DateTime.fromJSDate(log.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toLocaleString(dateFormat)}
@@ -117,27 +117,27 @@ export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
{/* Details Section - Two column layout */}
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-xs print:text-[6pt]">
<div>
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
<div className="font-medium uppercase tracking-wide text-muted-foreground/70">
{_(msg`User`)}
</div>
<div className="text-foreground mt-1 font-mono">{log.email || 'N/A'}</div>
<div className="mt-1 font-mono text-foreground">{log.email || 'N/A'}</div>
</div>
<div className="text-right">
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
<div className="font-medium uppercase tracking-wide text-muted-foreground/70">
{_(msg`IP Address`)}
</div>
<div className="text-foreground mt-1 font-mono">{log.ipAddress || 'N/A'}</div>
<div className="mt-1 font-mono text-foreground">{log.ipAddress || 'N/A'}</div>
</div>
<div className="col-span-2">
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
<div className="font-medium uppercase tracking-wide text-muted-foreground/70">
{_(msg`User Agent`)}
</div>
<div className="text-foreground mt-1">
<div className="mt-1 text-foreground">
{_(formatUserAgent(log.userAgent, userAgentInfo))}
</div>
</div>
@@ -6,6 +6,7 @@ import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { unsafeGetEntireEnvelope } from '@documenso/lib/server-only/admin/get-entire-document';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import {
Accordion,
@@ -26,6 +27,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog';
import { DocumentStatus } from '~/components/general/document/document-status';
import { AdminDocumentJobsTable } from '~/components/tables/admin-document-jobs-table';
import { AdminDocumentLogsTable } from '~/components/tables/admin-document-logs-table';
import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table';
import type { Route } from './+types/documents.$id';
@@ -87,6 +89,10 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
</div>
<div className="mt-4 text-sm text-muted-foreground">
<div>
<Trans>Document ID</Trans>: {mapSecondaryIdToDocumentId(envelope.secondaryId)}
</div>
<div>
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
</div>
@@ -156,6 +162,9 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<Badge size="small" variant="neutral">
{recipient.email}
</Badge>
<Badge size="small" variant="secondary">
{recipient.role}
</Badge>
</div>
</AccordionTrigger>
@@ -175,6 +184,22 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<hr className="my-4" />
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="audit-logs" className="rounded-lg border">
<AccordionTrigger className="px-4">
<h2 className="text-lg font-semibold">
<Trans>Audit Logs</Trans>
</h2>
</AccordionTrigger>
<AccordionContent className="border-t px-4 pt-4">
<AdminDocumentLogsTable envelopeId={envelope.id} />
</AccordionContent>
</AccordionItem>
</Accordion>
<hr className="my-4" />
{envelope && <AdminDocumentDeleteDialog envelopeId={envelope.id} />}
</div>
);
@@ -40,8 +40,8 @@ export default function OrganisationSettingsTeamsPage() {
if (organisation.teams.length === 0) {
return (
<div className="flex flex-col items-center justify-center px-4 py-16">
<div className="bg-muted mb-6 flex h-20 w-20 items-center justify-center rounded-full">
<UsersIcon className="text-muted-foreground h-10 w-10" />
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-muted">
<UsersIcon className="h-10 w-10 text-muted-foreground" />
</div>
<h2 className="mb-2 text-xl font-semibold">
@@ -53,7 +53,7 @@ export default function OrganisationSettingsTeamsPage() {
organisation.currentOrganisationRole,
) ? (
<>
<p className="text-muted-foreground mb-8 max-w-md text-center text-sm">
<p className="mb-8 max-w-md text-center text-sm text-muted-foreground">
<Trans>
Teams help you organise your work and collaborate with others. Create your first
team to get started.
@@ -73,21 +73,21 @@ export default function OrganisationSettingsTeamsPage() {
<h3 className="mb-2 font-medium">
<Trans>What you can do with teams:</Trans>
</h3>
<ul className="text-muted-foreground space-y-2 text-sm">
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex flex-row items-center gap-2">
<div className="bg-muted mt-0.5 flex h-5 w-5 items-center justify-center rounded-full font-bold">
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted font-bold">
<span className="text-xs">1</span>
</div>
<Trans>Organize your documents and templates</Trans>
</li>
<li className="flex flex-row items-center gap-2">
<div className="bg-muted mt-0.5 flex h-5 w-5 items-center justify-center rounded-full font-bold">
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted font-bold">
<span className="text-xs">2</span>
</div>
<Trans>Invite team members to collaborate</Trans>
</li>
<li className="flex flex-row items-center gap-2">
<div className="bg-muted mt-0.5 flex h-5 w-5 items-center justify-center rounded-full font-bold">
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted font-bold">
<span className="text-xs">3</span>
</div>
<Trans>Manage permissions and access controls</Trans>
@@ -96,7 +96,7 @@ export default function OrganisationSettingsTeamsPage() {
</div>
</>
) : (
<p className="text-muted-foreground mb-8 max-w-md text-center text-sm">
<p className="mb-8 max-w-md text-center text-sm text-muted-foreground">
<Trans>
You currently have no access to any teams within this organisation. Please contact
your organisation to request access.
@@ -114,7 +114,7 @@ export default function OrganisationSettingsTeamsPage() {
<h1 className="text-2xl font-semibold tracking-tight">
<Trans>{organisation.name} Teams</Trans>
</h1>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Select a team to view its dashboard</Trans>
</p>
</div>
@@ -129,7 +129,7 @@ export default function OrganisationSettingsTeamsPage() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{organisation.teams.map((team) => (
<Link to={`/t/${team.url}`} key={team.id}>
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
<Card className="h-full border border-border transition-all hover:bg-muted/50">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 border-2 border-solid">
@@ -145,7 +145,7 @@ export default function OrganisationSettingsTeamsPage() {
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{team.name}</h3>
<div className="text-muted-foreground truncate text-xs">
<div className="truncate text-xs text-muted-foreground">
{formatTeamUrl(team.url)}
</div>
</div>
@@ -154,11 +154,11 @@ export default function OrganisationSettingsTeamsPage() {
</div>
<div className="mt-2 flex items-center gap-4">
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarIcon className="h-3 w-3" />
{i18n.date(team.createdAt, { dateStyle: 'short' })}
</div>
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<UserIcon className="h-3 w-3" />
<span>{t(TEAM_MEMBER_ROLE_MAP[team.currentTeamRole])}</span>
</div>
@@ -79,7 +79,7 @@ export default function OrganisationSettingsBrandingPage() {
if (isLoadingOrganisation || !organisationWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
@@ -57,6 +57,7 @@ export default function OrganisationSettingsDocumentPage() {
includeSigningCertificate,
includeAuditLog,
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
aiFeaturesEnabled,
} = data;
@@ -83,6 +84,7 @@ export default function OrganisationSettingsDocumentPage() {
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
defaultRecipients,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
@@ -100,7 +100,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<DocumentRecipientLinkCopyDialog recipients={envelope.recipients} />
)}
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<Link to={documentRootPath} className="flex items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
@@ -84,7 +84,7 @@ export default function DocumentEditPage() {
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<Link to={documentRootPath} className="flex items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
@@ -106,7 +106,7 @@ export default function DocumentEditPage() {
/>
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<div className="flex items-center text-muted-foreground">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
@@ -134,7 +134,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link
to={`${documentRootPath}/${document.envelopeId}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
className="flex items-center text-documenso-700 hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Document</Trans>
@@ -50,6 +50,7 @@ export default function TeamsSettingsPage() {
includeSigningCertificate,
includeAuditLog,
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
aiFeaturesEnabled,
} = data;
@@ -64,6 +65,7 @@ export default function TeamsSettingsPage() {
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
defaultRecipients,
aiFeaturesEnabled,
...(signatureTypes.length === 0
? {
@@ -117,7 +117,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link to={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<Link to={templateRootPath} className="flex items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Templates</Trans>
</Link>
@@ -61,7 +61,7 @@ export default function TemplateEditPage() {
<div>
<Link
to={`${templateRootPath}/${template.envelopeId}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
className="flex items-center text-documenso-700 hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Template</Trans>
@@ -149,7 +149,7 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
<span className="mt-1 block">
{DateTime.fromJSDate(document.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-mm-dd hh:mm:ss a (ZZZZ)')}
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')}
</span>
</p>
@@ -159,7 +159,7 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
<span className="mt-1 block">
{DateTime.fromJSDate(document.updatedAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-mm-dd hh:mm:ss a (ZZZZ)')}
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')}
</span>
</p>
@@ -355,16 +355,16 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
</Trans>
</h2>
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<p className="mt-2.5 max-w-[60ch] text-center text-sm font-medium text-muted-foreground/60 md:text-base">
<Trans>This document has been cancelled by the owner.</Trans>
</p>
{user ? (
<Link to="/" className="text-documenso-700 hover:text-documenso-600 mt-36">
<Link to="/" className="mt-36 text-documenso-700 hover:text-documenso-600">
<Trans>Go Back Home</Trans>
</Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
<p className="mt-36 text-sm text-muted-foreground/60">
<Trans>
Want to send slick signing links like this one?{' '}
<Link
@@ -455,16 +455,16 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
</Trans>
</h2>
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<p className="mt-2.5 max-w-[60ch] text-center text-sm font-medium text-muted-foreground/60 md:text-base">
<Trans>This document has been cancelled by the owner.</Trans>
</p>
{user ? (
<Link to="/" className="text-documenso-700 hover:text-documenso-600 mt-36">
<Link to="/" className="mt-36 text-documenso-700 hover:text-documenso-600">
<Trans>Go Back Home</Trans>
</Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
<p className="mt-36 text-sm text-muted-foreground/60">
<Trans>
Want to send slick signing links like this one?{' '}
<Link
@@ -91,25 +91,25 @@ export default function RejectedSigningPage({ loaderData }: Route.ComponentProps
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="text-destructive h-10 w-10" />
<XCircle className="h-10 w-10 text-destructive" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
</div>
<div className="text-destructive mt-4 flex items-center text-center text-sm">
<div className="mt-4 flex items-center text-center text-sm text-destructive">
<Trans>You have rejected this document</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<p className="mt-6 max-w-[60ch] text-center text-sm text-muted-foreground">
<Trans>
The document owner has been notified of your decision. They may contact you with further
instructions if necessary.
</Trans>
</p>
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<p className="mt-2 max-w-[60ch] text-center text-sm text-muted-foreground">
<Trans>No further action is required from you at this time.</Trans>
</p>
@@ -78,14 +78,14 @@ export default function WaitingForTurnToSignPage({ loaderData }: Route.Component
<Trans>Waiting for Your Turn</Trans>
</h2>
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-sm text-muted-foreground">
<Trans>
It's currently not your turn to sign. You will receive an email with instructions once
it's your turn to sign the document.
</Trans>
</p>
<p className="text-muted-foreground mt-4 text-sm">
<p className="mt-4 text-sm text-muted-foreground">
<Trans>Please check your email for updates.</Trans>
</p>
+9 -10
View File
@@ -36,16 +36,16 @@
"@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.9.6",
"@react-router/serve": "^7.9.6",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
"autoprefixer": "^10.4.22",
"colord": "^2.9.3",
"content-disposition": "^1.0.1",
"framer-motion": "^12.23.24",
"hono": "4.10.6",
"hono": "4.11.4",
"hono-rate-limiter": "^0.4.2",
"hono-react-router-adapter": "^0.6.5",
"input-otp": "^1.4.2",
@@ -65,7 +65,7 @@
"react-hotkeys-hook": "^4.6.2",
"react-icons": "^5.5.0",
"react-rnd": "^10.5.2",
"react-router": "^7.9.6",
"react-router": "^7.12.0",
"recharts": "^2.15.4",
"remeda": "^2.32.0",
"remix-themes": "^2.0.4",
@@ -81,14 +81,13 @@
"@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.9.6",
"@react-router/remix-routes-option-adapter": "^7.9.6",
"@react-router/dev": "^7.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-typescript": "^12.3.0",
"@simplewebauthn/types": "^9.0.1",
"@types/content-disposition": "^0.5.9",
"@types/formidable": "^3.4.6",
"@types/luxon": "^3.7.1",
@@ -107,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.3.2"
"version": "2.4.0"
}
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
###########################
# BASE CONTAINER #
###########################
FROM node:22-alpine3.20 AS base
FROM node:22-alpine3.22 AS base
RUN apk add --no-cache openssl
RUN apk add --no-cache font-freefont
+299 -459
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.3.2",
"version": "2.4.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -64,7 +64,7 @@
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^7.0.10",
"pdfjs-dist": "5.4.449",
"pdfjs-dist": "5.4.296",
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
@@ -99,8 +99,8 @@
"zod": "^3.25.76"
},
"overrides": {
"pdfjs-dist": "5.4.449",
"pdfjs-dist": "5.4.296",
"typescript": "5.6.2",
"zod": "^3.25.76"
}
}
}
+3 -51
View File
@@ -41,7 +41,7 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import {
getPresignGetUrl,
getPresignPostUrl,
@@ -822,7 +822,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
formValues: body.formValues,
});
const newDocumentData = await putPdfFileServerSide({
const newDocumentData = await putNormalizedPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@@ -911,61 +911,13 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
title: body.title,
...body.meta,
},
formValues: body.formValues,
requestMetadata: metadata,
});
} catch (err) {
return AppError.toRestAPIError(err);
}
if (envelope.envelopeItems.length !== 1) {
throw new Error('API V1 does not support envelopes');
}
const firstEnvelopeDocumentData = await prisma.envelopeItem.findFirstOrThrow({
where: {
envelopeId: envelope.id,
},
include: {
documentData: true,
},
});
if (body.formValues) {
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title}.pdf`;
const pdf = await getFileServerSide(firstEnvelopeDocumentData.documentData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(pdf),
formValues: body.formValues,
});
const newDocumentData = await putPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
await prisma.envelope.update({
where: {
id: envelope.id,
},
data: {
formValues: body.formValues,
envelopeItems: {
update: {
where: {
id: firstEnvelopeDocumentData.id,
},
data: {
documentDataId: newDocumentData.id,
},
},
},
},
});
}
if (body.authOptions) {
await prisma.envelope.update({
where: {
@@ -0,0 +1,848 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
// Form field names in the test PDF
const FORM_FIELDS = {
TEXT_FIELD: 'test_text_field',
COMPANY_NAME: 'company_name',
CHECKBOX: 'accept_terms',
DROPDOWN: 'country',
} as const;
// Test values to insert into form fields
const TEST_FORM_VALUES = {
[FORM_FIELDS.TEXT_FIELD]: 'Hello World',
[FORM_FIELDS.COMPANY_NAME]: 'Documenso Inc.',
[FORM_FIELDS.CHECKBOX]: true,
[FORM_FIELDS.DROPDOWN]: 'Germany',
};
/**
* Helper to check if a PDF has interactive form fields.
* Returns true if the PDF has form fields, false if they've been flattened.
*/
async function pdfHasFormFields(pdfBuffer: Uint8Array): Promise<boolean> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const form = pdfDoc.getForm();
const fields = form.getFields();
return fields.length > 0;
}
/**
* Helper to get form field names from a PDF.
*/
async function getPdfFormFieldNames(pdfBuffer: Uint8Array): Promise<string[]> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const form = pdfDoc.getForm();
const fields = form.getFields();
return fields.map((field) => field.getName());
}
/**
* Helper to get the value of a text field in a PDF.
*/
async function getPdfTextFieldValue(
pdfBuffer: Uint8Array,
fieldName: string,
): Promise<string | undefined> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const form = pdfDoc.getForm();
try {
const textField = form.getTextField(fieldName);
return textField.getText() ?? '';
} catch {
return undefined;
}
}
test.describe.configure({
mode: 'parallel',
});
test.describe('Form Flattening', () => {
const formFieldsPdf = fs.readFileSync(
path.join(__dirname, '../../../../assets/form-fields-test.pdf'),
);
test.describe('Envelope Creation (DOCUMENT type)', () => {
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Form Values',
formValues: TEST_FORM_VALUES,
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
// Verify the envelope was created with the correct formValues
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
expect(envelope.type).toBe(EnvelopeType.DOCUMENT);
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document without Form Values',
// No formValues - but form should still be flattened for DOCUMENT type
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
});
test.describe('Template Creation (TEMPLATE type)', () => {
test('should NOT flatten form fields when creating a TEMPLATE envelope', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template with Form Fields',
// Note: formValues can be set but form should NOT be flattened for templates
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
expect(envelope.type).toBe(EnvelopeType.TEMPLATE);
// Get the PDF and verify form fields are NOT flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(true);
// Verify the specific form fields still exist
const fieldNames = await getPdfFormFieldNames(pdfBuffer);
expect(fieldNames).toContain(FORM_FIELDS.TEXT_FIELD);
expect(fieldNames).toContain(FORM_FIELDS.COMPANY_NAME);
expect(fieldNames).toContain(FORM_FIELDS.CHECKBOX);
expect(fieldNames).toContain(FORM_FIELDS.DROPDOWN);
});
test('should preserve form fields in template even when formValues are provided', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template with Form Values',
formValues: TEST_FORM_VALUES,
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
// formValues should be stored in the database
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
expect(envelope.type).toBe(EnvelopeType.TEMPLATE);
// But the PDF should still have interactive form fields
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe(
TEST_FORM_VALUES[FORM_FIELDS.TEXT_FIELD],
);
});
});
test.describe('Document from Template', () => {
test('should flatten form fields when creating document from template with formValues', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// First, create a template via API
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template for Document Creation',
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
expect(templateRes.ok()).toBeTruthy();
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
// Verify template has form fields
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
const templatePdfBuffer = await getFileServerSide(template.envelopeItems[0].documentData);
expect(await pdfHasFormFields(templatePdfBuffer)).toBe(true);
// Now create a document from the template with formValues
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
formValues: TEST_FORM_VALUES,
},
});
expect(useTemplateRes.ok()).toBeTruthy();
expect(useTemplateRes.status()).toBe(200);
const documentResponse = await useTemplateRes.json();
// Get the created document
const document = await prisma.envelope.findFirstOrThrow({
where: {
id: documentResponse.envelopeId,
},
include: {
envelopeItems: { include: { documentData: true } },
},
});
expect(document.type).toBe(EnvelopeType.DOCUMENT);
// Verify form fields are flattened in the created document
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
const hasFormFields = await pdfHasFormFields(documentPdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should flatten form fields when creating document from template without formValues', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Create a template
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template without Form Values',
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
expect(templateRes.ok()).toBeTruthy();
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
// Create document from template WITHOUT formValues
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
// No formValues provided
},
});
expect(useTemplateRes.ok()).toBeTruthy();
const documentResponse = await useTemplateRes.json();
const document = await prisma.envelope.findFirstOrThrow({
where: { id: documentResponse.envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
},
});
expect(document.type).toBe(EnvelopeType.DOCUMENT);
// Form fields should still be flattened even without formValues
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
const hasFormFields = await pdfHasFormFields(documentPdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should use template formValues when creating document without override', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Create a template with formValues
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template with Default Form Values',
formValues: {
[FORM_FIELDS.TEXT_FIELD]: 'Default Value',
[FORM_FIELDS.COMPANY_NAME]: 'Default Company',
},
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
expect(templateRes.ok()).toBeTruthy();
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
// Verify template stored the formValues
expect(template.formValues).toEqual({
[FORM_FIELDS.TEXT_FIELD]: 'Default Value',
[FORM_FIELDS.COMPANY_NAME]: 'Default Company',
});
// Create document from template without providing new formValues
// The template's formValues should be used
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
// No formValues - should inherit from template
},
});
expect(useTemplateRes.ok()).toBeTruthy();
const documentResponse = await useTemplateRes.json();
const document = await prisma.envelope.findFirstOrThrow({
where: { id: documentResponse.envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// Form fields should be flattened
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
expect(await pdfHasFormFields(documentPdfBuffer)).toBe(false);
});
});
test.describe('Form Values Verification', () => {
test('should correctly insert form values into PDF before flattening', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Create a template first (form fields preserved)
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template for Value Verification',
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
// Verify template PDF still has form fields
const templatePdfBuffer = await getFileServerSide(template.envelopeItems[0].documentData);
expect(await pdfHasFormFields(templatePdfBuffer)).toBe(true);
// Verify we can read a text field value (should be empty initially)
const initialValue = await getPdfTextFieldValue(templatePdfBuffer, FORM_FIELDS.TEXT_FIELD);
expect(initialValue).toBe('');
// Now create a document with form values
const testValues = {
[FORM_FIELDS.TEXT_FIELD]: 'Inserted Text Value',
[FORM_FIELDS.COMPANY_NAME]: 'Test Company Name',
};
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
formValues: testValues,
},
});
expect(useTemplateRes.ok()).toBeTruthy();
const documentResponse = await useTemplateRes.json();
const document = await prisma.envelope.findFirstOrThrow({
where: { id: documentResponse.envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// The form should be flattened, so we can't read form fields
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
expect(await pdfHasFormFields(documentPdfBuffer)).toBe(false);
// The values should have been inserted before flattening
// We can't verify the actual text content easily without visual inspection,
// but we can verify the form fields are gone (flattened)
const fieldNames = await getPdfFormFieldNames(documentPdfBuffer);
expect(fieldNames.length).toBe(0);
});
});
test.describe('Edge Cases', () => {
test('should handle PDF without form fields gracefully', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Use a PDF without form fields
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with No Form Fields',
formValues: {
nonexistent_field: 'Some Value',
},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
// Should succeed even with formValues for non-existent fields
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
},
});
expect(envelope.formValues).toEqual({ nonexistent_field: 'Some Value' });
});
test('should handle empty formValues object', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Empty Form Values',
formValues: {},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// Form should still be flattened for DOCUMENT type
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
test('should handle partial formValues (only some fields)', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Partial Form Values',
formValues: {
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
// Other fields not set
},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// Should store the partial formValues
expect(envelope.formValues).toEqual({
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
});
// Form should still be flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
});
});
@@ -0,0 +1,427 @@
import { expect, test } from '@playwright/test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({ mode: 'parallel' });
/**
* Helper function to set default recipients for a team
*/
const setTeamDefaultRecipients = async (
teamId: number,
defaultRecipients: Array<{ email: string; name: string; role: RecipientRole }>,
) => {
const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({
where: {
team: {
id: teamId,
},
},
});
await prisma.teamGlobalSettings.update({
where: {
id: teamSettings.id,
},
data: {
defaultRecipients,
},
});
};
test.describe('Default Recipients', () => {
test('[DEFAULT_RECIPIENTS]: default recipients are added to documents created via UI', async ({
page,
}) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
// Get a team member to set as default recipient
const teamMembers = await prisma.organisationMember.findMany({
where: {
organisationId: team.organisationId,
userId: {
not: owner.id,
},
},
include: {
user: true,
},
});
const defaultRecipientUser = teamMembers[0].user;
// Set up default recipients for the team
await setTeamDefaultRecipients(team.id, [
{
email: defaultRecipientUser.email,
name: defaultRecipientUser.name || defaultRecipientUser.email,
role: RecipientRole.CC,
},
]);
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/documents`,
});
// Upload document via UI - this triggers document creation with default recipients
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page
.locator('input[type=file]')
.nth(1)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
// Wait to be redirected to the edit page (v2 envelope editor)
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
// Extract document ID from URL
const urlParts = page.url().split('/');
const documentId = urlParts.find((part) => part.startsWith('envelope_'));
// Wait for the Recipients card to be visible (v2 envelope editor)
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await expect(page.getByTestId('signer-email-input').first()).not.toBeEmpty();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add a regular signer using the v2 editor
await page.getByTestId('signer-email-input').last().fill('regular-signer@documenso.com');
await page
.getByPlaceholder(/Recipient/)
.first()
.fill('Regular Signer');
// Wait for autosave to complete
await page.waitForTimeout(3000);
// Verify that default recipient is present in the database
await expect(async () => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: true,
},
});
// Should have 2 recipients: the regular signer + the default recipient
expect(envelope.recipients.length).toBe(2);
const defaultRecipient = envelope.recipients.find(
(r) => r.email.toLowerCase() === defaultRecipientUser.email.toLowerCase(),
);
expect(defaultRecipient).toBeDefined();
expect(defaultRecipient?.role).toBe(RecipientRole.CC);
const regularSigner = envelope.recipients.find(
(r) => r.email === 'regular-signer@documenso.com',
);
expect(regularSigner).toBeDefined();
}).toPass();
});
// TODO: Are we intending to allow default recipients to be removed from a document?
test.skip('[DEFAULT_RECIPIENTS]: default recipients cannot be removed from a document', async ({
page,
}) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
// Get a team member to set as default recipient
const teamMembers = await prisma.organisationMember.findMany({
where: {
organisationId: team.organisationId,
userId: {
not: owner.id,
},
},
include: {
user: true,
},
});
const defaultRecipientUser = teamMembers[0].user;
// Set up default recipients for the team
await setTeamDefaultRecipients(team.id, [
{
email: defaultRecipientUser.email,
name: defaultRecipientUser.name || defaultRecipientUser.email,
role: RecipientRole.CC,
},
]);
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/documents`,
});
// Upload document via UI - this triggers document creation with default recipients
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page
.locator('input[type=file]')
.nth(1)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
// Wait to be redirected to the edit page (v2 envelope editor)
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
// Extract document ID from URL
const urlParts = page.url().split('/');
const documentId = urlParts.find((part) => part.startsWith('envelope_'));
// Replace the default recipient with a regular signer
await page.getByTestId('signer-email-input').first().fill('regular-signer@documenso.com');
await page
.getByPlaceholder(/Recipient/)
.first()
.fill('Regular Signer');
// Wait for autosave to complete
await page.waitForTimeout(3000);
// Wait for recipients to be saved
await expect(async () => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: true,
},
});
expect(envelope.recipients.length).toBe(2);
}).toPass();
// Verify that the default recipient's remove button is disabled
// In the v2 editor, default recipients should have a disabled remove button
// Find the fieldset containing the default recipient's email and check if its remove button is disabled
const defaultRecipientRow = page.locator('fieldset').filter({
hasText: defaultRecipientUser.email,
});
// The default recipient row should exist and have a disabled remove button
await expect(defaultRecipientRow).toHaveCount(1);
const removeButton = defaultRecipientRow.getByTestId('remove-signer-button');
await expect(removeButton).toBeDisabled();
});
test('[DEFAULT_RECIPIENTS]: documents created via API have default recipients', async ({
request,
}) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
// Get a team member to set as default recipient
const teamMembers = await prisma.organisationMember.findMany({
where: {
organisationId: team.organisationId,
userId: {
not: owner.id,
},
},
include: {
user: true,
},
});
const defaultRecipientUser = teamMembers[0].user;
// Set up default recipients for the team
await setTeamDefaultRecipients(team.id, [
{
email: defaultRecipientUser.email,
name: defaultRecipientUser.name || defaultRecipientUser.email,
role: RecipientRole.CC,
},
]);
// Create API token
const { token } = await createApiToken({
userId: owner.id,
teamId: team.id,
tokenName: 'test-token',
expiresIn: null,
});
// Create envelope via API
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Test Document with Default Recipients',
recipients: [
{
email: 'api-recipient@documenso.com',
name: 'API Recipient',
role: RecipientRole.SIGNER,
},
],
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdfData = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
// Verify the envelope has both the API recipient and the default recipient
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: response.id,
},
include: {
recipients: true,
},
});
expect(envelope.recipients.length).toBe(2);
const apiRecipient = envelope.recipients.find((r) => r.email === 'api-recipient@documenso.com');
expect(apiRecipient).toBeDefined();
expect(apiRecipient?.role).toBe(RecipientRole.SIGNER);
const defaultRecipient = envelope.recipients.find(
(r) => r.email.toLowerCase() === defaultRecipientUser.email.toLowerCase(),
);
expect(defaultRecipient).toBeDefined();
expect(defaultRecipient?.role).toBe(RecipientRole.CC);
});
test('[DEFAULT_RECIPIENTS]: documents created from template have default recipients', async ({
page,
}) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
// Get a team member to set as default recipient
const teamMembers = await prisma.organisationMember.findMany({
where: {
organisationId: team.organisationId,
userId: {
not: owner.id,
},
},
include: {
user: true,
},
});
const defaultRecipientUser = teamMembers[0].user;
// Set up default recipients for the team
await setTeamDefaultRecipients(team.id, [
{
email: defaultRecipientUser.email,
name: defaultRecipientUser.name || defaultRecipientUser.email,
role: RecipientRole.CC,
},
]);
// Create a template
const template = await seedBlankTemplate(owner, team.id);
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set template title
await page.getByLabel('Title').fill('Template with Default Recipients');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a template recipient
await page.getByPlaceholder('Email').fill('template-recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Template Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template to create document
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Wait for document to be created
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
const documentId = page.url().split('/').pop();
// Verify the document has both the template recipient and the default recipient
const document = await prisma.envelope.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: true,
},
});
expect(document.recipients.length).toBe(2);
const templateRecipient = document.recipients.find(
(r) => r.email === 'template-recipient@documenso.com',
);
expect(templateRecipient).toBeDefined();
const defaultRecipient = document.recipients.find(
(r) => r.email.toLowerCase() === defaultRecipientUser.email.toLowerCase(),
);
expect(defaultRecipient).toBeDefined();
expect(defaultRecipient?.role).toBe(RecipientRole.CC);
});
});
@@ -505,7 +505,9 @@ test('[TEMPLATE]: should create a document from a template using template docume
});
expect(document.title).toEqual('TEMPLATE_WITH_ORIGINAL_DOC');
expect(firstDocumentData.data).toEqual(templateWithData.envelopeItems[0].documentData.data);
expect(firstDocumentData.initialData).toEqual(
templateWithData.envelopeItems[0].documentData.data,
);
expect(firstDocumentData.initialData).toEqual(
templateWithData.envelopeItems[0].documentData.initialData,
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 163 KiB

+3 -2
View File
@@ -15,11 +15,12 @@
"@hono/standard-validator": "^0.2.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@simplewebauthn/server": "^13.2.2",
"arctic": "^3.7.0",
"hono": "4.10.6",
"hono": "4.11.4",
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
}
}
}
+4 -3
View File
@@ -1,6 +1,7 @@
import { sValidator } from '@hono/standard-validator';
import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { Hono } from 'hono';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -80,9 +81,9 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
expectedChallenge: challengeToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
credential: {
id: isoBase64URL.fromBuffer(passkey.credentialId),
publicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null);
@@ -113,7 +113,7 @@ export const ConfirmTeamEmailTemplate = ({
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={`${baseUrl}/team/verify/email/${token}`}
>
<Trans>Accept</Trans>
+1 -1
View File
@@ -72,7 +72,7 @@ export const ResetPasswordTemplate = ({
<Trans>
Didn't request a password change? We are here to help you secure your account,
just{' '}
<Link className="text-documenso-700 font-normal" href="mailto:hi@documenso.com">
<Link className="font-normal text-documenso-700" href="mailto:hi@documenso.com">
contact us
</Link>
.
+9
View File
@@ -21,3 +21,12 @@ export const USE_INTERNAL_URL_BROWSERLESS = () =>
export const IS_AI_FEATURES_CONFIGURED = () =>
!!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
/**
* Temporary flag to toggle between Playwright-based and Konva-based PDF generation
* for audit logs during sealing.
*
* @deprecated This is a temporary flag and will be removed once Konva-based generation is stable.
*/
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () =>
env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
+9 -6
View File
@@ -1,30 +1,33 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { TDocumentAuth } from '../types/document-auth';
import { DocumentAuth } from '../types/document-auth';
type DocumentAuthTypeData = {
key: TDocumentAuth;
value: string;
value: MessageDescriptor;
};
export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
[DocumentAuth.ACCOUNT]: {
key: DocumentAuth.ACCOUNT,
value: 'Require account',
value: msg`Require account`,
},
[DocumentAuth.PASSKEY]: {
key: DocumentAuth.PASSKEY,
value: 'Require passkey',
value: msg`Require passkey`,
},
[DocumentAuth.TWO_FACTOR_AUTH]: {
key: DocumentAuth.TWO_FACTOR_AUTH,
value: 'Require 2FA',
value: msg`Require 2FA`,
},
[DocumentAuth.PASSWORD]: {
key: DocumentAuth.PASSWORD,
value: 'Require password',
value: msg`Require password`,
},
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)',
value: msg`None (Overrides global settings)`,
},
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
@@ -1,23 +1,26 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import type { TDocumentVisibility } from '../types/document-visibility';
type DocumentVisibilityTypeData = {
key: TDocumentVisibility;
value: string;
value: MessageDescriptor;
};
export const DOCUMENT_VISIBILITY: Record<string, DocumentVisibilityTypeData> = {
[DocumentVisibility.ADMIN]: {
key: DocumentVisibility.ADMIN,
value: 'Admins only',
value: msg`Admins only`,
},
[DocumentVisibility.EVERYONE]: {
key: DocumentVisibility.EVERYONE,
value: 'Everyone',
value: msg`Everyone`,
},
[DocumentVisibility.MANAGER_AND_ABOVE]: {
key: DocumentVisibility.MANAGER_AND_ABOVE,
value: 'Managers and above',
value: msg`Managers and above`,
},
} satisfies Record<TDocumentVisibility, DocumentVisibilityTypeData>;
+5
View File
@@ -8,3 +8,8 @@ export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20;
export const CAVEAT_FONT_PATH = () => `${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`;
export const PDF_SIZE_A4_72PPI = {
width: 595,
height: 842,
};
@@ -7,7 +7,7 @@ import {
rotateDegrees,
translate,
} from '@cantoo/pdf-lib';
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Field } from '@prisma/client';
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentStatus,
EnvelopeType,
@@ -20,9 +20,13 @@ import path from 'node:path';
import { groupBy } from 'remeda';
import { match } from 'ts-pattern';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
import { prisma } from '@documenso/prisma';
import { signPdf } from '@documenso/signing';
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
import { PDF_SIZE_A4_72PPI } from '../../../constants/pdf';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
@@ -48,7 +52,7 @@ import { putPdfFileServerSide } from '../../../universal/upload/put-file.server'
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
import { isDocumentCompleted } from '../../../utils/document';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { mapDocumentIdToSecondaryId, mapSecondaryIdToDocumentId } from '../../../utils/envelope';
import { mapDocumentIdToSecondaryId } from '../../../utils/envelope';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSealDocumentJobDefinition } from './seal-document';
@@ -68,8 +72,19 @@ export const run = async ({
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
include: {
user: {
select: {
name: true,
email: true,
},
},
documentMeta: true,
recipients: true,
fields: {
include: {
signature: true,
},
},
envelopeItems: {
include: {
documentData: true,
@@ -116,23 +131,20 @@ export const run = async ({
});
}
let envelopeItems = envelope.envelopeItems;
let { envelopeItems } = envelope;
const fields = envelope.fields;
if (envelopeItems.length < 1) {
throw new Error(`Document ${envelope.id} has no envelope items`);
}
const recipients = await prisma.recipient.findMany({
where: {
envelopeId: envelope.id,
role: {
not: RecipientRole.CC,
},
},
});
const recipientsWithoutCCers = envelope.recipients.filter(
(recipient) => recipient.role !== RecipientRole.CC,
);
// Determine if the document has been rejected by checking if any recipient has rejected it
const rejectedRecipient = recipients.find(
const rejectedRecipient = recipientsWithoutCCers.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
@@ -141,15 +153,6 @@ export const run = async ({
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
include: {
signature: true,
},
});
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${envelope.id} has unsigned required fields`);
@@ -178,13 +181,52 @@ export const run = async ({
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
let certificateDoc: PDFDocument | null = null;
let auditLogDoc: PDFDocument | null = null;
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
if (settings.includeSigningCertificate || settings.includeAuditLog) {
const certificatePayload = {
envelope,
recipients: envelope.recipients, // Need to use the recipients from envelope which contains ALL recipients.
fields,
language: envelope.documentMeta.language,
envelopeOwner: {
email: envelope.user.email,
name: envelope.user.name || '',
},
envelopeItems: envelopeItems.map((item) => item.title),
pageWidth: PDF_SIZE_A4_72PPI.width,
pageHeight: PDF_SIZE_A4_72PPI.height,
};
// Use Playwright-based PDF generation if enabled, otherwise use Konva-based generation.
// This is a temporary toggle while we validate the Konva-based approach.
const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF();
const makeCertificatePdf = async () =>
usePlaywrightPdf
? getCertificatePdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDFDocument.load(buffer))
: generateCertificatePdf(certificatePayload);
const makeAuditLogPdf = async () =>
usePlaywrightPdf
? getAuditLogsPdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDFDocument.load(buffer))
: generateAuditLogPdf(certificatePayload);
const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([
settings.includeSigningCertificate ? makeCertificatePdf() : null,
settings.includeAuditLog ? makeAuditLogPdf() : null,
]);
certificateDoc = createdCertificatePdf;
auditLogDoc = createdAuditLogPdf;
}
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
@@ -203,8 +245,8 @@ export const run = async ({
envelopeItemFields,
isRejected,
rejectionReason,
certificateData,
auditLogData,
certificateDoc,
auditLogDoc,
});
newDocumentData.push(result);
@@ -300,8 +342,8 @@ type DecorateAndSignPdfOptions = {
envelopeItemFields: Field[];
isRejected: boolean;
rejectionReason: string;
certificateData: Buffer | null;
auditLogData: Buffer | null;
certificateDoc: PDFDocument | null;
auditLogDoc: PDFDocument | null;
};
/**
@@ -313,8 +355,8 @@ const decorateAndSignPdf = async ({
envelopeItemFields,
isRejected,
rejectionReason,
certificateData,
auditLogData,
certificateDoc,
auditLogDoc,
}: DecorateAndSignPdfOptions) => {
const pdfData = await getFileServerSide(envelopeItem.documentData);
@@ -330,9 +372,7 @@ const decorateAndSignPdf = async ({
await addRejectionStampToPdf(pdfDoc, rejectionReason);
}
if (certificateData) {
const certificateDoc = await PDFDocument.load(certificateData);
if (certificateDoc) {
const certificatePages = await pdfDoc.copyPages(
certificateDoc,
certificateDoc.getPageIndices(),
@@ -343,9 +383,7 @@ const decorateAndSignPdf = async ({
});
}
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
if (auditLogDoc) {
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
@@ -470,47 +508,3 @@ const decorateAndSignPdf = async ({
newDocumentDataId: newDocumentData.id,
};
};
export const getCertificateAndAuditLogData = async ({
legacyDocumentId,
documentMeta,
settings,
}: {
legacyDocumentId: number;
documentMeta: DocumentMeta;
settings: { includeSigningCertificate: boolean; includeAuditLog: boolean };
}) => {
const getCertificateDataPromise = settings.includeSigningCertificate
? getCertificatePdf({
documentId: legacyDocumentId,
language: documentMeta.language,
}).catch((e) => {
console.log('Failed to get certificate PDF');
console.error(e);
return null;
})
: null;
const getAuditLogDataPromise = settings.includeAuditLog
? getAuditLogsPdf({
documentId: legacyDocumentId,
language: documentMeta.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const [certificateData, auditLogData] = await Promise.all([
getCertificateDataPromise,
getAuditLogDataPromise,
]);
return {
certificateData,
auditLogData,
};
};
+3 -2
View File
@@ -34,6 +34,7 @@
"@node-rs/bcrypt": "^1.10.7",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.2.6",
"@simplewebauthn/server": "^13.2.2",
"@sindresorhus/slugify": "^3.0.0",
"@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0",
@@ -54,7 +55,7 @@
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"react-pdf": "^10.2.0",
"react-pdf": "^10.3.0",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
@@ -67,4 +68,4 @@
"@types/luxon": "^3.7.1",
"@types/pg": "^8.15.6"
}
}
}
@@ -1,6 +1,7 @@
import type { Passkey } from '@prisma/client';
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
@@ -53,8 +54,7 @@ export const createPasskeyAuthenticationOptions = async ({
allowCredentials: preferredPasskey
? [
{
id: preferredPasskey.credentialId,
type: 'public-key',
id: isoBase64URL.fromBuffer(preferredPasskey.credentialId),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
transports: preferredPasskey.transports as AuthenticatorTransportFuture[],
},
@@ -1,5 +1,6 @@
import { generateRegistrationOptions } from '@simplewebauthn/server';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
@@ -32,14 +33,13 @@ export const createPasskeyRegistrationOptions = async ({
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: userId.toString(),
userID: Buffer.from(userId.toString()),
userName: user.email,
userDisplayName: user.name ?? undefined,
timeout: PASSKEY_TIMEOUT,
attestationType: 'none',
excludeCredentials: passkeys.map((passkey) => ({
id: passkey.credentialId,
type: 'public-key',
id: isoBase64URL.fromBuffer(passkey.credentialId),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
transports: passkey.transports as AuthenticatorTransportFuture[],
})),
@@ -1,6 +1,7 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
import type { RegistrationResponseJSON } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { prisma } from '@documenso/prisma';
@@ -83,20 +84,19 @@ export const createPasskey = async ({
});
}
const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
const { credentialDeviceType, credentialBackedUp, credential } = verification.registrationInfo;
await prisma.$transaction(async (tx) => {
await tx.passkey.create({
data: {
userId,
name: passkeyName,
credentialId: Buffer.from(credentialID),
credentialPublicKey: Buffer.from(credentialPublicKey),
counter,
credentialId: Buffer.from(isoBase64URL.toBuffer(credential.id)),
credentialPublicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
credentialDeviceType,
credentialBackedUp,
transports: verificationResponse.response.transports,
transports: credential.transports,
},
});
@@ -1,5 +1,6 @@
import type { Envelope, Recipient } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
@@ -252,9 +253,9 @@ const verifyPasskey = async ({
expectedChallenge: verificationToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
credential: {
id: isoBase64URL.fromBuffer(passkey.credentialId),
publicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null); // May want to log this for insights.
@@ -33,7 +33,7 @@ import {
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
@@ -334,7 +334,7 @@ const injectFormValuesIntoDocument = async (
fileName = `${envelope.title}.pdf`;
}
const newDocumentData = await putPdfFileServerSide({
const newDocumentData = await putNormalizedPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@@ -11,6 +11,7 @@ import {
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
@@ -184,7 +185,9 @@ export const createEnvelope = async ({
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer), {
flattenForm: type !== EnvelopeType.TEMPLATE,
});
const titleToUse = item.title || title;
@@ -345,8 +348,22 @@ export const createEnvelope = async ({
const firstEnvelopeItem = envelope.envelopeItems[0];
const defaultRecipients = settings.defaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map(
(recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
}),
);
const allRecipients = [...(data.recipients || []), ...mappedDefaultRecipients];
await Promise.all(
(data.recipients || []).map(async (recipient) => {
allRecipients.map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
@@ -1,3 +1,6 @@
/**
* @deprecated We use Konva to generate the audit logs PDF now.
*/
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
@@ -33,7 +36,9 @@ export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfO
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(browserlessUrl);
} else {
browser = await chromium.launch();
browser = await chromium.launch({
executablePath: env('PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH') || undefined,
});
}
if (!browser) {
@@ -1,3 +1,6 @@
/**
* @deprecated We use Konva to generate the certificate PDF now.
*/
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
@@ -33,7 +36,9 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(browserlessUrl);
} else {
browser = await chromium.launch();
browser = await chromium.launch({
executablePath: env('PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH') || undefined,
});
}
if (!browser) {
@@ -1,4 +1,4 @@
import type { Prisma } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { OrganisationType } from '@prisma/client';
import { OrganisationMemberRole } from '@prisma/client';
@@ -63,6 +63,7 @@ export const createOrganisation = async ({
const organisationSetting = await tx.organisationGlobalSettings.create({
data: {
...generateDefaultOrganisationSettings(),
defaultRecipients: Prisma.DbNull,
id: generateDatabaseId('org_setting'),
},
});
@@ -0,0 +1,60 @@
import { i18n } from '@lingui/core';
import { prisma } from '@documenso/prisma';
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
import { getTranslations } from '../../utils/i18n';
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
import type { GenerateCertificatePdfOptions } from './generate-certificate-pdf';
import { mergeFilesIntoPdf } from './generate-certificate-pdf';
import { renderAuditLogs } from './render-audit-logs';
type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
envelopeItems: string[];
};
export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) => {
const { envelope, envelopeOwner, envelopeItems, recipients, language, pageWidth, pageHeight } =
options;
const documentLanguage = ZSupportedLanguageCodeSchema.parse(language);
const [organisationClaim, auditLogs, messages] = await Promise.all([
getOrganisationClaimByTeamId({ teamId: envelope.teamId }),
getAuditLogs(envelope.id),
getTranslations(documentLanguage),
]);
i18n.loadAndActivate({
locale: documentLanguage,
messages,
});
const auditLogPages = await renderAuditLogs({
envelope,
envelopeOwner,
envelopeItems,
recipients,
auditLogs,
hidePoweredBy: organisationClaim.flags.hidePoweredBy ?? false,
pageWidth,
pageHeight,
i18n,
});
return await mergeFilesIntoPdf(auditLogPages);
};
const getAuditLogs = async (envelopeId: string) => {
const auditLogs = await prisma.documentAuditLog.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'desc',
},
});
return auditLogs.map((auditLog) => parseDocumentAuditLogData(auditLog));
};
@@ -0,0 +1,160 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta } from '@prisma/client';
import type { Envelope, Field, Recipient, Signature } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { prop, sortBy } from 'remeda';
import { match } from 'ts-pattern';
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getTranslations } from '../../utils/i18n';
import { getDocumentCertificateAuditLogs } from '../document/get-document-certificate-audit-logs';
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
import { renderCertificate } from './render-certificate';
export type GenerateCertificatePdfOptions = {
envelope: Envelope & {
documentMeta: DocumentMeta;
};
envelopeOwner: {
name: string;
email: string;
};
recipients: Recipient[];
fields: (Pick<Field, 'id' | 'type' | 'secondaryId' | 'recipientId'> & {
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | null;
})[];
language?: string;
pageWidth: number;
pageHeight: number;
};
export const generateCertificatePdf = async (options: GenerateCertificatePdfOptions) => {
const { envelope, envelopeOwner, recipients, fields, language, pageWidth, pageHeight } = options;
const documentLanguage = ZSupportedLanguageCodeSchema.parse(language);
const [organisationClaim, auditLogs, messages] = await Promise.all([
getOrganisationClaimByTeamId({ teamId: envelope.teamId }),
getDocumentCertificateAuditLogs({
envelopeId: envelope.id,
}),
getTranslations(documentLanguage),
]);
i18n.loadAndActivate({
locale: documentLanguage,
messages,
});
const payload = {
recipients: recipients.map((recipient) => {
const recipientId = recipient.id;
const signatureField = fields.find(
(field) => field.recipientId === recipient.id && field.type === FieldType.SIGNATURE,
);
const emailSent: TDocumentAuditLogBaseSchema | undefined = auditLogs['EMAIL_SENT'].find(
(log) => log.type === 'EMAIL_SENT' && log.data.recipientId === recipientId,
);
const documentSent: TDocumentAuditLogBaseSchema | undefined = auditLogs['DOCUMENT_SENT'].find(
(log) => log.type === 'DOCUMENT_SENT',
);
const documentOpened: TDocumentAuditLogBaseSchema | undefined = auditLogs[
'DOCUMENT_OPENED'
].find((log) => log.type === 'DOCUMENT_OPENED' && log.data.recipientId === recipientId);
const documentRecipientCompleted: TDocumentAuditLogBaseSchema | undefined = auditLogs[
'DOCUMENT_RECIPIENT_COMPLETED'
].find(
(log) =>
log.type === 'DOCUMENT_RECIPIENT_COMPLETED' && log.data.recipientId === recipientId,
);
const documentRecipientRejected: TDocumentAuditLogBaseSchema | undefined = auditLogs[
'DOCUMENT_RECIPIENT_REJECTED'
].find(
(log) => log.type === 'DOCUMENT_RECIPIENT_REJECTED' && log.data.recipientId === recipientId,
);
const extractedAuthMethods = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
const insertedAuditLogsWithFieldAuth = sortBy(
auditLogs.DOCUMENT_FIELD_INSERTED.filter(
(log) => log.data.recipientId === recipient.id && log.data.fieldSecurity,
),
[prop('createdAt'), 'desc'],
);
const actionAuthMethod = insertedAuditLogsWithFieldAuth.at(0)?.data?.fieldSecurity?.type;
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => i18n._(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => i18n._(msg`Two-Factor Re-Authentication`))
.with('PASSWORD', () => i18n._(msg`Password Re-Authentication`))
.with('PASSKEY', () => i18n._(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => i18n._(msg`Email`))
.with(undefined, () => null)
.exhaustive();
if (!authLevel) {
const accessAuthMethod = extractedAuthMethods.derivedRecipientAccessAuth.at(0);
authLevel = match(accessAuthMethod)
.with('ACCOUNT', () => i18n._(msg`Account Authentication`))
.with('TWO_FACTOR_AUTH', () => i18n._(msg`Two-Factor Authentication`))
.with(undefined, () => i18n._(msg`Email`))
.exhaustive();
}
return {
id: recipient.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingStatus: recipient.signingStatus,
signatureField,
rejectionReason: recipient.rejectionReason,
authLevel,
logs: {
emailed: emailSent ?? null,
sent: documentSent ?? null,
opened: documentOpened ?? null,
completed: documentRecipientCompleted ?? null,
rejected: documentRecipientRejected ?? null,
},
};
}),
envelopeOwner,
qrToken: envelope.qrToken,
hidePoweredBy: organisationClaim.flags.hidePoweredBy ?? false,
pageWidth,
pageHeight,
i18n,
};
const certificatePages = await renderCertificate(payload);
return await mergeFilesIntoPdf(certificatePages);
};
export async function mergeFilesIntoPdf(buffers: Uint8Array[]) {
const mergedPdf = await PDFDocument.create();
for (const buffer of buffers) {
const pdf = await PDFDocument.load(buffer);
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach((p) => mergedPdf.addPage(p));
}
return mergedPdf;
}
@@ -4,7 +4,9 @@ import { AppError } from '../../errors/app-error';
import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer) => {
export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean } = {}) => {
const shouldFlattenForm = options.flattenForm ?? true;
const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`);
@@ -20,8 +22,11 @@ export const normalizePdf = async (pdf: Buffer) => {
}
removeOptionalContentGroups(pdfDoc);
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
if (shouldFlattenForm) {
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
}
return Buffer.from(await pdfDoc.save());
};
@@ -0,0 +1,721 @@
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta } from '@prisma/client';
import type { Envelope, RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { match } from 'ts-pattern';
import { P } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { DOCUMENT_STATUS } from '../../constants/document';
import { APP_I18N_OPTIONS } from '../../constants/i18n';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TDocumentAuditLog } from '../../types/document-audit-logs';
import { formatDocumentAuditLogAction } from '../../utils/document-audit-logs';
export type AuditLogRecipient = {
id: number;
name: string;
email: string;
role: RecipientRole;
};
type GenerateAuditLogsOptions = {
envelope: Envelope & {
documentMeta: DocumentMeta;
};
envelopeItems: string[];
recipients: AuditLogRecipient[];
auditLogs: TDocumentAuditLog[];
hidePoweredBy: boolean;
pageWidth: number;
pageHeight: number;
i18n: I18n;
envelopeOwner: {
email: string;
name: string;
};
};
const parser = new UAParser();
const textMutedForegroundLight = '#929DAE';
const textForeground = '#000';
const textMutedForeground = '#64748B';
const textBase = 10;
const textSm = 9;
const textXs = 8;
const fontMedium = '500';
const pageTopMargin = 60;
const pageBottomMargin = 15;
const contentMaxWidth = 768;
const rowPadding = 10;
const titleFontSize = 18;
type RenderOverviewCardLabelAndTextOptions = {
label: string;
text: string | string[];
width: number;
groupX?: number;
};
const renderOverviewCardLabels = (options: RenderOverviewCardLabelAndTextOptions) => {
const { width, text } = options;
const labelYSpacing = 4;
const group = new Konva.Group({
x: options.groupX ?? 0,
});
const label = new Konva.Text({
x: 0,
y: 0,
text: options.label,
fontStyle: fontMedium,
fontFamily: 'Inter',
fill: textForeground,
fontSize: textSm,
});
group.add(label);
if (typeof text === 'string') {
const value = new Konva.Text({
x: 0,
y: label.height() + labelYSpacing,
width: width - label.width(),
fontFamily: 'Inter',
text,
fill: textForeground,
wrap: 'char',
fontSize: textSm,
});
group.add(value);
} else {
for (const textValue of text) {
const value = new Konva.Text({
x: 0,
y: group.getClientRect().height + 4,
width: width - label.width(),
fontFamily: 'Inter',
text: '• ' + textValue,
fill: textForeground,
wrap: 'char',
fontSize: textSm,
});
group.add(value);
}
}
return group;
};
type RenderVerticalLabelAndTextOptions = {
label: string;
text: string;
width?: number;
align?: 'left' | 'right';
x?: number;
y?: number;
textFontFamily?: string;
};
const renderVerticalLabelAndText = (options: RenderVerticalLabelAndTextOptions) => {
const { label, text, width, align, x, y, textFontFamily } = options;
const group = new Konva.Group({
x: x ?? 0,
y: y ?? 0,
});
const konvaLabel = new Konva.Text({
align: align ?? 'left',
fontFamily: 'Inter',
width,
text: label,
fontSize: textXs,
fill: textMutedForegroundLight,
});
group.add(konvaLabel);
const konvaText = new Konva.Text({
y: group.getClientRect().height + 6,
align: align ?? 'left',
fontFamily: textFontFamily ?? 'Inter',
width,
text: text,
fontSize: textXs,
fill: textForeground,
});
group.add(konvaText);
return group;
};
type RenderOverviewCardOptions = {
envelope: Envelope & {
documentMeta: DocumentMeta;
};
envelopeItems: string[];
envelopeOwner: {
email: string;
name: string;
};
recipients: AuditLogRecipient[];
width: number;
i18n: I18n;
};
const renderOverviewCard = (options: RenderOverviewCardOptions) => {
const { envelope, envelopeItems, envelopeOwner, recipients, width, i18n } = options;
const cardPadding = 16;
const overviewCard = new Konva.Group();
const columnSpacing = 10;
const columnWidth = (width - columnSpacing) / 2;
const rowVerticalSpacing = 32;
const rowOne = new Konva.Group({
x: cardPadding,
y: cardPadding,
});
const envelopeIdLabel = renderOverviewCardLabels({
label: i18n._(msg`Envelope ID`),
text: envelope.id,
width: columnWidth,
});
const ownerLabel = renderOverviewCardLabels({
label: i18n._(msg`Owner`),
text: `${envelopeOwner.name} (${envelopeOwner.email})`,
width: columnWidth,
groupX: columnWidth + columnSpacing,
});
rowOne.add(envelopeIdLabel);
rowOne.add(ownerLabel);
overviewCard.add(rowOne);
const rowTwo = new Konva.Group({
x: cardPadding,
y: overviewCard.getClientRect().height + rowVerticalSpacing,
});
const statusLabel = renderOverviewCardLabels({
label: i18n._(msg`Status`),
text: i18n
._(envelope.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[envelope.status].description)
.toUpperCase(),
width: columnWidth,
});
const timeZoneLabel = renderOverviewCardLabels({
label: i18n._(msg`Time Zone`),
text: envelope.documentMeta?.timezone || 'N/A',
width: columnWidth,
groupX: columnWidth + columnSpacing,
});
rowTwo.add(statusLabel);
rowTwo.add(timeZoneLabel);
overviewCard.add(rowTwo);
const rowThree = new Konva.Group({
x: cardPadding,
y: overviewCard.getClientRect().height + rowVerticalSpacing,
});
const createdAtLabel = renderOverviewCardLabels({
label: i18n._(msg`Created At`),
text: DateTime.fromJSDate(envelope.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'),
width: columnWidth,
});
const lastUpdatedLabel = renderOverviewCardLabels({
label: i18n._(msg`Last Updated`),
text: DateTime.fromJSDate(envelope.updatedAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'),
width: columnWidth,
groupX: columnWidth + columnSpacing,
});
rowThree.add(createdAtLabel);
rowThree.add(lastUpdatedLabel);
overviewCard.add(rowThree);
const rowFour = new Konva.Group({
x: cardPadding,
y: overviewCard.getClientRect().height + rowVerticalSpacing,
});
const enclosedDocumentsLabel = renderOverviewCardLabels({
label: i18n._(msg`Enclosed Documents`),
text: envelopeItems,
width: columnWidth,
});
const recipientsLabel = renderOverviewCardLabels({
label: i18n._(msg`Recipients`),
text: recipients.map(
(recipient) =>
`[${i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}] ${recipient.name} (${recipient.email})`,
),
width: columnWidth,
groupX: columnWidth + columnSpacing,
});
rowFour.add(enclosedDocumentsLabel);
rowFour.add(recipientsLabel);
overviewCard.add(rowFour);
// Create rect border around the overview card
const cardRect = new Konva.Rect({
x: 0,
y: 0,
width,
height: overviewCard.getClientRect().height + cardPadding * 2,
stroke: '#e5e7eb',
strokeWidth: 1.5,
cornerRadius: 8,
});
overviewCard.add(cardRect);
return overviewCard;
};
type RenderRowOptions = {
auditLog: TDocumentAuditLog;
width: number;
i18n: I18n;
};
const renderRow = (options: RenderRowOptions) => {
const { auditLog, width, i18n } = options;
const paddingWithinCard = 12;
const columnSpacing = 10;
const columnWidth = (width - paddingWithinCard * 2 - columnSpacing) / 2;
const indicatorWidth = 3;
const indicatorPaddingRight = 10;
const rowGroup = new Konva.Group();
const rowHeaderGroup = new Konva.Group();
const auditLogIndicatorColor = new Konva.Circle({
x: indicatorWidth,
y: indicatorWidth + 3,
radius: indicatorWidth,
fill: getAuditLogIndicatorColor(auditLog.type),
});
const auditLogTypeText = new Konva.Text({
x: indicatorWidth + indicatorPaddingRight,
y: 0,
width: columnWidth - indicatorWidth - indicatorPaddingRight,
text: auditLog.type.replace(/_/g, ' '),
fontFamily: 'Inter',
fontSize: textSm,
fontStyle: fontMedium,
fill: textMutedForeground,
});
const auditLogDescriptionText = new Konva.Text({
x: indicatorWidth + indicatorPaddingRight,
y: auditLogTypeText.height() + 4,
width: columnWidth - indicatorWidth - indicatorPaddingRight,
text: formatDocumentAuditLogAction(i18n, auditLog).description,
fontFamily: 'Inter',
fontSize: textSm,
fill: textForeground,
});
const auditLogTimestampText = new Konva.Text({
x: columnWidth + columnSpacing,
width: columnWidth,
text: DateTime.fromJSDate(auditLog.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toLocaleString(dateFormat),
fontFamily: 'Inter',
align: 'right',
fontSize: textSm,
fill: textMutedForeground,
});
rowHeaderGroup.add(auditLogIndicatorColor);
rowHeaderGroup.add(auditLogTypeText);
rowHeaderGroup.add(auditLogDescriptionText);
rowHeaderGroup.add(auditLogTimestampText);
rowHeaderGroup.setAttrs({
x: paddingWithinCard,
y: paddingWithinCard,
} satisfies Partial<Konva.GroupConfig>);
rowGroup.add(rowHeaderGroup);
// Draw border line.
const borderLine = new Konva.Line({
points: [0, 0, width - paddingWithinCard * 2, 0],
stroke: '#e5e7eb',
strokeWidth: 1,
x: paddingWithinCard,
y: rowGroup.getClientRect().height + paddingWithinCard + 12,
});
rowGroup.add(borderLine);
const bottomSection = new Konva.Group({
x: paddingWithinCard,
y: rowGroup.getClientRect().height + paddingWithinCard + 12,
});
// Row 1 Column 1
const userLabel = renderVerticalLabelAndText({
label: i18n._(msg`User`).toUpperCase(),
text: auditLog.email || 'N/A',
align: 'left',
width: columnWidth,
textFontFamily: 'ui-monospace',
});
// Row 1 Column 2
const ipAddressLabel = renderVerticalLabelAndText({
label: i18n._(msg`IP Address`).toUpperCase(),
text: auditLog.ipAddress || 'N/A',
align: 'right',
x: columnWidth + columnSpacing,
width: columnWidth,
textFontFamily: 'ui-monospace',
});
bottomSection.add(userLabel);
bottomSection.add(ipAddressLabel);
parser.setUA(auditLog.userAgent || '');
const userAgentInfo = parser.getResult();
// Row 2 Column 1
const userAgentLabel = renderVerticalLabelAndText({
label: i18n._(msg`User Agent`).toUpperCase(),
text: i18n._(formatUserAgent(auditLog.userAgent, userAgentInfo)),
align: 'left',
width,
y: bottomSection.getClientRect().height + 16,
});
bottomSection.add(userAgentLabel);
rowGroup.add(bottomSection);
const cardRect = new Konva.Rect({
x: 0,
y: 0,
width: rowGroup.getClientRect().width,
height: rowGroup.getClientRect().height + paddingWithinCard * 2,
stroke: '#e5e7eb',
strokeWidth: 1,
cornerRadius: 8,
});
rowGroup.add(cardRect);
return rowGroup;
};
const renderBranding = () => {
const branding = new Konva.Group();
const brandingHeight = 16;
const logoPath = path.join(process.cwd(), 'public/static/logo.png');
const logo = fs.readFileSync(logoPath);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const img = new SkiaImage(logo) as unknown as HTMLImageElement;
const brandingImage = new Konva.Image({
image: img,
height: brandingHeight,
width: brandingHeight * (img.width / img.height),
});
branding.add(brandingImage);
return branding;
};
type GroupRowsIntoPagesOptions = {
auditLogs: TDocumentAuditLog[];
maxHeight: number;
contentWidth: number;
i18n: I18n;
overviewCard: Konva.Group;
};
const groupRowsIntoPages = (options: GroupRowsIntoPagesOptions) => {
const { auditLogs, maxHeight, contentWidth, i18n, overviewCard } = options;
const groupedRows: Konva.Group[][] = [[]];
const overviewCardHeight = overviewCard.getClientRect().height;
// First page has title + overview card
let availableHeight = maxHeight - pageTopMargin - overviewCardHeight;
let currentGroupedRowIndex = 0;
// Group rows into pages.
for (const auditLog of auditLogs) {
const row = renderRow({ auditLog, width: contentWidth, i18n });
const rowHeight = row.getClientRect().height;
const requiredHeight = rowHeight + rowPadding;
if (requiredHeight > availableHeight) {
currentGroupedRowIndex++;
groupedRows[currentGroupedRowIndex] = [row];
// Subsequent pages only have title (no overview card)
availableHeight = maxHeight - pageTopMargin;
} else {
groupedRows[currentGroupedRowIndex].push(row);
}
// Reduce available height by the row height.
availableHeight -= requiredHeight;
}
return groupedRows;
};
type RenderPagesOptions = {
groupedRows: Konva.Group[][];
margin: number;
pageTopMargin: number;
i18n: I18n;
overviewCard: Konva.Group;
};
const renderPages = (options: RenderPagesOptions) => {
const { groupedRows, margin, pageTopMargin, i18n, overviewCard } = options;
const rowPadding = 10;
const pages: Konva.Group[] = [];
// Render the rows for each page.
for (const [pageIndex, rows] of groupedRows.entries()) {
const pageGroup = new Konva.Group();
// Add title to each page
const pageTitle = new Konva.Text({
x: margin,
y: 0,
height: pageTopMargin,
verticalAlign: 'middle',
text: i18n._(msg`Audit Log`),
fill: textForeground,
fontFamily: 'Inter',
fontSize: titleFontSize,
fontStyle: '700',
});
pageGroup.add(pageTitle);
// Add overview card only on first page
if (pageIndex === 0) {
overviewCard.setAttrs({
x: margin,
y: pageGroup.getClientRect().height,
});
pageGroup.add(overviewCard);
}
// Add rows to the page
for (const row of rows) {
const yPosition = pageGroup.getClientRect().height + rowPadding;
row.setAttrs({
x: margin,
y: yPosition,
});
pageGroup.add(row);
}
pages.push(pageGroup);
}
return pages;
};
export async function renderAuditLogs({
envelope,
envelopeOwner,
envelopeItems,
recipients,
auditLogs,
pageWidth,
pageHeight,
i18n,
hidePoweredBy,
}: GenerateAuditLogsOptions) {
const fontPath = path.join(process.cwd(), 'public/fonts');
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
});
const minimumMargin = 10;
const contentWidth = Math.min(pageWidth - minimumMargin * 2, contentMaxWidth);
const margin = (pageWidth - contentWidth) / 2;
let stage: Konva.Stage | null = new Konva.Stage({ width: pageWidth, height: pageHeight });
const overviewCard = renderOverviewCard({
envelope,
envelopeOwner,
envelopeItems,
recipients,
width: contentWidth,
i18n,
});
const groupedRows = groupRowsIntoPages({
auditLogs,
maxHeight: pageHeight,
contentWidth,
i18n,
overviewCard,
});
const pageGroups = renderPages({
groupedRows,
margin,
pageTopMargin,
i18n,
overviewCard,
});
const brandingGroup = renderBranding();
const brandingRect = brandingGroup.getClientRect();
const brandingTopPadding = 24;
const pages: Uint8Array[] = [];
let isBrandingPlaced = false;
// Render each page group to PDF
for (const [index, pageGroup] of pageGroups.entries()) {
stage.destroyChildren();
const page = new Konva.Layer();
page.add(pageGroup);
// Add branding on the last page if there is space.
if (index === pageGroups.length - 1 && !hidePoweredBy) {
const remainingHeight = pageHeight - pageGroup.getClientRect().height - pageBottomMargin;
if (brandingRect.height + brandingTopPadding <= remainingHeight) {
brandingGroup.setAttrs({
x: pageWidth - brandingRect.width - margin,
y: pageGroup.getClientRect().height + brandingTopPadding,
} satisfies Partial<Konva.GroupConfig>);
page.add(brandingGroup);
isBrandingPlaced = true;
}
}
stage.add(page);
// Export the page and save it.
const canvas = page.canvas._canvas as unknown as Canvas; // eslint-disable-line @typescript-eslint/consistent-type-assertions
const buffer = await canvas.toBuffer('pdf');
pages.push(new Uint8Array(buffer));
}
// Need to create an empty page for the branding if it hasn't been placed yet.
if (!hidePoweredBy && !isBrandingPlaced) {
stage.destroyChildren();
const page = new Konva.Layer();
brandingGroup.setAttrs({
x: pageWidth - brandingRect.width - margin,
y: pageTopMargin,
} satisfies Partial<Konva.GroupConfig>);
page.add(brandingGroup);
stage.add(page);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const canvas = page.canvas._canvas as unknown as Canvas;
const buffer = await canvas.toBuffer('pdf');
pages.push(new Uint8Array(buffer));
}
stage.destroy();
stage = null;
return pages;
}
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
/**
* Get the color indicator for the audit log type
*/
const getAuditLogIndicatorColor = (type: string) =>
match(type)
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => '#22c55e') // bg-green-500
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => '#ef4444') // bg-red-500
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => '#f97316') // bg-orange-500
.with(
P.union(
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
),
() => '#3b82f6', // bg-blue-500
)
.otherwise(() => '#f1f5f9'); // bg-muted
const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UAParser.IResult) => {
if (!userAgent) {
return msg`N/A`;
}
const browser = userAgentInfo.browser.name;
const version = userAgentInfo.browser.version;
const os = userAgentInfo.os.name;
// If we can parse meaningful browser info, format it nicely
if (browser && os) {
const browserInfo = version ? `${browser} ${version}` : browser;
return msg`${browserInfo} on ${os}`;
}
return msg`${userAgent}`;
};
@@ -0,0 +1,819 @@
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import type { RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import { DateTime } from 'luxon';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { APP_I18N_OPTIONS } from '../../constants/i18n';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_SIGNING_REASONS,
} from '../../constants/recipient-roles';
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
import { svgToPng } from '../../utils/images/svg-to-png';
type ColumnWidths = [number, number, number];
type BaseAuditLog = Pick<TDocumentAuditLogBaseSchema, 'createdAt' | 'ipAddress' | 'userAgent'>;
export type CertificateRecipient = {
id: number;
name: string;
email: string;
role: RecipientRole;
rejectionReason: string | null;
signingStatus: SigningStatus;
signatureField?: Pick<Field, 'id' | 'secondaryId' | 'recipientId'> & {
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | null;
};
authLevel: string;
logs: {
emailed: BaseAuditLog | null;
sent: BaseAuditLog | null;
opened: BaseAuditLog | null;
completed: BaseAuditLog | null;
rejected: BaseAuditLog | null;
};
};
type GenerateCertificateOptions = {
recipients: CertificateRecipient[];
qrToken: string | null;
hidePoweredBy: boolean;
i18n: I18n;
envelopeOwner: {
name: string;
email: string;
};
pageWidth: number;
pageHeight: number;
};
// Helper function to get device info from user agent
const getDevice = (userAgent?: string | null): string => {
if (!userAgent) {
return 'Unknown';
}
const parser = new UAParser(userAgent);
parser.setUA(userAgent);
const result = parser.getResult();
return `${result.os.name} - ${result.browser.name} ${result.browser.version}`;
};
const textMutedForegroundLight = '#929DAE';
const textForeground = '#000';
const textMutedForeground = '#64748B';
const textBase = 10;
const textSm = 9;
const textXs = 8;
const fontMedium = '500';
const columnWidthPercentages = [30, 30, 40];
const rowPadding = 12;
const tableHeaderHeight = 38;
const pageTopMargin = 72;
const pageBottomMargin = 12;
const contentMaxWidth = 768;
const titleFontSize = 18;
type RenderLabelAndTextOptions = {
label: string;
text: string;
width: number;
y?: number;
};
const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
const { width, y } = options;
const group = new Konva.Group({
y,
});
const label = new Konva.Text({
x: 0,
y: 0,
text: `${options.label}: `,
fontStyle: fontMedium,
fontFamily: 'Inter',
fill: textMutedForeground,
fontSize: textSm,
});
group.add(label);
const value = new Konva.Text({
x: label.width(),
y: 0,
width: width - label.width(),
fontFamily: 'Inter',
text: options.text,
fill: textMutedForeground,
wrap: 'char',
fontSize: textSm,
});
group.add(value);
return group;
};
type RenderRowHeaderOptions = {
columnWidths: number[];
i18n: I18n;
};
const renderRowHeader = (options: RenderRowHeaderOptions) => {
const { columnWidths, i18n } = options;
const columnOneWidth = columnWidths[0];
const columnTwoWidth = columnWidths[1];
const columnThreeWidth = columnWidths[2];
const headerRow = new Konva.Group();
const headerFontStyling = {
fontFamily: 'Inter',
fontSize: 11,
fontStyle: fontMedium,
verticalAlign: 'middle',
fill: textMutedForeground,
height: tableHeaderHeight,
};
const header1 = new Konva.Text({
x: rowPadding,
width: columnOneWidth,
text: i18n._(msg`Signer Events`),
...headerFontStyling,
});
headerRow.add(header1);
const header2 = new Konva.Text({
x: columnOneWidth + rowPadding,
width: columnTwoWidth,
text: i18n._(msg`Signature`),
...headerFontStyling,
});
headerRow.add(header2);
const header3 = new Konva.Text({
x: columnOneWidth + columnTwoWidth + rowPadding,
width: columnThreeWidth,
text: i18n._(msg`Details`),
...headerFontStyling,
});
headerRow.add(header3);
return headerRow;
};
const columnPadding = 10;
type RenderColumnOptions = {
recipient: CertificateRecipient;
width: number;
i18n: I18n;
envelopeOwner: {
name: string;
email: string;
};
};
const renderColumnOne = (options: RenderColumnOptions) => {
const { recipient, width, i18n } = options;
const columnGroup = new Konva.Group();
const textSectionPadding = 8;
const textFontStyling = {
x: 0,
fontFamily: 'Inter',
wrap: 'char',
lineHeight: 1.2,
fill: textMutedForeground,
width: width - columnPadding,
};
if (recipient.name) {
const nameText = new Konva.Text({
y: 0,
text: recipient.name,
fontSize: textBase,
...textFontStyling,
fontStyle: fontMedium,
});
columnGroup.add(nameText);
}
const emailText = new Konva.Text({
y: columnGroup.getClientRect().height,
text: recipient.email,
fontSize: textBase,
...textFontStyling,
});
columnGroup.add(emailText);
const roleText = new Konva.Text({
y: columnGroup.getClientRect().height + textSectionPadding,
text: i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName),
fontSize: textSm,
...textFontStyling,
});
columnGroup.add(roleText);
const authLabel = new Konva.Text({
y: columnGroup.getClientRect().height + textSectionPadding,
text: `${i18n._(msg`Authentication Level`)}:`,
fontSize: textSm,
fontStyle: fontMedium,
...textFontStyling,
});
columnGroup.add(authLabel);
const authValue = new Konva.Text({
y: columnGroup.getClientRect().height,
text: recipient.authLevel,
fontSize: textSm,
...textFontStyling,
});
columnGroup.add(authValue);
return columnGroup;
};
const renderColumnTwo = (options: RenderColumnOptions) => {
const { recipient, width, i18n } = options;
// Column 2: Signature
const column = new Konva.Group();
const columnWidth = width - columnPadding;
if (recipient.signatureField?.secondaryId) {
// Signature container with green border
const signatureContainer = new Konva.Group({ x: 0, y: 0 });
const minSignatureHeight = 40;
const maxSignatureWidth = 100;
// Signature content
if (recipient.signatureField?.signature?.signatureImageAsBase64) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const img = new SkiaImage(
recipient.signatureField?.signature?.signatureImageAsBase64,
) as unknown as HTMLImageElement;
const signatureImage = new Konva.Image({
image: img,
x: 4,
y: 4,
width: maxSignatureWidth,
height: maxSignatureWidth * (img.height / img.width),
});
signatureContainer.add(signatureImage);
} else if (recipient.signatureField?.signature?.typedSignature) {
const typedSig = new Konva.Text({
x: 2,
text: recipient.signatureField?.signature?.typedSignature,
padding: 4,
fontFamily: 'Caveat',
fontSize: 16,
align: 'center',
verticalAlign: 'middle',
width: maxSignatureWidth,
});
if (typedSig.getClientRect().height < minSignatureHeight) {
typedSig.setAttrs({
height: minSignatureHeight,
});
}
signatureContainer.add(typedSig);
}
column.add(signatureContainer);
const signatureHeight = Math.max(signatureContainer.getClientRect().height, minSignatureHeight);
const signatureBorder = new Konva.Rect({
x: 2,
y: 2,
width: maxSignatureWidth,
height: signatureHeight,
stroke: 'rgba(122, 196, 85, 0.6)',
strokeWidth: 1,
cornerRadius: 8,
});
signatureContainer.add(signatureBorder);
const signatureShadow = new Konva.Rect({
x: 0,
y: 0,
width: maxSignatureWidth + 4,
height: signatureHeight + 4,
stroke: 'rgba(122, 196, 85, 0.1)',
strokeWidth: 4,
cornerRadius: 8,
});
signatureContainer.add(signatureShadow);
// Signature ID
const sigIdLabel = new Konva.Text({
x: 0,
y: signatureHeight + 10,
text: `${i18n._(msg`Signature ID`)}:`,
fill: textMutedForeground,
width: columnWidth,
fontFamily: 'Inter',
fontSize: textSm,
fontStyle: fontMedium,
lineHeight: 1.4,
});
column.add(sigIdLabel);
const sigIdValue = new Konva.Text({
x: 0,
y: column.getClientRect().height,
text: recipient.signatureField.secondaryId.toUpperCase(),
fill: textMutedForeground,
fontFamily: 'monospace',
fontSize: textSm,
width: columnWidth,
wrap: 'char',
});
column.add(sigIdValue);
} else {
const naText = new Konva.Text({
x: 0,
y: 0,
text: 'N/A',
fill: textMutedForeground,
fontFamily: 'Inter',
fontSize: textSm,
});
column.add(naText);
}
const ipLabelAndText = renderLabelAndText({
label: i18n._(msg`IP Address`),
text: recipient.logs.completed?.ipAddress ?? i18n._(msg`Unknown`),
width,
y: column.getClientRect().height + 6,
});
column.add(ipLabelAndText);
const deviceLabelAndText = renderLabelAndText({
label: i18n._(msg`Device`),
text: getDevice(recipient.logs.completed?.userAgent),
width,
y: column.getClientRect().height + 6,
});
column.add(deviceLabelAndText);
return column;
};
const renderColumnThree = (options: RenderColumnOptions) => {
const { recipient, width, i18n, envelopeOwner } = options;
const column = new Konva.Group();
const itemsToRender = [
{
label: i18n._(msg`Sent`),
value: recipient.logs.emailed
? DateTime.fromJSDate(recipient.logs.emailed.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: recipient.logs.sent
? DateTime.fromJSDate(recipient.logs.sent.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: i18n._(msg`Unknown`),
},
{
label: i18n._(msg`Viewed`),
value: recipient.logs.opened
? DateTime.fromJSDate(recipient.logs.opened.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: i18n._(msg`Unknown`),
},
];
if (recipient.logs.rejected) {
itemsToRender.push({
label: i18n._(msg`Rejected`),
value: DateTime.fromJSDate(recipient.logs.rejected.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'),
});
} else {
itemsToRender.push({
label: i18n._(msg`Signed`),
value: recipient.logs.completed
? DateTime.fromJSDate(recipient.logs.completed.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: i18n._(msg`Unknown`),
});
}
const isOwner = recipient.email.toLowerCase() === envelopeOwner.email.toLowerCase();
itemsToRender.push({
label: i18n._(msg`Reason`),
value:
recipient.signingStatus === SigningStatus.REJECTED
? recipient.rejectionReason || ''
: isOwner
? i18n._(msg`I am the owner of this document`)
: i18n._(RECIPIENT_ROLE_SIGNING_REASONS[recipient.role]),
});
for (const [index, item] of itemsToRender.entries()) {
const labelAndText = renderLabelAndText({
label: item.label,
text: item.value,
width,
y: column.getClientRect().height + (index === 0 ? 0 : 8),
});
column.add(labelAndText);
}
return column;
};
type RenderRowOptions = {
recipient: CertificateRecipient;
columnWidths: ColumnWidths;
i18n: I18n;
envelopeOwner: {
name: string;
email: string;
};
};
const renderRow = (options: RenderRowOptions) => {
const { recipient, columnWidths, i18n, envelopeOwner } = options;
const rowGroup = new Konva.Group();
const width = columnWidths[0] + columnWidths[1] + columnWidths[2];
// Draw top border line.
const borderLine = new Konva.Line({
points: [0, 0, width + rowPadding * 2, 0],
stroke: '#e5e7eb',
strokeWidth: 1,
});
rowGroup.add(borderLine);
// Column 1: Signer Events
const columnGroup = renderColumnOne({
recipient,
width: columnWidths[0],
i18n,
envelopeOwner,
});
columnGroup.setAttrs({
x: rowPadding,
y: rowPadding,
} satisfies Partial<Konva.GroupConfig>);
rowGroup.add(columnGroup);
const columnTwoGroup = renderColumnTwo({
recipient,
width: columnWidths[1],
i18n,
envelopeOwner,
});
columnTwoGroup.setAttrs({
x: rowPadding + columnWidths[0],
y: rowPadding,
} satisfies Partial<Konva.GroupConfig>);
rowGroup.add(columnTwoGroup);
// Column 3: Details
const columnThreeGroup = renderColumnThree({
recipient,
width: columnWidths[2],
i18n,
envelopeOwner,
});
columnThreeGroup.setAttrs({
x: rowPadding + columnWidths[0] + columnWidths[1],
y: rowPadding,
} satisfies Partial<Konva.GroupConfig>);
rowGroup.add(columnThreeGroup);
const rowBottomPadding = new Konva.Rect({
x: 0,
y: rowGroup.getClientRect().height,
width: rowGroup.getClientRect().width,
height: rowPadding,
});
rowGroup.add(rowBottomPadding);
return rowGroup;
};
const renderBranding = async ({ qrToken, i18n }: { qrToken: string | null; i18n: I18n }) => {
const branding = new Konva.Group();
const brandingHeight = 12;
const text = new Konva.Text({
x: 0,
verticalAlign: 'middle',
text: i18n._(msg`Signing certificate provided by`) + ':',
fontStyle: fontMedium,
fontFamily: 'Inter',
fontSize: textSm,
height: brandingHeight,
});
const logoPath = path.join(process.cwd(), 'public/static/logo.png');
const logo = fs.readFileSync(logoPath);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const img = new SkiaImage(logo) as unknown as HTMLImageElement;
const documensoImage = new Konva.Image({
image: img,
height: brandingHeight,
width: brandingHeight * (img.width / img.height),
x: text.width() + 16,
});
const qrSize = qrToken ? 72 : 0;
const logoGroup = new Konva.Group({
y: qrSize + 16,
});
logoGroup.add(text);
logoGroup.add(documensoImage);
branding.add(logoGroup);
if (qrToken) {
const qrSvg = renderSVG(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrToken}`, {
ecc: 'Q',
});
const svgImage = await svgToPng(qrSvg);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const qrSkiaImage = new SkiaImage(svgImage) as unknown as HTMLImageElement;
const qrImage = new Konva.Image({
image: qrSkiaImage,
height: qrSize,
width: qrSize,
x: branding.getClientRect().width - qrSize,
y: 0,
});
branding.add(qrImage);
}
return branding;
};
type GroupRowsIntoPagesOptions = {
recipients: CertificateRecipient[];
maxHeight: number;
i18n: I18n;
columnWidths: ColumnWidths;
envelopeOwner: {
name: string;
email: string;
};
};
const groupRowsIntoPages = (options: GroupRowsIntoPagesOptions) => {
const { recipients, maxHeight, i18n, columnWidths, envelopeOwner } = options;
const rowHeader = renderRowHeader({ columnWidths, i18n });
const rowHeaderHeight = rowHeader.getClientRect().height;
const groupedRows: Konva.Group[][] = [[]];
let availablePageHeight = maxHeight - rowHeaderHeight;
let currentGroupedRowIndex = 0;
// Group rows into pages.
for (const recipient of recipients) {
const row = renderRow({ recipient, columnWidths, i18n, envelopeOwner });
const rowHeight = row.getClientRect().height;
if (rowHeight > availablePageHeight) {
currentGroupedRowIndex++;
groupedRows[currentGroupedRowIndex] = [row];
availablePageHeight = maxHeight - rowHeaderHeight;
} else {
groupedRows[currentGroupedRowIndex].push(row);
}
// Reduce available height by the row height.
availablePageHeight -= rowHeight;
}
return groupedRows;
};
type RenderTablesOptions = {
groupedRows: Konva.Group[][];
columnWidths: ColumnWidths;
i18n: I18n;
};
const renderTables = (options: RenderTablesOptions) => {
const { groupedRows, columnWidths, i18n } = options;
const tables: Konva.Group[] = [];
// Render the rows for each page.
for (const rows of groupedRows) {
const table = new Konva.Group();
const tableHeader = renderRowHeader({ columnWidths, i18n });
table.add(tableHeader);
for (const row of rows) {
row.setAttrs({
x: 0,
y: table.getClientRect().height,
} satisfies Partial<Konva.GroupConfig>);
table.add(row);
}
// Add table background and border.
const tableClientRect = table.getClientRect();
const cardRect = new Konva.Rect({
x: tableClientRect.x,
y: tableClientRect.y,
width: tableClientRect.width,
height: tableClientRect.height,
stroke: '#e5e7eb',
strokeWidth: 1.5,
cornerRadius: 8,
});
table.add(cardRect);
tables.push(table);
}
return tables;
};
export async function renderCertificate({
recipients,
qrToken,
hidePoweredBy,
i18n,
envelopeOwner,
pageWidth,
pageHeight,
}: GenerateCertificateOptions) {
const fontPath = path.join(process.cwd(), 'public/fonts');
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
});
const minimumMargin = 10;
const tableWidth = Math.min(pageWidth - minimumMargin * 2, contentMaxWidth);
const tableContentWidth = tableWidth - rowPadding * 2;
const margin = (pageWidth - tableWidth) / 2;
const columnOneWidth = (tableContentWidth * columnWidthPercentages[0]) / 100;
const columnTwoWidth = (tableContentWidth * columnWidthPercentages[1]) / 100;
const columnThreeWidth = (tableContentWidth * columnWidthPercentages[2]) / 100;
const columnWidths: ColumnWidths = [columnOneWidth, columnTwoWidth, columnThreeWidth];
// Helper to render a Konva stage to a PNG buffer
let stage: Konva.Stage | null = new Konva.Stage({ width: pageWidth, height: pageHeight });
const maxTableHeight = pageHeight - pageTopMargin - pageBottomMargin;
const groupedRows = groupRowsIntoPages({
recipients,
maxHeight: maxTableHeight,
columnWidths,
i18n,
envelopeOwner,
});
const tables = renderTables({ groupedRows, columnWidths, i18n });
const brandingGroup = await renderBranding({ qrToken, i18n });
const brandingRect = brandingGroup.getClientRect();
const brandingTopPadding = 24;
const pages: Uint8Array[] = [];
let isQrPlaced = false;
// Add a table to each page.
for (const [index, table] of tables.entries()) {
stage.destroyChildren();
const page = new Konva.Layer();
const group = new Konva.Group();
const titleText = new Konva.Text({
x: margin,
y: 0,
height: pageTopMargin,
verticalAlign: 'middle',
text: i18n._(msg`Signing Certificate`),
fontFamily: 'Inter',
fontSize: titleFontSize,
fontStyle: '700',
});
table.setAttrs({
x: margin,
y: pageTopMargin,
} satisfies Partial<Konva.GroupConfig>);
group.add(titleText);
group.add(table);
// Add QR code and branding on the last page if there is space.
if (index === tables.length - 1 && !hidePoweredBy) {
const remainingHeight = pageHeight - group.getClientRect().height - pageBottomMargin;
if (brandingRect.height + brandingTopPadding <= remainingHeight) {
brandingGroup.setAttrs({
x: pageWidth - brandingRect.width - margin,
y: group.getClientRect().height + brandingTopPadding,
} satisfies Partial<Konva.GroupConfig>);
page.add(brandingGroup);
isQrPlaced = true;
}
}
page.add(group);
stage.add(page);
// Export the page and save it.
const canvas = page.canvas._canvas as unknown as Canvas; // eslint-disable-line @typescript-eslint/consistent-type-assertions
const buffer = await canvas.toBuffer('pdf');
pages.push(new Uint8Array(buffer));
}
// Need to create an empty page for the QR code if it hasn't been placed yet.
if (!hidePoweredBy && !isQrPlaced) {
const page = new Konva.Layer();
brandingGroup.setAttrs({
x: pageWidth - brandingRect.width - margin,
y: pageTopMargin / 2, // Less padding since there's nothing else on this page.
} satisfies Partial<Konva.GroupConfig>);
page.add(brandingGroup);
stage.add(page);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const canvas = page.canvas._canvas as unknown as Canvas;
const buffer = await canvas.toBuffer('pdf');
pages.push(new Uint8Array(buffer));
}
stage.destroy();
stage = null;
return pages;
}
+7 -1
View File
@@ -1,4 +1,9 @@
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import {
OrganisationGroupType,
OrganisationMemberRole,
Prisma,
TeamMemberRole,
} from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -137,6 +142,7 @@ export const createTeam = async ({
const teamSettings = await tx.teamGlobalSettings.create({
data: {
...generateDefaultTeamSettings(),
defaultRecipients: Prisma.DbNull,
id: generateDatabaseId('team_setting'),
},
});
@@ -19,9 +19,11 @@ import { prisma } from '@documenso/prisma';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { ZDefaultRecipientsSchema } from '../../types/default-recipients';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type { TDocumentFormValues } from '../../types/document-form-values';
import type {
TCheckboxFieldMeta,
TDropdownFieldMeta,
@@ -42,7 +44,7 @@ import {
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
@@ -55,6 +57,7 @@ import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -117,6 +120,8 @@ export type CreateDocumentFromTemplateOptions = {
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
};
formValues?: TDocumentFormValues;
requestMetadata: ApiRequestMetadata;
};
@@ -303,6 +308,7 @@ export const createDocumentFromTemplate = async ({
folderId,
prefillFields,
attachments,
formValues,
}: CreateDocumentFromTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
@@ -396,6 +402,30 @@ export const createDocumentFromTemplate = async ({
};
});
const defaultRecipients = settings.defaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const defaultRecipientsFinal: FinalRecipient[] = defaultRecipients.map((recipient) => {
const authOptions = ZRecipientAuthOptionsSchema.parse({});
return {
templateRecipientId: -1,
fields: [],
name: recipient.name || recipient.email,
email: recipient.email,
role: recipient.role,
signingOrder: null,
authOptions: createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: authOptions.actionAuth,
}),
token: nanoid(),
};
});
const allFinalRecipients = [...finalRecipients, ...defaultRecipientsFinal];
// Key = original envelope item ID
// Value = duplicated envelope item ID.
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
@@ -431,11 +461,19 @@ export const createDocumentFromTemplate = async ({
});
}
const buffer = await getFileServerSide(documentDataToDuplicate);
let buffer = await getFileServerSide(documentDataToDuplicate);
const titleToUse = item.title || finalEnvelopeTitle;
const duplicatedFile = await putPdfFileServerSide({
if (formValues) {
// eslint-disable-next-line require-atomic-updates
buffer = await insertFormValuesInPdf({
pdf: Buffer.from(buffer),
formValues,
});
}
const duplicatedFile = await putNormalizedPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(buffer),
@@ -445,7 +483,7 @@ export const createDocumentFromTemplate = async ({
data: {
type: duplicatedFile.type,
data: duplicatedFile.data,
initialData: duplicatedFile.initialData,
initialData: documentDataToDuplicate.data,
},
});
@@ -515,7 +553,7 @@ export const createDocumentFromTemplate = async ({
documentMetaId: documentMeta.id,
recipients: {
createMany: {
data: finalRecipients.map((recipient) => {
data: allFinalRecipients.map((recipient) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
return {
@@ -596,7 +634,7 @@ export const createDocumentFromTemplate = async ({
}
}
Object.values(finalRecipients).forEach(({ token, fields }) => {
Object.values(allFinalRecipients).forEach(({ token, fields }) => {
const recipient = envelope.recipients.find((recipient) => recipient.token === token);
if (!recipient) {
+131 -61
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-12-15 02:39\n"
"PO-Revision-Date: 2026-01-13 02:39\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -244,6 +244,7 @@ msgid "{0} Teams"
msgstr "{0} Teams"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "{browserInfo} on {os}"
msgstr "{browserInfo} auf {os}"
@@ -475,6 +476,7 @@ msgid "{teamName} has invited you to {action} {documentName}"
msgstr "{teamName} hat Sie eingeladen, {action} {documentName}"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "{userAgent}"
msgstr "{userAgent}"
@@ -955,6 +957,7 @@ msgid "Account"
msgstr "Konto"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Account Authentication"
msgstr "Kontowauthentifizierung"
@@ -992,6 +995,7 @@ msgid "Account Linking Request"
msgstr "Kontoverknüpfungsanfrage"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Account Re-Authentication"
msgstr "Kontowiederauthentifizierung"
@@ -1287,6 +1291,7 @@ msgid "Admin Panel"
msgstr "Admin-Panel"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
msgid "Admins only"
msgstr "Nur Admins"
@@ -1939,6 +1944,7 @@ msgid "Attempts sealing the document again, useful for after a code change has o
msgstr "Versuche, das Dokument erneut zu versiegeln, nützlich nach einer Codeänderung, um ein fehlerhaftes Dokument zu beheben."
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Audit Log"
msgstr "Audit-Protokoll"
@@ -1947,6 +1953,7 @@ msgid "Audit Logs"
msgstr "Protokolle"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Authentication Level"
msgstr "Authentifizierungsstufe"
@@ -2572,6 +2579,7 @@ msgid "Configure the team roles for each member"
msgstr "Konfigurieren Sie die Teamrollen für jedes Mitglied"
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "Confirm"
msgstr "Bestätigen"
@@ -2967,6 +2975,7 @@ msgstr "Erstellt"
#: apps/remix/app/components/tables/admin-organisations-table.tsx
#: apps/remix/app/components/tables/user-organisations-table.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Created At"
msgstr "Erstellt am"
@@ -3111,7 +3120,7 @@ msgstr "Standardwert"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Delegate Document Ownership"
msgstr ""
msgstr "Dokumenteigentum delegieren"
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
msgid "delete"
@@ -3259,6 +3268,7 @@ msgstr "Löschen Sie Ihr Konto und alle Inhalte, einschließlich abgeschlossener
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Deleted"
msgstr "Gelöscht"
@@ -3272,6 +3282,7 @@ msgstr "Ziel"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Details"
msgstr "Einzelheiten"
@@ -3328,6 +3339,7 @@ msgstr "Entwicklermodus"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Device"
msgstr "Gerät"
@@ -3679,7 +3691,7 @@ msgstr "Dokument geöffnet"
#: packages/lib/utils/document-audit-logs.ts
msgctxt "Audit log format"
msgid "Document ownership delegated"
msgstr ""
msgstr "Dokumenteigentum delegiert"
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document pending"
@@ -3724,6 +3736,7 @@ msgctxt "Audit log format"
msgid "Document sent"
msgstr "Dokument gesendet"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx
msgid "Document Settings"
msgstr "Dokumenteneinstellungen"
@@ -4090,6 +4103,8 @@ msgstr "Offenlegung der elektronischen Unterschrift"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/constants/document.ts
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
#: packages/lib/utils/fields.ts
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4120,10 +4135,6 @@ msgstr "E-Mail bereits bestätigt"
msgid "Email already exists"
msgstr "E-Mail existiert bereits"
#: apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx
msgid "Email cannot already exist in the template"
msgstr "E-Mail darf nicht bereits in der Vorlage vorhanden sein"
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Email Confirmed!"
msgstr "E-Mail bestätigt!"
@@ -4290,7 +4301,7 @@ msgstr "SSO-Portal aktivieren"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Enable team API tokens to delegate document ownership to another team member."
msgstr ""
msgstr "Team-API-Tokens aktivieren, um das Dokumenteigentum an ein anderes Teammitglied zu delegieren."
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
@@ -4309,6 +4320,18 @@ msgstr "Das Aktivieren des Kontos führt dazu, dass der Benutzer das Konto wiede
msgid "Enclosed Document"
msgstr "Beigefügte Dokument"
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Enclosed Documents"
msgstr "Beigefügte Dokumente"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Enter"
msgstr "Eingeben"
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Geben Sie einen Namen für Ihren neuen Ordner ein. Ordner helfen Ihnen, Ihre Dateien zu organisieren."
@@ -4317,6 +4340,26 @@ msgstr "Geben Sie einen Namen für Ihren neuen Ordner ein. Ordner helfen Ihnen,
msgid "Enter claim name"
msgstr "Anspruchsname eingeben"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Enter Email"
msgstr "E-Mail eingeben"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Enter Initials"
msgstr "Initialen eingeben"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Enter Name"
msgstr "Namen eingeben"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Enter Number"
msgstr "Nummer eingeben"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Enter Text"
msgstr "Text eingeben"
#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx
msgid "Enter the domain you want to use for sending emails (without http:// or www)"
msgstr "Geben Sie die Domain ein, die Sie zum Versenden von E-Mails verwenden möchten (ohne http:// oder www)"
@@ -4384,6 +4427,7 @@ msgid "Envelope Duplicated"
msgstr "Umschlag dupliziert"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Envelope ID"
msgstr "Umschlag-ID"
@@ -4510,6 +4554,7 @@ msgid "Event Type"
msgstr "Ereignistyp"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
#: packages/ui/components/document/document-visibility-select.tsx
msgid "Everyone"
msgstr "Jeder"
@@ -5081,6 +5126,7 @@ msgid "I am required to receive a copy of this document"
msgstr "Ich bin verpflichtet, eine Kopie dieses Dokuments zu erhalten"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "I am the owner of this document"
msgstr "Ich bin der Besitzer dieses Dokuments"
@@ -5208,14 +5254,6 @@ msgstr "Initialen sind erforderlich"
msgid "Initials Settings"
msgstr "Einstellungen für Initialen"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Insert a value into the number field"
msgstr "Fügen Sie einen Wert in das Zahlenfeld ein."
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Insert a value into the text field"
msgstr "Fügen Sie einen Wert in das Textfeld ein."
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
msgid "Inserted"
msgstr "Eingefügt"
@@ -5308,6 +5346,8 @@ msgstr "Rechnung"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "IP Address"
msgstr "IP-Adresse"
@@ -5420,6 +5460,7 @@ msgid "Last updated"
msgstr "Zuletzt aktualisiert"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Last Updated"
msgstr "Zuletzt aktualisiert"
@@ -5632,7 +5673,7 @@ msgstr "Organisation verwalten"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
msgid "Manage Organisation"
msgstr ""
msgstr "Organisation verwalten"
#: apps/remix/app/routes/_authenticated+/admin+/organisations._index.tsx
msgid "Manage organisations"
@@ -5721,6 +5762,7 @@ msgid "Manager"
msgstr "Manager"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
msgid "Managers and above"
msgstr "Manager und höher"
@@ -5881,6 +5923,7 @@ msgid "My Folder"
msgstr "Mein Ordner"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "N/A"
msgstr "N/A"
@@ -6115,6 +6158,10 @@ msgstr "Keine Sorge, das passiert! Geben Sie Ihre E-Mail ein, und wir senden Ihn
msgid "None"
msgstr "Keine"
#: packages/lib/constants/document-auth.ts
msgid "None (Overrides global settings)"
msgstr "Keine (überschreibt globale Einstellungen)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
@@ -6407,6 +6454,7 @@ msgstr "Organisationseinstellungen überschreiben"
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Owner"
msgstr "Besitzer"
@@ -6457,6 +6505,7 @@ msgid "Passkey name"
msgstr "Passkey-Name"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Passkey Re-Authentication"
msgstr "Passwortwiederauthentifizierung"
@@ -6483,6 +6532,7 @@ msgid "Password"
msgstr "Passwort"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Password Re-Authentication"
msgstr "Passwort-Neuauthentifizierung"
@@ -6675,6 +6725,10 @@ msgstr "Bitte kontaktieren Sie den Webseiteninhaber für weitere Unterstützung.
msgid "Please enter a meaningful name for your token. This will help you identify it later."
msgstr "Bitte geben Sie einen aussagekräftigen Namen für Ihr Token ein. Dies wird Ihnen helfen, es später zu identifizieren."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a number"
msgstr "Bitte gib eine Zahl ein"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -6685,6 +6739,22 @@ msgstr "Bitte geben Sie einen gültigen Namen ein."
msgid "Please enter a valid number"
msgstr "Bitte geben Sie eine gültige Nummer ein."
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Please enter a value"
msgstr "Bitte gib einen Wert ein"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Please enter your email address"
msgstr "Bitte gib deine E-Mail-Adresse ein"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Please enter your full name"
msgstr "Bitte gib deinen vollständigen Namen ein"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Please enter your initials"
msgstr "Bitte geben Sie Ihre Initialen ein"
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
msgid "Please mark as viewed to complete"
msgstr "Bitte als angesehen markieren, um zu vervollständigen"
@@ -6953,6 +7023,7 @@ msgid "Ready"
msgstr "Bereit"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Reason"
msgstr "Grund"
@@ -7039,6 +7110,7 @@ msgstr "Empfänger aktualisiert"
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Recipients"
msgstr "Empfänger"
@@ -7116,6 +7188,7 @@ msgstr "Dokument Ablehnen"
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/constants/document.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Rejected"
msgstr "Abgelehnt"
@@ -7226,6 +7299,22 @@ msgstr "Anfrage"
msgid "Requesting Organisation"
msgstr "Anfragende Organisation"
#: packages/lib/constants/document-auth.ts
msgid "Require 2FA"
msgstr "2FA erforderlich"
#: packages/lib/constants/document-auth.ts
msgid "Require account"
msgstr "Konto erforderlich"
#: packages/lib/constants/document-auth.ts
msgid "Require passkey"
msgstr "Passkey erforderlich"
#: packages/lib/constants/document-auth.ts
msgid "Require password"
msgstr "Passwort erforderlich"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx
@@ -7356,7 +7445,7 @@ msgstr "Zurück"
#: apps/remix/app/routes/_recipient+/sign.$token+/rejected.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
msgid "Return Home"
msgstr ""
msgstr "Zur Startseite zurückkehren"
#: apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
msgid "Return to Documenso sign in page here"
@@ -7638,6 +7727,10 @@ msgstr "Wählen Sie Mitglieder aus, die diesem Team hinzugefügt werden sollen"
msgid "Select Option"
msgstr "Option auswählen"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
msgid "Select Options"
msgstr "Optionen auswählen"
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
msgid "Select passkey"
msgstr "Passkey auswählen"
@@ -7787,6 +7880,7 @@ msgstr "Senden..."
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Sent"
msgstr "Gesendet"
@@ -7859,13 +7953,7 @@ msgstr "Erweiterte Einstellungen anzeigen"
msgid "Show templates in your public profile for your audience to sign and get started quickly"
msgstr "Vorlagen in Ihrem öffentlichen Profil anzeigen, damit Ihre Zielgruppe unterschreiben und schnell loslegen kann"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
@@ -7899,10 +7987,6 @@ msgstr "Unterzeichnen als {0} <0>({1})</0>"
msgid "Sign as<0>{0} <1>({1})</1></0>"
msgstr "Unterzeichnen als<0>{0} <1>({1})</1></0>"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
msgid "Sign Checkbox Field"
msgstr "Sign Checkbox-Feld"
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@@ -7919,10 +8003,6 @@ msgstr "Dokument signieren"
msgid "Sign Documents"
msgstr "Dokumente unterschreiben"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Sign Email"
msgstr "Signatur-E-Mail"
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
msgid "Sign field"
@@ -7945,18 +8025,6 @@ msgstr "Anmelden"
msgid "Sign in to your account"
msgstr "Melden Sie sich bei Ihrem Konto an"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Sign Initials"
msgstr "Initialen signieren"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Sign Name"
msgstr "Name signieren"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Sign Number Field"
msgstr "Zahlenfeld signieren"
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/menu-switcher.tsx
#: apps/remix/app/components/general/org-menu-switcher.tsx
@@ -7967,10 +8035,6 @@ msgstr "Ausloggen"
msgid "Sign Signature Field"
msgstr "Signaturfeld signieren"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Sign Text Field"
msgstr "Textfeld signieren"
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@@ -7993,18 +8057,6 @@ msgstr "Registrieren mit Microsoft"
msgid "Sign Up with OIDC"
msgstr "Registrieren mit OIDC"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Sign your email into the field"
msgstr "E-Mail ins Feld signieren"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Sign your full name into the field"
msgstr "Vollständigen Namen ins Feld signieren"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Sign your initials into the field"
msgstr "Initialen ins Feld signieren"
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
@@ -8017,6 +8069,7 @@ msgstr "Initialen ins Feld signieren"
#: apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/utils/fields.ts
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/ui/primitives/document-flow/types.ts
@@ -8025,6 +8078,7 @@ msgid "Signature"
msgstr "Unterschrift"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signature ID"
msgstr "Signatur-ID"
@@ -8050,6 +8104,7 @@ msgstr "Gesammelte Unterschriften"
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/ui/components/document/document-read-only-fields.tsx
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
msgid "Signed"
@@ -8075,6 +8130,7 @@ msgid "Signer"
msgstr "Unterzeichner"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signer Events"
msgstr "Signer-Ereignisse"
@@ -8089,10 +8145,12 @@ msgid "Signing"
msgstr "Unterzeichnung"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signing Certificate"
msgstr "Unterzeichnungszertifikat"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signing certificate provided by"
msgstr "Unterzeichnungszertifikat bereitgestellt von"
@@ -8300,6 +8358,7 @@ msgstr "Statistiken"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Status"
msgstr "Status"
@@ -8865,7 +8924,7 @@ msgstr "Der Dokumenteneigentümer wurde über Ihre Entscheidung informiert. Er k
#. placeholder {1}: data.teamName
#: packages/lib/utils/document-audit-logs.ts
msgid "The document ownership was delegated to {0} on behalf of {1}"
msgstr ""
msgstr "Das Dokumenteigentum wurde im Namen von {1} an {0} delegiert"
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
msgid "The document was created but could not be sent to recipients."
@@ -9442,6 +9501,7 @@ msgstr "Zeitzone"
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/ui/primitives/document-flow/add-settings.tsx
#: packages/ui/primitives/template-flow/add-template-settings.tsx
msgid "Time Zone"
@@ -9633,6 +9693,7 @@ msgstr "Wiederherstellungscodes für die Zwei-Faktor-Authentifizierung werden ve
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Two-Factor Authentication"
msgstr "Zwei-Faktor-Authentifizierung"
@@ -9649,6 +9710,7 @@ msgid "Two-factor authentication has been disabled for your account. You will no
msgstr "Die Zwei-Faktor-Authentifizierung wurde für Ihr Konto deaktiviert. Sie müssen beim Anmelden keinen Code aus Ihrer Authentifizierungs-App mehr eingeben."
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Two-Factor Re-Authentication"
msgstr "Zwei-Faktor-Wiederauthentifizierung"
@@ -9782,6 +9844,10 @@ msgstr "Rückgängig"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Unknown"
msgstr "Unbekannt"
@@ -10084,10 +10150,12 @@ msgstr "Verwenden Sie Ihren Passkey zur Authentifizierung"
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "User"
msgstr "Benutzer"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "User Agent"
msgstr "Benutzer-Agent"
@@ -10331,6 +10399,7 @@ msgstr "DNS-Datensätze für diese E-Mail-Domain anzeigen"
#: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Viewed"
msgstr "Betrachtet"
@@ -11654,3 +11723,4 @@ msgstr "Ihr Verifizierungscode:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+123 -54
View File
@@ -239,6 +239,7 @@ msgid "{0} Teams"
msgstr "{0} Teams"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "{browserInfo} on {os}"
msgstr "{browserInfo} on {os}"
@@ -470,6 +471,7 @@ msgid "{teamName} has invited you to {action} {documentName}"
msgstr "{teamName} has invited you to {action} {documentName}"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "{userAgent}"
msgstr "{userAgent}"
@@ -950,6 +952,7 @@ msgid "Account"
msgstr "Account"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Account Authentication"
msgstr "Account Authentication"
@@ -987,6 +990,7 @@ msgid "Account Linking Request"
msgstr "Account Linking Request"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Account Re-Authentication"
msgstr "Account Re-Authentication"
@@ -1282,6 +1286,7 @@ msgid "Admin Panel"
msgstr "Admin Panel"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
msgid "Admins only"
msgstr "Admins only"
@@ -1934,6 +1939,7 @@ msgid "Attempts sealing the document again, useful for after a code change has o
msgstr "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document."
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Audit Log"
msgstr "Audit Log"
@@ -1942,6 +1948,7 @@ msgid "Audit Logs"
msgstr "Audit Logs"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Authentication Level"
msgstr "Authentication Level"
@@ -2567,6 +2574,7 @@ msgid "Configure the team roles for each member"
msgstr "Configure the team roles for each member"
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "Confirm"
msgstr "Confirm"
@@ -2962,6 +2970,7 @@ msgstr "Created"
#: apps/remix/app/components/tables/admin-organisations-table.tsx
#: apps/remix/app/components/tables/user-organisations-table.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Created At"
msgstr "Created At"
@@ -3254,6 +3263,7 @@ msgstr "Delete your account and all its contents, including completed documents.
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Deleted"
msgstr "Deleted"
@@ -3267,6 +3277,7 @@ msgstr "Destination"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Details"
msgstr "Details"
@@ -3323,6 +3334,7 @@ msgstr "Developer Mode"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Device"
msgstr "Device"
@@ -3719,6 +3731,7 @@ msgctxt "Audit log format"
msgid "Document sent"
msgstr "Document sent"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx
msgid "Document Settings"
msgstr "Document Settings"
@@ -4085,6 +4098,8 @@ msgstr "Electronic Signature Disclosure"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/constants/document.ts
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
#: packages/lib/utils/fields.ts
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4115,10 +4130,6 @@ msgstr "Email already confirmed"
msgid "Email already exists"
msgstr "Email already exists"
#: apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx
msgid "Email cannot already exist in the template"
msgstr "Email cannot already exist in the template"
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Email Confirmed!"
msgstr "Email Confirmed!"
@@ -4304,6 +4315,18 @@ msgstr "Enabling the account results in the user being able to use the account a
msgid "Enclosed Document"
msgstr "Enclosed Document"
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Enclosed Documents"
msgstr "Enclosed Documents"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Enter"
msgstr "Enter"
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Enter a name for your new folder. Folders help you organise your items."
@@ -4312,6 +4335,26 @@ msgstr "Enter a name for your new folder. Folders help you organise your items."
msgid "Enter claim name"
msgstr "Enter claim name"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Enter Email"
msgstr "Enter Email"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Enter Initials"
msgstr "Enter Initials"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Enter Name"
msgstr "Enter Name"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Enter Number"
msgstr "Enter Number"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Enter Text"
msgstr "Enter Text"
#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx
msgid "Enter the domain you want to use for sending emails (without http:// or www)"
msgstr "Enter the domain you want to use for sending emails (without http:// or www)"
@@ -4379,6 +4422,7 @@ msgid "Envelope Duplicated"
msgstr "Envelope Duplicated"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Envelope ID"
msgstr "Envelope ID"
@@ -4505,6 +4549,7 @@ msgid "Event Type"
msgstr "Event Type"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
#: packages/ui/components/document/document-visibility-select.tsx
msgid "Everyone"
msgstr "Everyone"
@@ -5076,6 +5121,7 @@ msgid "I am required to receive a copy of this document"
msgstr "I am required to receive a copy of this document"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "I am the owner of this document"
msgstr "I am the owner of this document"
@@ -5203,14 +5249,6 @@ msgstr "Initials are required"
msgid "Initials Settings"
msgstr "Initials Settings"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Insert a value into the number field"
msgstr "Insert a value into the number field"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Insert a value into the text field"
msgstr "Insert a value into the text field"
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
msgid "Inserted"
msgstr "Inserted"
@@ -5303,6 +5341,8 @@ msgstr "Invoice"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "IP Address"
msgstr "IP Address"
@@ -5415,6 +5455,7 @@ msgid "Last updated"
msgstr "Last updated"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Last Updated"
msgstr "Last Updated"
@@ -5716,6 +5757,7 @@ msgid "Manager"
msgstr "Manager"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
msgid "Managers and above"
msgstr "Managers and above"
@@ -5876,6 +5918,7 @@ msgid "My Folder"
msgstr "My Folder"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "N/A"
msgstr "N/A"
@@ -6110,6 +6153,10 @@ msgstr "No worries, it happens! Enter your email and we'll email you a special l
msgid "None"
msgstr "None"
#: packages/lib/constants/document-auth.ts
msgid "None (Overrides global settings)"
msgstr "None (Overrides global settings)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
@@ -6402,6 +6449,7 @@ msgstr "Override organisation settings"
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Owner"
msgstr "Owner"
@@ -6452,6 +6500,7 @@ msgid "Passkey name"
msgstr "Passkey name"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Passkey Re-Authentication"
msgstr "Passkey Re-Authentication"
@@ -6478,6 +6527,7 @@ msgid "Password"
msgstr "Password"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Password Re-Authentication"
msgstr "Password Re-Authentication"
@@ -6670,6 +6720,10 @@ msgstr "Please contact the site owner for further assistance."
msgid "Please enter a meaningful name for your token. This will help you identify it later."
msgstr "Please enter a meaningful name for your token. This will help you identify it later."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a number"
msgstr "Please enter a number"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -6680,6 +6734,22 @@ msgstr "Please enter a valid name."
msgid "Please enter a valid number"
msgstr "Please enter a valid number"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Please enter a value"
msgstr "Please enter a value"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Please enter your email address"
msgstr "Please enter your email address"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Please enter your full name"
msgstr "Please enter your full name"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Please enter your initials"
msgstr "Please enter your initials"
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
msgid "Please mark as viewed to complete"
msgstr "Please mark as viewed to complete"
@@ -6948,6 +7018,7 @@ msgid "Ready"
msgstr "Ready"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Reason"
msgstr "Reason"
@@ -7034,6 +7105,7 @@ msgstr "Recipient updated"
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Recipients"
msgstr "Recipients"
@@ -7111,6 +7183,7 @@ msgstr "Reject Document"
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/constants/document.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Rejected"
msgstr "Rejected"
@@ -7221,6 +7294,22 @@ msgstr "Request"
msgid "Requesting Organisation"
msgstr "Requesting Organisation"
#: packages/lib/constants/document-auth.ts
msgid "Require 2FA"
msgstr "Require 2FA"
#: packages/lib/constants/document-auth.ts
msgid "Require account"
msgstr "Require account"
#: packages/lib/constants/document-auth.ts
msgid "Require passkey"
msgstr "Require passkey"
#: packages/lib/constants/document-auth.ts
msgid "Require password"
msgstr "Require password"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx
@@ -7633,6 +7722,10 @@ msgstr "Select members to add to this team"
msgid "Select Option"
msgstr "Select Option"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
msgid "Select Options"
msgstr "Select Options"
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
msgid "Select passkey"
msgstr "Select passkey"
@@ -7782,6 +7875,7 @@ msgstr "Sending..."
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Sent"
msgstr "Sent"
@@ -7854,13 +7948,7 @@ msgstr "Show advanced settings"
msgid "Show templates in your public profile for your audience to sign and get started quickly"
msgstr "Show templates in your public profile for your audience to sign and get started quickly"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
@@ -7894,10 +7982,6 @@ msgstr "Sign as {0} <0>({1})</0>"
msgid "Sign as<0>{0} <1>({1})</1></0>"
msgstr "Sign as<0>{0} <1>({1})</1></0>"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
msgid "Sign Checkbox Field"
msgstr "Sign Checkbox Field"
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@@ -7914,10 +7998,6 @@ msgstr "Sign Document"
msgid "Sign Documents"
msgstr "Sign Documents"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Sign Email"
msgstr "Sign Email"
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
msgid "Sign field"
@@ -7940,18 +8020,6 @@ msgstr "Sign In"
msgid "Sign in to your account"
msgstr "Sign in to your account"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Sign Initials"
msgstr "Sign Initials"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Sign Name"
msgstr "Sign Name"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Sign Number Field"
msgstr "Sign Number Field"
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/menu-switcher.tsx
#: apps/remix/app/components/general/org-menu-switcher.tsx
@@ -7962,10 +8030,6 @@ msgstr "Sign Out"
msgid "Sign Signature Field"
msgstr "Sign Signature Field"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Sign Text Field"
msgstr "Sign Text Field"
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@@ -7988,18 +8052,6 @@ msgstr "Sign Up with Microsoft"
msgid "Sign Up with OIDC"
msgstr "Sign Up with OIDC"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Sign your email into the field"
msgstr "Sign your email into the field"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Sign your full name into the field"
msgstr "Sign your full name into the field"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Sign your initials into the field"
msgstr "Sign your initials into the field"
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
@@ -8012,6 +8064,7 @@ msgstr "Sign your initials into the field"
#: apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/utils/fields.ts
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/ui/primitives/document-flow/types.ts
@@ -8020,6 +8073,7 @@ msgid "Signature"
msgstr "Signature"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signature ID"
msgstr "Signature ID"
@@ -8045,6 +8099,7 @@ msgstr "Signatures Collected"
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/ui/components/document/document-read-only-fields.tsx
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
msgid "Signed"
@@ -8070,6 +8125,7 @@ msgid "Signer"
msgstr "Signer"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signer Events"
msgstr "Signer Events"
@@ -8084,10 +8140,12 @@ msgid "Signing"
msgstr "Signing"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signing Certificate"
msgstr "Signing Certificate"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signing certificate provided by"
msgstr "Signing certificate provided by"
@@ -8295,6 +8353,7 @@ msgstr "Stats"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Status"
msgstr "Status"
@@ -9437,6 +9496,7 @@ msgstr "Time zone"
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/ui/primitives/document-flow/add-settings.tsx
#: packages/ui/primitives/template-flow/add-template-settings.tsx
msgid "Time Zone"
@@ -9628,6 +9688,7 @@ msgstr "Two factor authentication recovery codes are used to access your account
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Two-Factor Authentication"
msgstr "Two-Factor Authentication"
@@ -9644,6 +9705,7 @@ msgid "Two-factor authentication has been disabled for your account. You will no
msgstr "Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in."
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Two-Factor Re-Authentication"
msgstr "Two-Factor Re-Authentication"
@@ -9777,6 +9839,10 @@ msgstr "Undo"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Unknown"
msgstr "Unknown"
@@ -10079,10 +10145,12 @@ msgstr "Use your passkey for authentication"
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "User"
msgstr "User"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "User Agent"
msgstr "User Agent"
@@ -10326,6 +10394,7 @@ msgstr "View the DNS records for this email domain"
#: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Viewed"
msgstr "Viewed"
+131 -61
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: es\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-12-15 02:39\n"
"PO-Revision-Date: 2026-01-13 02:39\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -244,6 +244,7 @@ msgid "{0} Teams"
msgstr "{0} Equipos"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "{browserInfo} on {os}"
msgstr "{browserInfo} en {os}"
@@ -475,6 +476,7 @@ msgid "{teamName} has invited you to {action} {documentName}"
msgstr "{teamName} te ha invitado a {action} {documentName}"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "{userAgent}"
msgstr "{userAgent}"
@@ -955,6 +957,7 @@ msgid "Account"
msgstr "Cuenta"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Account Authentication"
msgstr "Autenticación de Cuenta"
@@ -992,6 +995,7 @@ msgid "Account Linking Request"
msgstr "Solicitud de Vinculación de Cuenta"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Account Re-Authentication"
msgstr "Re-autenticación de Cuenta"
@@ -1287,6 +1291,7 @@ msgid "Admin Panel"
msgstr "Panel de Administración"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
msgid "Admins only"
msgstr "Solo para administradores"
@@ -1939,6 +1944,7 @@ msgid "Attempts sealing the document again, useful for after a code change has o
msgstr "Intenta sellar el documento de nuevo, útil después de que se haya producido un cambio de código para resolver un documento erróneo."
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Audit Log"
msgstr "Registro de Auditoría"
@@ -1947,6 +1953,7 @@ msgid "Audit Logs"
msgstr "Registros de Auditoría"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Authentication Level"
msgstr "Nivel de Autenticación"
@@ -2572,6 +2579,7 @@ msgid "Configure the team roles for each member"
msgstr "Configura los roles del equipo para cada miembro"
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "Confirm"
msgstr "Confirmar"
@@ -2967,6 +2975,7 @@ msgstr "Creado"
#: apps/remix/app/components/tables/admin-organisations-table.tsx
#: apps/remix/app/components/tables/user-organisations-table.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Created At"
msgstr "Creado En"
@@ -3111,7 +3120,7 @@ msgstr "Valor Predeterminado"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Delegate Document Ownership"
msgstr ""
msgstr "Delegar la propiedad del documento"
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
msgid "delete"
@@ -3259,6 +3268,7 @@ msgstr "Eliminar su cuenta y todo su contenido, incluidos documentos completados
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Deleted"
msgstr "Eliminado"
@@ -3272,6 +3282,7 @@ msgstr "Destino"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Details"
msgstr "Detalles"
@@ -3328,6 +3339,7 @@ msgstr "Modo desarrollador"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Device"
msgstr "Dispositivo"
@@ -3679,7 +3691,7 @@ msgstr "Documento abierto"
#: packages/lib/utils/document-audit-logs.ts
msgctxt "Audit log format"
msgid "Document ownership delegated"
msgstr ""
msgstr "Propiedad del documento delegada"
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document pending"
@@ -3724,6 +3736,7 @@ msgctxt "Audit log format"
msgid "Document sent"
msgstr "Documento enviado"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx
msgid "Document Settings"
msgstr "Configuración del Documento"
@@ -4090,6 +4103,8 @@ msgstr "Divulgación de Firma Electrónica"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/constants/document.ts
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
#: packages/lib/utils/fields.ts
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4120,10 +4135,6 @@ msgstr "Correo electrónico ya confirmado"
msgid "Email already exists"
msgstr "El correo ya existe"
#: apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx
msgid "Email cannot already exist in the template"
msgstr "El correo electrónico no puede existir ya en la plantilla"
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Email Confirmed!"
msgstr "¡Correo electrónico confirmado!"
@@ -4290,7 +4301,7 @@ msgstr "Habilitar portal SSO"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Enable team API tokens to delegate document ownership to another team member."
msgstr ""
msgstr "Habilita los tokens de API del equipo para delegar la propiedad del documento a otro miembro del equipo."
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
@@ -4309,6 +4320,18 @@ msgstr "Habilitar la cuenta permite al usuario usar la cuenta de nuevo, junto co
msgid "Enclosed Document"
msgstr "Documento Adjunto"
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Enclosed Documents"
msgstr "Documentos adjuntos"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Enter"
msgstr "Ingresar"
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Ingrese un nombre para su nueva carpeta. Las carpetas le ayudan a organizar sus elementos."
@@ -4317,6 +4340,26 @@ msgstr "Ingrese un nombre para su nueva carpeta. Las carpetas le ayudan a organi
msgid "Enter claim name"
msgstr "Ingresar nombre de la reclamación"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Enter Email"
msgstr "Ingresar correo electrónico"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Enter Initials"
msgstr "Ingresar iniciales"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Enter Name"
msgstr "Ingresar nombre"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Enter Number"
msgstr "Ingresar número"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Enter Text"
msgstr "Ingresar texto"
#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx
msgid "Enter the domain you want to use for sending emails (without http:// or www)"
msgstr "Ingresa el dominio que deseas usar para enviar correos electrónicos (sin http:// ni www)"
@@ -4384,6 +4427,7 @@ msgid "Envelope Duplicated"
msgstr "Sobre Duplicado"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Envelope ID"
msgstr "ID de Sobre"
@@ -4510,6 +4554,7 @@ msgid "Event Type"
msgstr "Tipo de evento"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
#: packages/ui/components/document/document-visibility-select.tsx
msgid "Everyone"
msgstr "Todos"
@@ -5081,6 +5126,7 @@ msgid "I am required to receive a copy of this document"
msgstr "Se me requiere recibir una copia de este documento"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "I am the owner of this document"
msgstr "Soy el propietario de este documento"
@@ -5208,14 +5254,6 @@ msgstr "Se requieren iniciales"
msgid "Initials Settings"
msgstr "Configuración de Iniciales"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Insert a value into the number field"
msgstr "Introduce un valor en el campo de número"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Insert a value into the text field"
msgstr "Introduce un valor en el campo de texto"
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
msgid "Inserted"
msgstr "Insertado"
@@ -5308,6 +5346,8 @@ msgstr "Factura"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "IP Address"
msgstr "Dirección IP"
@@ -5420,6 +5460,7 @@ msgid "Last updated"
msgstr "Última actualización"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Last Updated"
msgstr "Última Actualización"
@@ -5632,7 +5673,7 @@ msgstr "Administrar organización"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
msgid "Manage Organisation"
msgstr ""
msgstr "Gestionar organización"
#: apps/remix/app/routes/_authenticated+/admin+/organisations._index.tsx
msgid "Manage organisations"
@@ -5721,6 +5762,7 @@ msgid "Manager"
msgstr "Gerente"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
msgid "Managers and above"
msgstr "Gerentes y superiores"
@@ -5881,6 +5923,7 @@ msgid "My Folder"
msgstr "Mi Carpeta"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "N/A"
msgstr "N/A"
@@ -6115,6 +6158,10 @@ msgstr "¡No te preocupes, sucede! Ingresa tu correo electrónico y te enviaremo
msgid "None"
msgstr "Ninguno"
#: packages/lib/constants/document-auth.ts
msgid "None (Overrides global settings)"
msgstr "Ninguno (anula la configuración global)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
@@ -6407,6 +6454,7 @@ msgstr "Anular la configuración de la organización"
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Owner"
msgstr "Propietario"
@@ -6457,6 +6505,7 @@ msgid "Passkey name"
msgstr "Nombre de clave de acceso"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Passkey Re-Authentication"
msgstr "Re-autenticación de Passkey"
@@ -6483,6 +6532,7 @@ msgid "Password"
msgstr "Contraseña"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Password Re-Authentication"
msgstr "Reautenticación de contraseña"
@@ -6675,6 +6725,10 @@ msgstr "Por favor, contacte al propietario del sitio para más asistencia."
msgid "Please enter a meaningful name for your token. This will help you identify it later."
msgstr "Por favor, ingresa un nombre significativo para tu token. Esto te ayudará a identificarlo más tarde."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a number"
msgstr "Por favor ingresa un número"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -6685,6 +6739,22 @@ msgstr "Por favor, introduce un nombre válido."
msgid "Please enter a valid number"
msgstr "Por favor, ingresa un número válido"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Please enter a value"
msgstr "Por favor ingresa un valor"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Please enter your email address"
msgstr "Por favor ingresa tu dirección de correo electrónico"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Please enter your full name"
msgstr "Por favor ingresa tu nombre completo"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Please enter your initials"
msgstr "Por favor, introduzca sus iniciales"
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
msgid "Please mark as viewed to complete"
msgstr "Por favor marque como visto para completar"
@@ -6953,6 +7023,7 @@ msgid "Ready"
msgstr "Listo"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Reason"
msgstr "Razón"
@@ -7039,6 +7110,7 @@ msgstr "Destinatario actualizado"
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Recipients"
msgstr "Destinatarios"
@@ -7116,6 +7188,7 @@ msgstr "Rechazar Documento"
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/constants/document.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Rejected"
msgstr "Rejected"
@@ -7226,6 +7299,22 @@ msgstr "Solicitud"
msgid "Requesting Organisation"
msgstr "Organización solicitante"
#: packages/lib/constants/document-auth.ts
msgid "Require 2FA"
msgstr "Requerir 2FA"
#: packages/lib/constants/document-auth.ts
msgid "Require account"
msgstr "Requerir cuenta"
#: packages/lib/constants/document-auth.ts
msgid "Require passkey"
msgstr "Requerir llave de acceso"
#: packages/lib/constants/document-auth.ts
msgid "Require password"
msgstr "Requerir contraseña"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx
@@ -7356,7 +7445,7 @@ msgstr "Regresar"
#: apps/remix/app/routes/_recipient+/sign.$token+/rejected.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
msgid "Return Home"
msgstr ""
msgstr "Volver al inicio"
#: apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
msgid "Return to Documenso sign in page here"
@@ -7638,6 +7727,10 @@ msgstr "Seleccione los miembros para añadir a este equipo"
msgid "Select Option"
msgstr "Seleccionar una opción"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
msgid "Select Options"
msgstr "Seleccionar opciones"
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
msgid "Select passkey"
msgstr "Seleccionar clave de acceso"
@@ -7787,6 +7880,7 @@ msgstr "Enviando..."
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Sent"
msgstr "Enviado"
@@ -7859,13 +7953,7 @@ msgstr "Mostrar configuraciones avanzadas"
msgid "Show templates in your public profile for your audience to sign and get started quickly"
msgstr "Mostrar plantillas en tu perfil público para que tu audiencia firme y comience rápidamente"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
@@ -7899,10 +7987,6 @@ msgstr "Firmar como {0} <0>({1})</0>"
msgid "Sign as<0>{0} <1>({1})</1></0>"
msgstr "Firmar como<0>{0} <1>({1})</1></0>"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
msgid "Sign Checkbox Field"
msgstr "Campo de casilla de verificación de firma"
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@@ -7919,10 +8003,6 @@ msgstr "Firmar Documento"
msgid "Sign Documents"
msgstr "Firmar documentos"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Sign Email"
msgstr "Correo de firma"
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
msgid "Sign field"
@@ -7945,18 +8025,6 @@ msgstr "Iniciar sesión"
msgid "Sign in to your account"
msgstr "Inicia sesión en tu cuenta"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Sign Initials"
msgstr "Iniciales de firma"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Sign Name"
msgstr "Nombre de firma"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Sign Number Field"
msgstr "Campo de número de firma"
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/menu-switcher.tsx
#: apps/remix/app/components/general/org-menu-switcher.tsx
@@ -7967,10 +8035,6 @@ msgstr "Cerrar sesión"
msgid "Sign Signature Field"
msgstr "Campo de firma Firma"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Sign Text Field"
msgstr "Campo de texto de firma"
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@@ -7993,18 +8057,6 @@ msgstr "Registrarse con Microsoft"
msgid "Sign Up with OIDC"
msgstr "Regístrate con OIDC"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Sign your email into the field"
msgstr "Firme su correo electrónico en el campo"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Sign your full name into the field"
msgstr "Firme su nombre completo en el campo"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Sign your initials into the field"
msgstr "Firme sus iniciales en el campo"
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
@@ -8017,6 +8069,7 @@ msgstr "Firme sus iniciales en el campo"
#: apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/utils/fields.ts
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/ui/primitives/document-flow/types.ts
@@ -8025,6 +8078,7 @@ msgid "Signature"
msgstr "Firma"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signature ID"
msgstr "ID de Firma"
@@ -8050,6 +8104,7 @@ msgstr "Firmas recolectadas"
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/ui/components/document/document-read-only-fields.tsx
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
msgid "Signed"
@@ -8075,6 +8130,7 @@ msgid "Signer"
msgstr "Firmante"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signer Events"
msgstr "Eventos del Firmante"
@@ -8089,10 +8145,12 @@ msgid "Signing"
msgstr "Firmando"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signing Certificate"
msgstr "Certificado de Firma"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signing certificate provided by"
msgstr "Certificado de firma proporcionado por"
@@ -8300,6 +8358,7 @@ msgstr "Estadísticas"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Status"
msgstr "Estado"
@@ -8865,7 +8924,7 @@ msgstr "The document owner has been notified of your decision. They may contact
#. placeholder {1}: data.teamName
#: packages/lib/utils/document-audit-logs.ts
msgid "The document ownership was delegated to {0} on behalf of {1}"
msgstr ""
msgstr "La propiedad del documento fue delegada a {0} en nombre de {1}"
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
msgid "The document was created but could not be sent to recipients."
@@ -9442,6 +9501,7 @@ msgstr "Zona horaria"
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/ui/primitives/document-flow/add-settings.tsx
#: packages/ui/primitives/template-flow/add-template-settings.tsx
msgid "Time Zone"
@@ -9633,6 +9693,7 @@ msgstr "Los códigos de recuperación de autenticación de dos factores se utili
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Two-Factor Authentication"
msgstr "Autenticación de dos factores"
@@ -9649,6 +9710,7 @@ msgid "Two-factor authentication has been disabled for your account. You will no
msgstr "La autenticación de dos factores ha sido desactivada para tu cuenta. Ya no se te pedirá ingresar un código de tu aplicación de autenticador al iniciar sesión."
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Two-Factor Re-Authentication"
msgstr "Re-autenticación de Doble Factor"
@@ -9782,6 +9844,10 @@ msgstr "Deshacer"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Unknown"
msgstr "Desconocido"
@@ -10084,10 +10150,12 @@ msgstr "Utilice su clave de acceso para la autenticación"
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "User"
msgstr "Usuario"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "User Agent"
msgstr "Agente de usuario"
@@ -10331,6 +10399,7 @@ msgstr "Ver los registros DNS para este dominio de correo electrónico"
#: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Viewed"
msgstr "Visto"
@@ -11654,3 +11723,4 @@ msgstr "Su código de verificación:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "su-dominio.com otro-dominio.com"
+131 -61
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-12-15 02:39\n"
"PO-Revision-Date: 2026-01-13 02:39\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -244,6 +244,7 @@ msgid "{0} Teams"
msgstr "{0} Équipes"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "{browserInfo} on {os}"
msgstr "{browserInfo} sur {os}"
@@ -475,6 +476,7 @@ msgid "{teamName} has invited you to {action} {documentName}"
msgstr "{teamName} vous a invité à {action} {documentName}"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "{userAgent}"
msgstr "{userAgent}"
@@ -955,6 +957,7 @@ msgid "Account"
msgstr "Compte"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Account Authentication"
msgstr "Authentification de compte"
@@ -992,6 +995,7 @@ msgid "Account Linking Request"
msgstr "Demande de liaison de compte"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Account Re-Authentication"
msgstr "Ré-authentification de compte"
@@ -1287,6 +1291,7 @@ msgid "Admin Panel"
msgstr "Panneau d'administration"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
msgid "Admins only"
msgstr "Administrateurs uniquement"
@@ -1939,6 +1944,7 @@ msgid "Attempts sealing the document again, useful for after a code change has o
msgstr "Essaye de sceller le document à nouveau, utile après qu'un changement de code ait eu lieu pour résoudre un document erroné."
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Audit Log"
msgstr "Journal d'audit"
@@ -1947,6 +1953,7 @@ msgid "Audit Logs"
msgstr "Journaux de vérification"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Authentication Level"
msgstr "Niveau d'authentification"
@@ -2572,6 +2579,7 @@ msgid "Configure the team roles for each member"
msgstr "Configurez les rôles d'équipe pour chaque membre"
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "Confirm"
msgstr "Confirmer"
@@ -2967,6 +2975,7 @@ msgstr "Créé"
#: apps/remix/app/components/tables/admin-organisations-table.tsx
#: apps/remix/app/components/tables/user-organisations-table.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Created At"
msgstr "Créé le"
@@ -3111,7 +3120,7 @@ msgstr "Valeur par défaut"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Delegate Document Ownership"
msgstr ""
msgstr "Déléguer la propriété du document"
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
msgid "delete"
@@ -3259,6 +3268,7 @@ msgstr "Supprimez votre compte et tout son contenu, y compris les documents comp
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Deleted"
msgstr "Supprimé"
@@ -3272,6 +3282,7 @@ msgstr "Destination"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Details"
msgstr "Détails"
@@ -3328,6 +3339,7 @@ msgstr "Mode développeur"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Device"
msgstr "Appareil"
@@ -3679,7 +3691,7 @@ msgstr "Document ouvert"
#: packages/lib/utils/document-audit-logs.ts
msgctxt "Audit log format"
msgid "Document ownership delegated"
msgstr ""
msgstr "Propriété du document déléguée"
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document pending"
@@ -3724,6 +3736,7 @@ msgctxt "Audit log format"
msgid "Document sent"
msgstr "Document envoyé"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx
msgid "Document Settings"
msgstr "Paramètres du document"
@@ -4090,6 +4103,8 @@ msgstr "Divulgation de signature électronique"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/constants/document.ts
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
#: packages/lib/utils/fields.ts
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4120,10 +4135,6 @@ msgstr "E-mail déjà confirmé"
msgid "Email already exists"
msgstr "L'e-mail existe déjà"
#: apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx
msgid "Email cannot already exist in the template"
msgstr "L'e-mail ne peut déjà exister dans le modèle"
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Email Confirmed!"
msgstr "Email confirmé !"
@@ -4290,7 +4301,7 @@ msgstr "Activer le portail SSO"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Enable team API tokens to delegate document ownership to another team member."
msgstr ""
msgstr "Activer les jetons dAPI d’équipe pour déléguer la propriété du document à un autre membre de l’équipe."
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
@@ -4309,6 +4320,18 @@ msgstr "Activer le compte permet à l'utilisateur de pouvoir utiliser le compte
msgid "Enclosed Document"
msgstr "Document joint"
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Enclosed Documents"
msgstr "Documents joints"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Enter"
msgstr "Entrer"
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Entrez un nom pour votre nouveau dossier. Les dossiers vous aident à organiser vos éléments."
@@ -4317,6 +4340,26 @@ msgstr "Entrez un nom pour votre nouveau dossier. Les dossiers vous aident à or
msgid "Enter claim name"
msgstr "Entrez le nom de la réclamation"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Enter Email"
msgstr "Entrer ladresse e-mail"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Enter Initials"
msgstr "Entrer les initiales"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Enter Name"
msgstr "Entrer le nom"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Enter Number"
msgstr "Entrer le numéro"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Enter Text"
msgstr "Entrer le texte"
#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx
msgid "Enter the domain you want to use for sending emails (without http:// or www)"
msgstr "Entrez le domaine que vous souhaitez utiliser pour l'envoi des e-mails (sans http:// ou www)"
@@ -4384,6 +4427,7 @@ msgid "Envelope Duplicated"
msgstr "Enveloppe dupliquée"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Envelope ID"
msgstr "ID de l'enveloppe"
@@ -4510,6 +4554,7 @@ msgid "Event Type"
msgstr "Type d'événement"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
#: packages/ui/components/document/document-visibility-select.tsx
msgid "Everyone"
msgstr "Tout le monde"
@@ -5081,6 +5126,7 @@ msgid "I am required to receive a copy of this document"
msgstr "Je dois recevoir une copie de ce document"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "I am the owner of this document"
msgstr "Je suis le propriétaire de ce document"
@@ -5208,14 +5254,6 @@ msgstr "Les initiales sont requises"
msgid "Initials Settings"
msgstr "Paramètres des initiales"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Insert a value into the number field"
msgstr "Insérez une valeur dans le champ de nombre"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Insert a value into the text field"
msgstr "Insérez une valeur dans le champ de texte"
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
msgid "Inserted"
msgstr "Inséré"
@@ -5308,6 +5346,8 @@ msgstr "Facture"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "IP Address"
msgstr "Adresse IP"
@@ -5420,6 +5460,7 @@ msgid "Last updated"
msgstr "Dernière mise à jour"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Last Updated"
msgstr "Dernière mise à jour"
@@ -5632,7 +5673,7 @@ msgstr "Gérer l'organisation"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
msgid "Manage Organisation"
msgstr ""
msgstr "Gérer lorganisation"
#: apps/remix/app/routes/_authenticated+/admin+/organisations._index.tsx
msgid "Manage organisations"
@@ -5721,6 +5762,7 @@ msgid "Manager"
msgstr "Gestionnaire"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
msgid "Managers and above"
msgstr "Responsables et supérieur"
@@ -5881,6 +5923,7 @@ msgid "My Folder"
msgstr "Mon Dossier"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "N/A"
msgstr "N/A"
@@ -6115,6 +6158,10 @@ msgstr "Pas de soucis, ça arrive ! Entrez votre email et nous vous enverrons un
msgid "None"
msgstr "Aucun"
#: packages/lib/constants/document-auth.ts
msgid "None (Overrides global settings)"
msgstr "Aucun (remplace les paramètres globaux)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
@@ -6407,6 +6454,7 @@ msgstr "Ignorer les paramètres de l'organisation"
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Owner"
msgstr "Propriétaire"
@@ -6457,6 +6505,7 @@ msgid "Passkey name"
msgstr "Nom de la clé d'accès"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Passkey Re-Authentication"
msgstr "Ré-authentification par clé d'accès"
@@ -6483,6 +6532,7 @@ msgid "Password"
msgstr "Mot de passe"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Password Re-Authentication"
msgstr "Ré-authentification par mot de passe"
@@ -6675,6 +6725,10 @@ msgstr "Veuillez contacter le propriétaire du site pour obtenir de l'aide suppl
msgid "Please enter a meaningful name for your token. This will help you identify it later."
msgstr "Veuillez entrer un nom significatif pour votre token. Cela vous aidera à l'identifier plus tard."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a number"
msgstr "Veuillez entrer un nombre"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -6685,6 +6739,22 @@ msgstr "Veuiillez entrer un nom valide."
msgid "Please enter a valid number"
msgstr "Veuillez entrer un numéro valide"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Please enter a value"
msgstr "Veuillez entrer une valeur"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Please enter your email address"
msgstr "Veuillez entrer votre adresse e-mail"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Please enter your full name"
msgstr "Veuillez entrer votre nom complet"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Please enter your initials"
msgstr "Veuillez saisir vos initiales"
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
msgid "Please mark as viewed to complete"
msgstr "Veuillez marquer comme vu pour compléter"
@@ -6953,6 +7023,7 @@ msgid "Ready"
msgstr "Prêt"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Reason"
msgstr "Raison"
@@ -7039,6 +7110,7 @@ msgstr "Destinataire mis à jour"
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Recipients"
msgstr "Destinataires"
@@ -7116,6 +7188,7 @@ msgstr "Rejeter le Document"
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/constants/document.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Rejected"
msgstr "Rejeté"
@@ -7226,6 +7299,22 @@ msgstr "Requête"
msgid "Requesting Organisation"
msgstr "Organisation Demandeuse"
#: packages/lib/constants/document-auth.ts
msgid "Require 2FA"
msgstr "Exiger une 2FA"
#: packages/lib/constants/document-auth.ts
msgid "Require account"
msgstr "Exiger un compte"
#: packages/lib/constants/document-auth.ts
msgid "Require passkey"
msgstr "Exiger une clé daccès"
#: packages/lib/constants/document-auth.ts
msgid "Require password"
msgstr "Exiger un mot de passe"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx
@@ -7356,7 +7445,7 @@ msgstr "Retour"
#: apps/remix/app/routes/_recipient+/sign.$token+/rejected.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
msgid "Return Home"
msgstr ""
msgstr "Retour à laccueil"
#: apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
msgid "Return to Documenso sign in page here"
@@ -7638,6 +7727,10 @@ msgstr "Sélectionnez des membres à ajouter à cette équipe"
msgid "Select Option"
msgstr "Sélectionner une option"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
msgid "Select Options"
msgstr "Sélectionner des options"
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
msgid "Select passkey"
msgstr "Sélectionner la clé d'authentification"
@@ -7787,6 +7880,7 @@ msgstr "Envoi..."
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Sent"
msgstr "Envoyé"
@@ -7859,13 +7953,7 @@ msgstr "Afficher les paramètres avancés"
msgid "Show templates in your public profile for your audience to sign and get started quickly"
msgstr "Afficher des modèles dans votre profil public pour que votre audience puisse signer et commencer rapidement"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
@@ -7899,10 +7987,6 @@ msgstr "Signer comme {0} <0>({1})</0>"
msgid "Sign as<0>{0} <1>({1})</1></0>"
msgstr "Signer comme<0>{0} <1>({1})</1></0>"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
msgid "Sign Checkbox Field"
msgstr "Champ de case à cocher signe"
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@@ -7919,10 +8003,6 @@ msgstr "Signer le document"
msgid "Sign Documents"
msgstr "Signer des documents"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Sign Email"
msgstr "Email de Signature"
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
msgid "Sign field"
@@ -7945,18 +8025,6 @@ msgstr "Se connecter"
msgid "Sign in to your account"
msgstr "Connectez-vous à votre compte"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Sign Initials"
msgstr "Initiales de Signature"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Sign Name"
msgstr "Nom de Signature"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Sign Number Field"
msgstr "Champ de nombre à signer"
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/menu-switcher.tsx
#: apps/remix/app/components/general/org-menu-switcher.tsx
@@ -7967,10 +8035,6 @@ msgstr "Déconnexion"
msgid "Sign Signature Field"
msgstr "Champ de signature"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Sign Text Field"
msgstr "Champ de texte à signer"
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@@ -7993,18 +8057,6 @@ msgstr "S'inscrire avec Microsoft"
msgid "Sign Up with OIDC"
msgstr "S'inscrire avec OIDC"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Sign your email into the field"
msgstr "Signez votre email dans le champ"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Sign your full name into the field"
msgstr "Signez votre nom complet dans le champ"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Sign your initials into the field"
msgstr "Signez vos initiales dans le champ"
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
@@ -8017,6 +8069,7 @@ msgstr "Signez vos initiales dans le champ"
#: apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/utils/fields.ts
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/ui/primitives/document-flow/types.ts
@@ -8025,6 +8078,7 @@ msgid "Signature"
msgstr "Signature"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signature ID"
msgstr "ID de signature"
@@ -8050,6 +8104,7 @@ msgstr "Signatures collectées"
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/ui/components/document/document-read-only-fields.tsx
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
msgid "Signed"
@@ -8075,6 +8130,7 @@ msgid "Signer"
msgstr "Signataire"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signer Events"
msgstr "Événements de signataire"
@@ -8089,10 +8145,12 @@ msgid "Signing"
msgstr "En train de signer"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signing Certificate"
msgstr "Certificat de signature"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signing certificate provided by"
msgstr "Certificat de signature fourni par"
@@ -8300,6 +8358,7 @@ msgstr "Statistiques"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Status"
msgstr "Statut"
@@ -8865,7 +8924,7 @@ msgstr "Le propriétaire du document a été informé de votre décision. Il peu
#. placeholder {1}: data.teamName
#: packages/lib/utils/document-audit-logs.ts
msgid "The document ownership was delegated to {0} on behalf of {1}"
msgstr ""
msgstr "La propriété du document a été déléguée à {0} au nom de {1}"
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
msgid "The document was created but could not be sent to recipients."
@@ -9442,6 +9501,7 @@ msgstr "Fuseau horaire"
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/ui/primitives/document-flow/add-settings.tsx
#: packages/ui/primitives/template-flow/add-template-settings.tsx
msgid "Time Zone"
@@ -9633,6 +9693,7 @@ msgstr "Les codes de récupération de l'authentification à deux facteurs sont
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Two-Factor Authentication"
msgstr "Authentification à deux facteurs"
@@ -9649,6 +9710,7 @@ msgid "Two-factor authentication has been disabled for your account. You will no
msgstr "L'authentification à deux facteurs a été désactivée pour votre compte. Vous ne serez plus tenu d'entrer un code de votre application d'authentification lors de la connexion."
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Two-Factor Re-Authentication"
msgstr "Ré-authentification à deux facteurs"
@@ -9782,6 +9844,10 @@ msgstr "Annuler"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Unknown"
msgstr "Inconnu"
@@ -10084,10 +10150,12 @@ msgstr "Utilisez votre clé d'accès pour l'authentification"
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "User"
msgstr "Utilisateur"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "User Agent"
msgstr "Agent utilisateur"
@@ -10331,6 +10399,7 @@ msgstr "Voir les enregistrements DNS pour ce domaine de messagerie"
#: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Viewed"
msgstr "Vu"
@@ -11654,3 +11723,4 @@ msgstr "Votre code de vérification :"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+131 -61
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: it\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-12-15 02:39\n"
"PO-Revision-Date: 2026-01-13 02:39\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -244,6 +244,7 @@ msgid "{0} Teams"
msgstr "{0} Squadre"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "{browserInfo} on {os}"
msgstr "{browserInfo} su {os}"
@@ -475,6 +476,7 @@ msgid "{teamName} has invited you to {action} {documentName}"
msgstr "{teamName} ti ha invitato a {action} {documentName}"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "{userAgent}"
msgstr "{userAgent}"
@@ -955,6 +957,7 @@ msgid "Account"
msgstr "Account"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Account Authentication"
msgstr "Autenticazione dell'account"
@@ -992,6 +995,7 @@ msgid "Account Linking Request"
msgstr "Richiesta di Collegamento Account"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Account Re-Authentication"
msgstr "Ri-autenticazione dell'account"
@@ -1287,6 +1291,7 @@ msgid "Admin Panel"
msgstr "Pannello di Amministrazione"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
msgid "Admins only"
msgstr "Solo amministratori"
@@ -1939,6 +1944,7 @@ msgid "Attempts sealing the document again, useful for after a code change has o
msgstr "Tenta nuovamente di sigillare il documento, utile dopo una modifica al codice per risolvere un documento errato."
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Audit Log"
msgstr "Registro di controllo"
@@ -1947,6 +1953,7 @@ msgid "Audit Logs"
msgstr "Registri di Audit"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Authentication Level"
msgstr "Livello di Autenticazione"
@@ -2572,6 +2579,7 @@ msgid "Configure the team roles for each member"
msgstr "Configura i ruoli del team per ogni membro"
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "Confirm"
msgstr "Conferma"
@@ -2967,6 +2975,7 @@ msgstr "Creato"
#: apps/remix/app/components/tables/admin-organisations-table.tsx
#: apps/remix/app/components/tables/user-organisations-table.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Created At"
msgstr "Creato il"
@@ -3111,7 +3120,7 @@ msgstr "Valore predefinito"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Delegate Document Ownership"
msgstr ""
msgstr "Delega della proprietà del documento"
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
msgid "delete"
@@ -3259,6 +3268,7 @@ msgstr "Elimina il tuo account e tutti i suoi contenuti, inclusi i documenti com
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Deleted"
msgstr "Eliminato"
@@ -3272,6 +3282,7 @@ msgstr "Destinazione"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Details"
msgstr "Dettagli"
@@ -3328,6 +3339,7 @@ msgstr "Modalità sviluppatore"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Device"
msgstr "Dispositivo"
@@ -3679,7 +3691,7 @@ msgstr "Documento aperto"
#: packages/lib/utils/document-audit-logs.ts
msgctxt "Audit log format"
msgid "Document ownership delegated"
msgstr ""
msgstr "Proprietà del documento delegata"
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document pending"
@@ -3724,6 +3736,7 @@ msgctxt "Audit log format"
msgid "Document sent"
msgstr "Documento inviato"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx
msgid "Document Settings"
msgstr "Impostazioni Documento"
@@ -4090,6 +4103,8 @@ msgstr "Divulgazione della firma elettronica"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/constants/document.ts
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
#: packages/lib/utils/fields.ts
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4120,10 +4135,6 @@ msgstr "Email già confermata"
msgid "Email already exists"
msgstr "Email già esistente"
#: apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx
msgid "Email cannot already exist in the template"
msgstr "L'email non può già esistere nel modello"
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
msgid "Email Confirmed!"
msgstr "Email confermato!"
@@ -4290,7 +4301,7 @@ msgstr "Abilita portale SSO"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Enable team API tokens to delegate document ownership to another team member."
msgstr ""
msgstr "Abilita i token API del team per delegare la proprietà del documento a un altro membro del team."
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
@@ -4309,6 +4320,18 @@ msgstr "Abilitare l'account consente all'utente di utilizzare nuovamente l'accou
msgid "Enclosed Document"
msgstr "Documento Allegato"
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Enclosed Documents"
msgstr "Documenti allegati"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Enter"
msgstr "Inserisci"
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Inserisci un nome per la tua nuova cartella. Le cartelle ti aiutano a organizzare i tuoi elementi."
@@ -4317,6 +4340,26 @@ msgstr "Inserisci un nome per la tua nuova cartella. Le cartelle ti aiutano a or
msgid "Enter claim name"
msgstr "Inserisci nome richiesta"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Enter Email"
msgstr "Inserisci l'email"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Enter Initials"
msgstr "Inserisci le iniziali"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Enter Name"
msgstr "Inserisci il nome"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Enter Number"
msgstr "Inserisci il numero"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Enter Text"
msgstr "Inserisci il testo"
#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx
msgid "Enter the domain you want to use for sending emails (without http:// or www)"
msgstr "Inserisci il dominio che desideri utilizzare per inviare email (senza http:// o www)"
@@ -4384,6 +4427,7 @@ msgid "Envelope Duplicated"
msgstr "Busta Duplicata"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Envelope ID"
msgstr "ID Busta"
@@ -4510,6 +4554,7 @@ msgid "Event Type"
msgstr "Tipo di Evento"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
#: packages/ui/components/document/document-visibility-select.tsx
msgid "Everyone"
msgstr "Tutti"
@@ -5081,6 +5126,7 @@ msgid "I am required to receive a copy of this document"
msgstr "Sono tenuto a ricevere una copia di questo documento"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "I am the owner of this document"
msgstr "Sono il proprietario di questo documento"
@@ -5208,14 +5254,6 @@ msgstr "Iniziali richieste"
msgid "Initials Settings"
msgstr "Impostazioni Iniziali"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Insert a value into the number field"
msgstr "Inserisci un valore nel campo numero"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Insert a value into the text field"
msgstr "Inserisci un valore nel campo di testo"
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
msgid "Inserted"
msgstr "Inserito"
@@ -5308,6 +5346,8 @@ msgstr "Fattura"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "IP Address"
msgstr "Indirizzo IP"
@@ -5420,6 +5460,7 @@ msgid "Last updated"
msgstr "Ultimo aggiornamento"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Last Updated"
msgstr "Ultimo aggiornamento"
@@ -5632,7 +5673,7 @@ msgstr "Gestisci l'organizzazione"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
msgid "Manage Organisation"
msgstr ""
msgstr "Gestisci organizzazione"
#: apps/remix/app/routes/_authenticated+/admin+/organisations._index.tsx
msgid "Manage organisations"
@@ -5721,6 +5762,7 @@ msgid "Manager"
msgstr "Responsabile"
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: packages/lib/constants/document-visibility.ts
msgid "Managers and above"
msgstr "Manager e superiori"
@@ -5881,6 +5923,7 @@ msgid "My Folder"
msgstr "La Mia Cartella"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "N/A"
msgstr "N/A"
@@ -6115,6 +6158,10 @@ msgstr "Non ti preoccupare, succede! Inserisci la tua email e ti invieremo un li
msgid "None"
msgstr "Nessuno"
#: packages/lib/constants/document-auth.ts
msgid "None (Overrides global settings)"
msgstr "Nessuno (sovrascrive le impostazioni globali)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
@@ -6407,6 +6454,7 @@ msgstr "Sovrascrivi impostazioni organizzazione"
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Owner"
msgstr "Proprietario"
@@ -6457,6 +6505,7 @@ msgid "Passkey name"
msgstr "Nome della passkey"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Passkey Re-Authentication"
msgstr "Ri-autenticazione con chiave di accesso"
@@ -6483,6 +6532,7 @@ msgid "Password"
msgstr "\"Password\""
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Password Re-Authentication"
msgstr "Riautenticazione della password"
@@ -6675,6 +6725,10 @@ msgstr "Si prega di contattare il proprietario del sito per ulteriori assistenza
msgid "Please enter a meaningful name for your token. This will help you identify it later."
msgstr "Si prega di inserire un nome significativo per il proprio token. Questo ti aiuterà a identificarlo più tardi."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a number"
msgstr "Inserisci un numero"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -6685,6 +6739,22 @@ msgstr "Per favore inserisci un nome valido."
msgid "Please enter a valid number"
msgstr "Per favore inserisci un numero valido"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Please enter a value"
msgstr "Inserisci un valore"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Please enter your email address"
msgstr "Inserisci il tuo indirizzo email"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Please enter your full name"
msgstr "Inserisci il tuo nome completo"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Please enter your initials"
msgstr "Inserisci le tue iniziali"
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
msgid "Please mark as viewed to complete"
msgstr "Per favore segna come visto per completare"
@@ -6953,6 +7023,7 @@ msgid "Ready"
msgstr "Pronto"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Reason"
msgstr "Motivo"
@@ -7039,6 +7110,7 @@ msgstr "Destinatario aggiornato"
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Recipients"
msgstr "Destinatari"
@@ -7116,6 +7188,7 @@ msgstr "Rifiuta Documento"
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/constants/document.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Rejected"
msgstr "Rifiutato"
@@ -7226,6 +7299,22 @@ msgstr "Richiesta"
msgid "Requesting Organisation"
msgstr "Organizzazione richiedente"
#: packages/lib/constants/document-auth.ts
msgid "Require 2FA"
msgstr "Richiedi 2FA"
#: packages/lib/constants/document-auth.ts
msgid "Require account"
msgstr "Richiedi account"
#: packages/lib/constants/document-auth.ts
msgid "Require passkey"
msgstr "Richiedi passkey"
#: packages/lib/constants/document-auth.ts
msgid "Require password"
msgstr "Richiedi password"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx
@@ -7356,7 +7445,7 @@ msgstr "Ritorna"
#: apps/remix/app/routes/_recipient+/sign.$token+/rejected.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
msgid "Return Home"
msgstr ""
msgstr "Torna alla home"
#: apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
msgid "Return to Documenso sign in page here"
@@ -7638,6 +7727,10 @@ msgstr "Seleziona membri da aggiungere a questo team"
msgid "Select Option"
msgstr "Seleziona opzione"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
msgid "Select Options"
msgstr "Seleziona opzioni"
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
msgid "Select passkey"
msgstr "Seleziona una chiave di accesso"
@@ -7787,6 +7880,7 @@ msgstr "Invio..."
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Sent"
msgstr "Inviato"
@@ -7859,13 +7953,7 @@ msgstr "Mostra impostazioni avanzate"
msgid "Show templates in your public profile for your audience to sign and get started quickly"
msgstr "Mostra modelli nel tuo profilo pubblico per il tuo pubblico da firmare e iniziare rapidamente"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
@@ -7899,10 +7987,6 @@ msgstr "Firma come {0} <0>({1})</0>"
msgid "Sign as<0>{0} <1>({1})</1></0>"
msgstr "Firma come<0>{0} <1>({1})</1></0>"
#: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
msgid "Sign Checkbox Field"
msgstr "Campo casella di controllo firma"
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@@ -7919,10 +8003,6 @@ msgstr "Firma documento"
msgid "Sign Documents"
msgstr "Firma documenti"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Sign Email"
msgstr "Firma email"
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
msgid "Sign field"
@@ -7945,18 +8025,6 @@ msgstr "Accedi"
msgid "Sign in to your account"
msgstr "Accedi al tuo account"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Sign Initials"
msgstr "Firma iniziali"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Sign Name"
msgstr "Firma nome"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Sign Number Field"
msgstr "Campo numero firma"
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/menu-switcher.tsx
#: apps/remix/app/components/general/org-menu-switcher.tsx
@@ -7967,10 +8035,6 @@ msgstr "Disconnetti"
msgid "Sign Signature Field"
msgstr "Campo firma firma"
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
msgid "Sign Text Field"
msgstr "Campo testo firma"
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@@ -7993,18 +8057,6 @@ msgstr "Registrati con Microsoft"
msgid "Sign Up with OIDC"
msgstr "Iscriviti con OIDC"
#: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx
msgid "Sign your email into the field"
msgstr "Firma la tua email nel campo"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
msgid "Sign your full name into the field"
msgstr "Firma il tuo nome completo nel campo"
#: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx
msgid "Sign your initials into the field"
msgstr "Firma le tue iniziali nel campo"
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
@@ -8017,6 +8069,7 @@ msgstr "Firma le tue iniziali nel campo"
#: apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/utils/fields.ts
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/ui/primitives/document-flow/types.ts
@@ -8025,6 +8078,7 @@ msgid "Signature"
msgstr "Firma"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signature ID"
msgstr "ID Firma"
@@ -8050,6 +8104,7 @@ msgstr "Firme raccolte"
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/ui/components/document/document-read-only-fields.tsx
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
msgid "Signed"
@@ -8075,6 +8130,7 @@ msgid "Signer"
msgstr "Firmante"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signer Events"
msgstr "Eventi del Firmatario"
@@ -8089,10 +8145,12 @@ msgid "Signing"
msgstr "Firma in corso"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signing Certificate"
msgstr "Certificato di Firma"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Signing certificate provided by"
msgstr "Certificato di firma fornito da"
@@ -8300,6 +8358,7 @@ msgstr "Statistiche"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "Status"
msgstr "Stato"
@@ -8865,7 +8924,7 @@ msgstr "Il proprietario del documento è stato informato della tua decisione. Po
#. placeholder {1}: data.teamName
#: packages/lib/utils/document-audit-logs.ts
msgid "The document ownership was delegated to {0} on behalf of {1}"
msgstr ""
msgstr "La proprietà del documento è stata delegata a {0} per conto di {1}"
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
msgid "The document was created but could not be sent to recipients."
@@ -9442,6 +9501,7 @@ msgstr "Fuso orario"
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/ui/primitives/document-flow/add-settings.tsx
#: packages/ui/primitives/template-flow/add-template-settings.tsx
msgid "Time Zone"
@@ -9633,6 +9693,7 @@ msgstr "I codici di recupero dell'autenticazione a due fattori sono utilizzati p
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Two-Factor Authentication"
msgstr "Autenticazione a due fattori"
@@ -9649,6 +9710,7 @@ msgid "Two-factor authentication has been disabled for your account. You will no
msgstr "L'autenticazione a due fattori è stata disattivata per il tuo account. Non sarà più necessario inserire un codice dalla tua app di autenticazione quando accedi."
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/generate-certificate-pdf.ts
msgid "Two-Factor Re-Authentication"
msgstr "Ri-autenticazione a due fattori"
@@ -9782,6 +9844,10 @@ msgstr "Annulla"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Unknown"
msgstr "Sconosciuto"
@@ -10084,10 +10150,12 @@ msgstr "Usa la tua chiave di accesso per l'autenticazione"
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "User"
msgstr "Utente"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
msgid "User Agent"
msgstr "User Agent"
@@ -10331,6 +10399,7 @@ msgstr "Visualizza i record DNS per questo dominio email"
#: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Viewed"
msgstr "Visualizzato"
@@ -11654,3 +11723,4 @@ msgstr "Il tuo codice di verifica:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "tuo-dominio.com altro-dominio.com"

Some files were not shown because too many files have changed in this diff Show More