mirror of
https://github.com/documenso/documenso.git
synced 2026-07-10 21:15:15 +10:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2757232e8 | |||
| 158b36a9b7 | |||
| fabd69bd62 | |||
| c976e747e3 | |||
| 34f512bd55 | |||
| db913e95b6 | |||
| bb3e9583e4 | |||
| 5bc73a7471 | |||
| 06d7849146 | |||
| cef7987a72 | |||
| cf6f6bcea0 | |||
| 2f27304750 | |||
| 912530ca17 | |||
| a995961c4e | |||
| 6b041c23b4 | |||
| 7b6e948aa2 | |||
| f6d81b22bd | |||
| c861dd2ee2 | |||
| 7eabae4b4b | |||
| ae4272a6b6 | |||
| fd672943d1 | |||
| c2ea5e5859 | |||
| c1217c5a58 | |||
| 27eb2d65d4 | |||
| ef407cb0b4 | |||
| af492cbaaa | |||
| 7868527fa0 |
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||

|
||||
|
||||

|
||||
```bash
|
||||
# New editor
|
||||
|
||||
https://app.documenso.com/t/<team-url>/documents/<envelope-id>/edit?step=addFields&devmode=true
|
||||
```
|
||||
|
||||

|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+2
-16
@@ -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>
|
||||
)}
|
||||
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-9
@@ -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>
|
||||
|
||||
+11
-9
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+299
-459
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 d’API 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 l’adresse 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 l’organisation"
|
||||
|
||||
#: 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é d’accè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 à l’accueil"
|
||||
|
||||
#: 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"
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user