mirror of
https://github.com/documenso/documenso.git
synced 2026-07-11 13:35:20 +10:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47e0fa40ca |
@@ -1,519 +0,0 @@
|
||||
---
|
||||
date: 2026-02-10
|
||||
title: Envelope Expiration
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Envelopes (documents sent for signing) should automatically expire after a configurable period, preventing recipients from completing stale documents. Expiration is tracked **per-recipient** — when a recipient's signing window lapses, the document owner is notified and can resend (extending the deadline) or cancel. The document itself stays PENDING so other recipients can continue signing.
|
||||
|
||||
**Settings cascade**: Organisation → Team → Document (each level can override the prior).
|
||||
**Default**: 1 month from when the envelope is sent (transitions to PENDING).
|
||||
|
||||
---
|
||||
|
||||
## 1. Database Schema Changes
|
||||
|
||||
### 1.1 Expiration period data shape
|
||||
|
||||
Store expiration as a structured JSON object rather than an enum or raw milliseconds. This avoids the enum treadmill (adding `FOUR_MONTHS` later requires a migration) while keeping values validated and meaningful.
|
||||
|
||||
**Zod schema** (defined in `packages/lib/constants/envelope-expiration.ts`):
|
||||
|
||||
```typescript
|
||||
export const ZEnvelopeExpirationPeriod = z.union([
|
||||
z.object({ unit: z.enum(['day', 'week', 'month', 'year']), amount: z.number().int().min(1) }),
|
||||
z.object({ disabled: z.literal(true) }),
|
||||
]);
|
||||
|
||||
export type TEnvelopeExpirationPeriod = z.infer<typeof ZEnvelopeExpirationPeriod>;
|
||||
```
|
||||
|
||||
Semantics:
|
||||
|
||||
- `null` on `DocumentMeta` / `TeamGlobalSettings` = inherit from parent
|
||||
- `{ disabled: true }` = explicitly never expires
|
||||
- `{ unit: 'month', amount: 1 }` = expires in 1 month
|
||||
|
||||
No Prisma enum is needed — the period is stored as `Json?` on the relevant models (see sections 1.3 and 1.4).
|
||||
|
||||
### 1.2 Add expiration fields to `Recipient`
|
||||
|
||||
```prisma
|
||||
model Recipient {
|
||||
// ... existing fields
|
||||
expiresAt DateTime?
|
||||
expirationNotifiedAt DateTime? // null = not yet notified; set when owner notification sent
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
```
|
||||
|
||||
`expiresAt` is a computed timestamp set when the envelope transitions to PENDING (at send time). It is calculated from the effective expiration period. Storing the concrete timestamp rather than a relative duration means:
|
||||
|
||||
- Sweep queries are simple (`WHERE expiresAt <= NOW() AND expirationNotifiedAt IS NULL`)
|
||||
- No need to re-resolve the settings cascade at query time
|
||||
- The sender can see the exact deadline in the UI
|
||||
- The index on `expiresAt` ensures the expiration sweep query is efficient
|
||||
|
||||
`expirationNotifiedAt` tracks whether the owner has already been notified about this recipient's expiration, making the notification job idempotent.
|
||||
|
||||
### 1.3 Add expiration period to settings models
|
||||
|
||||
**OrganisationGlobalSettings** (JSON, application-level default):
|
||||
|
||||
```prisma
|
||||
model OrganisationGlobalSettings {
|
||||
// ... existing fields
|
||||
envelopeExpirationPeriod Json?
|
||||
}
|
||||
```
|
||||
|
||||
Prisma `@default` doesn't work for `Json` columns, so the application-level default (`{ unit: 'month', amount: 1 }`) is applied in `extractDerivedTeamSettings` / `extractDerivedDocumentMeta` when the value is null. The migration should backfill existing rows with `{ "unit": "month", "amount": 1 }`.
|
||||
|
||||
**TeamGlobalSettings** (nullable, null = inherit from org):
|
||||
|
||||
```prisma
|
||||
model TeamGlobalSettings {
|
||||
// ... existing fields
|
||||
envelopeExpirationPeriod Json?
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 Add expiration period to DocumentMeta
|
||||
|
||||
This allows per-document override during the document editing flow:
|
||||
|
||||
```prisma
|
||||
model DocumentMeta {
|
||||
// ... existing fields
|
||||
envelopeExpirationPeriod Json?
|
||||
}
|
||||
```
|
||||
|
||||
When null on DocumentMeta, the resolved team/org setting is used at send time. Validated at write time using `ZEnvelopeExpirationPeriod.nullable()`.
|
||||
|
||||
**Important**: `envelopeExpirationPeriod` on `DocumentMeta` is a user-facing preference that may be set during the draft editing flow. It does NOT determine the final expiration — that is resolved at send time (see section 2.3). The value stored here is just the user's selection in the document editor.
|
||||
|
||||
---
|
||||
|
||||
## 2. Expiration Period Resolution
|
||||
|
||||
### 2.1 Duration mapping
|
||||
|
||||
Add to `packages/lib/constants/envelope-expiration.ts` alongside the Zod schema:
|
||||
|
||||
```typescript
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
const UNIT_TO_LUXON_KEY: Record<TEnvelopeExpirationPeriod['unit'], string> = {
|
||||
day: 'days',
|
||||
week: 'weeks',
|
||||
month: 'months',
|
||||
year: 'years',
|
||||
};
|
||||
|
||||
export const DEFAULT_ENVELOPE_EXPIRATION_PERIOD: TEnvelopeExpirationPeriod = {
|
||||
unit: 'month',
|
||||
amount: 1,
|
||||
};
|
||||
|
||||
export const getEnvelopeExpirationDuration = (period: TEnvelopeExpirationPeriod): Duration => {
|
||||
return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 Settings cascade integration
|
||||
|
||||
`extractDerivedTeamSettings()` in `packages/lib/utils/teams.ts` needs **no code changes** — it iterates `Object.keys(derivedSettings)` and overrides with non-null team values at runtime. The new `envelopeExpirationPeriod` field on both `OrganisationGlobalSettings` and `TeamGlobalSettings` will be automatically picked up.
|
||||
|
||||
Update `extractDerivedDocumentMeta()` in `packages/lib/utils/document.ts` to include the new field:
|
||||
|
||||
```typescript
|
||||
envelopeExpirationPeriod: meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod,
|
||||
```
|
||||
|
||||
### 2.3 Compute `expiresAt` at send time
|
||||
|
||||
The expiration period is **locked at send time** — when the envelope transitions to PENDING. The concrete `expiresAt` timestamp is computed for each recipient when the document is actually sent.
|
||||
|
||||
In `packages/lib/server-only/document/send-document.ts`:
|
||||
|
||||
```typescript
|
||||
// Resolve effective period: document meta -> team/org settings -> default
|
||||
const rawPeriod =
|
||||
envelope.documentMeta?.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod;
|
||||
|
||||
const expiresAt = resolveExpiresAt(rawPeriod);
|
||||
|
||||
// Inside the $transaction, for each recipient:
|
||||
await tx.recipient.updateMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
data: { expiresAt },
|
||||
});
|
||||
```
|
||||
|
||||
### 2.4 Compute `expiresAt` in the direct template flow
|
||||
|
||||
`create-document-from-direct-template.ts` creates envelopes directly as PENDING and then calls `sendDocument` afterward. Since `sendDocument` handles setting `expiresAt` on recipients, the direct template flow doesn't need to set it directly — `sendDocument` handles it.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cron Job Infrastructure (New)
|
||||
|
||||
The current job system is purely event-triggered. Inngest natively supports cron-triggered functions, but the local provider (used in dev and by self-hosters who don't want a third-party dependency) has no scheduling capability. This section adds cron support to the local provider to maintain feature parity.
|
||||
|
||||
### 3.1 Extend `JobDefinition` with cron support
|
||||
|
||||
Add an optional `cron` field to the trigger type in `packages/lib/jobs/client/_internal/job.ts`:
|
||||
|
||||
```typescript
|
||||
export type JobDefinition<Name extends string = string, Schema = any> = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
enabled?: boolean;
|
||||
optimizeParallelism?: boolean;
|
||||
trigger: {
|
||||
name: Name;
|
||||
schema?: z.ZodType<Schema>;
|
||||
/** Cron expression (e.g. "* * * * *"). When set, the job runs on a schedule. */
|
||||
cron?: string;
|
||||
};
|
||||
handler: (options: { payload: Schema; io: JobRunIO }) => Promise<Json | void>;
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 Inngest provider: wire up native cron
|
||||
|
||||
In `packages/lib/jobs/client/inngest.ts`, when defining a function, check for `cron`:
|
||||
|
||||
```typescript
|
||||
defineJob(job) {
|
||||
if (job.trigger.cron) {
|
||||
this._functions.push(
|
||||
this._client.createFunction(
|
||||
{ id: job.id, name: job.name },
|
||||
{ cron: job.trigger.cron },
|
||||
async ({ step, logger }) => {
|
||||
const io = convertInngestIoToJobRunIo(step, logger, this);
|
||||
await job.handler({ payload: {} as any, io });
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Existing event-triggered logic (unchanged)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Local provider: poller + deterministic `BackgroundJob` IDs
|
||||
|
||||
Use the existing `BackgroundJob` table for multi-instance dedupe instead of advisory locks. This approach keeps implementation Prisma-only (no raw SQL), works for single-instance and multi-instance deployments, and preserves existing retry/visibility behavior.
|
||||
|
||||
**On `defineJob()`**: If the job has a `cron` field, register an in-process scheduler entry and start a lightweight poller (every 30s with jitter).
|
||||
|
||||
**Each poll tick**:
|
||||
|
||||
1. Evaluate whether the cron schedule has one or more due run slots since the last tick (use a real cron parser, e.g. `cron-parser`)
|
||||
2. For each due slot, build a deterministic run ID from job ID + scheduled slot time
|
||||
3. Create a `BackgroundJob` row with that deterministic ID using Prisma
|
||||
4. If insert succeeds → enqueue via the existing local job pipeline
|
||||
5. If insert fails with Prisma `P2002` (unique violation) → another node already enqueued that run, skip
|
||||
|
||||
### 3.4 Summary of changes to the job system
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `packages/lib/jobs/client/_internal/job.ts` | Add optional `cron` field to `trigger` type |
|
||||
| `packages/lib/jobs/client/local.ts` | Add cron poller + deterministic `BackgroundJob.id` dedupe |
|
||||
| `packages/lib/jobs/client/inngest.ts` | Wire up `{ cron: ... }` in `createFunction` for cron jobs |
|
||||
| `packages/lib/jobs/client/_internal/*` | Add cron helper utilities (`getDueCronSlots`, run ID generation) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Expiration Processing
|
||||
|
||||
### 4.1 Two-job architecture
|
||||
|
||||
Expiration uses two jobs: a **sweep dispatcher** that runs on a cron schedule and finds expired recipients, and an **individual notification job** that handles the audit log, owner notification email, and webhook for a single recipient. This separation means:
|
||||
|
||||
- The sweep is lightweight and fast (just a query + N job triggers)
|
||||
- Each recipient's expiration notification is independently retryable
|
||||
- The individual jobs are idempotent — they check `expirationNotifiedAt IS NULL` before processing
|
||||
|
||||
### 4.2 Sweep job: `EXPIRE_RECIPIENTS_SWEEP_JOB`
|
||||
|
||||
A cron-triggered job that runs every minute to find and dispatch notifications for expired recipients.
|
||||
|
||||
**Definition:** `packages/lib/jobs/definitions/internal/expire-recipients-sweep.ts`
|
||||
|
||||
**Handler:** `packages/lib/jobs/definitions/internal/expire-recipients-sweep.handler.ts`
|
||||
|
||||
```typescript
|
||||
const expiredRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
expiresAt: { lte: new Date() },
|
||||
expirationNotifiedAt: null,
|
||||
signingStatus: { notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED] },
|
||||
envelope: { status: DocumentStatus.PENDING },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
for (const recipient of expiredRecipients) {
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.notify-recipient-expired',
|
||||
payload: { recipientId: recipient.id },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Individual notification job: `NOTIFY_RECIPIENT_EXPIRED_JOB`
|
||||
|
||||
An event-triggered job that handles a single recipient's expiration.
|
||||
|
||||
**Definition:** `packages/lib/jobs/definitions/internal/notify-recipient-expired.ts`
|
||||
|
||||
**Handler:** `packages/lib/jobs/definitions/internal/notify-recipient-expired.handler.ts`
|
||||
|
||||
The handler:
|
||||
|
||||
1. Fetches the recipient (with guard: `expirationNotifiedAt IS NULL` + not signed/rejected)
|
||||
2. Sets `recipient.expirationNotifiedAt = now()` (idempotency)
|
||||
3. Creates audit log entry with `DOCUMENT_RECIPIENT_EXPIRED` type
|
||||
4. Sends email notification to the **document owner** (inline — no separate email job)
|
||||
5. The document stays PENDING — the owner decides whether to resend or cancel
|
||||
|
||||
### 4.4 Register in job client
|
||||
|
||||
Add `EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION` and `NOTIFY_RECIPIENT_EXPIRED_JOB_DEFINITION` to the job registry in `packages/lib/jobs/client.ts`.
|
||||
|
||||
### 4.5 Email template: Recipient Expired
|
||||
|
||||
Target the **document owner**:
|
||||
|
||||
- Subject: `Signing window expired for "{recipientName}" on "{documentTitle}"`
|
||||
- Body: "The signing window for {recipientName} ({recipientEmail}) on document {title} has expired. You can resend the document to extend their deadline or cancel the document."
|
||||
- Include a "View Document" link to the document page in the app
|
||||
|
||||
Template files:
|
||||
|
||||
- `packages/email/templates/recipient-expired.tsx` — wrapper
|
||||
- `packages/email/template-components/template-recipient-expired.tsx` — body
|
||||
|
||||
### 4.6 Recipient signing guard
|
||||
|
||||
In the signing flow, check `recipient.expiresAt` before allowing any signing action. Note that the document stays PENDING even after recipient expiration, so the existing `status !== PENDING` guard does not block expired recipients — an explicit expiration check is required:
|
||||
|
||||
```typescript
|
||||
if (recipient.expiresAt && recipient.expiresAt <= new Date()) {
|
||||
throw new AppError(AppErrorCode.RECIPIENT_EXPIRED, {
|
||||
message: 'Recipient signing window has expired',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Files to update:**
|
||||
|
||||
- `packages/lib/server-only/document/complete-document-with-token.ts`
|
||||
- `packages/lib/server-only/field/sign-field-with-token.ts`
|
||||
- `packages/lib/server-only/field/remove-signed-field-with-token.ts`
|
||||
- `packages/lib/server-only/document/reject-document-with-token.ts`
|
||||
|
||||
---
|
||||
|
||||
## 5. UI Design
|
||||
|
||||
### 5.1 Expiration Period Selector Component
|
||||
|
||||
Use a number input + unit selector combo. This gives organisations full flexibility to configure any duration without needing schema changes for new options.
|
||||
|
||||
**Layout**: A horizontal group with:
|
||||
|
||||
- A number `<Input>` (min 1, integer)
|
||||
- A `<Select>` for the unit (`day`, `week`, `month`, `year`)
|
||||
- A "Never expires" toggle/checkbox that disables the duration inputs and sets the value to `{ disabled: true }`
|
||||
|
||||
At the team level, include an "Inherit from organisation" option that clears the value to `null`.
|
||||
|
||||
**Validation**: Use `ZEnvelopeExpirationPeriod` for form validation.
|
||||
|
||||
### 5.2 Organisation Settings → Document Preferences
|
||||
|
||||
Add a "Default Envelope Expiration" field to the `DocumentPreferencesForm` component. At the org level, there is no "Inherit" option — it must have a concrete value (default: `{ unit: 'month', amount: 1 }`).
|
||||
|
||||
### 5.3 Team Settings → Document Preferences
|
||||
|
||||
Same field as org, but with the additional "Inherit from organisation" option (stored as `null`).
|
||||
|
||||
### 5.4 Document Editor → Settings Step
|
||||
|
||||
Add the expiration selector to `packages/ui/primitives/document-flow/add-settings.tsx` inside the "Advanced Options" accordion.
|
||||
|
||||
Label: **"Expiration"**
|
||||
Description: _"How long recipients have to complete this document after it is sent."_
|
||||
|
||||
### 5.5 Recipient Signing Page — Expired State
|
||||
|
||||
When a recipient visits a signing link for an expired recipient:
|
||||
|
||||
- Redirect to `/sign/{token}/expired`
|
||||
- Show a clear, non-alarming message: "Your signing window has expired. Please contact the sender for a new invitation."
|
||||
- Do not show the signing form or fields
|
||||
- The `isExpired` flag in `get-envelope-for-recipient-signing.ts` is derived from `recipient.expiresAt`
|
||||
|
||||
### 5.6 Embed Signing — Expired State
|
||||
|
||||
Embed signing routes handle recipient expiration by throwing `embed-recipient-expired`:
|
||||
|
||||
- `apps/remix/app/routes/embed+/_v0+/sign.$token.tsx` — both V1 and V2 loaders check expiration
|
||||
- The embed error boundary renders an `EmbedRecipientExpired` component
|
||||
- Direct templates (`direct.$token.tsx`) create fresh recipients so `isExpired` is always `false`
|
||||
|
||||
---
|
||||
|
||||
## 6. API / TRPC Changes
|
||||
|
||||
### 6.1 Update settings mutation schemas
|
||||
|
||||
- `packages/trpc/server/organisation-router/update-organisation-settings.types.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod` (non-nullable at org level)
|
||||
- `packages/trpc/server/team-router/update-team-settings.types.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable()` (null = inherit from org)
|
||||
|
||||
### 6.2 Update document mutation schemas
|
||||
|
||||
- `packages/lib/types/document-meta.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable()` to the meta schema
|
||||
- `packages/trpc/server/document-router/create-document.types.ts` — include in meta
|
||||
- `packages/trpc/server/document-router/update-document.types.ts` — include in meta
|
||||
- `packages/trpc/server/document-router/distribute-document.types.ts` — include in meta
|
||||
|
||||
### 6.3 Expose `expiresAt` in recipient responses
|
||||
|
||||
Ensure `expiresAt` and `expirationNotifiedAt` are returned when fetching recipients/documents so the UI can display expiration status.
|
||||
|
||||
### 6.4 Webhook / API schema updates
|
||||
|
||||
- Recipient schema includes `expiresAt` and `expirationNotifiedAt` fields (replacing the old `expired` field)
|
||||
- Update `packages/api/v1/schema.ts`, webhook payload types, zapier integration, and sample data generators
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge Cases & Considerations
|
||||
|
||||
### 7.1 Already-sent documents
|
||||
|
||||
The migration should NOT retroactively expire existing recipients. `expiresAt` will be null for all existing recipients, meaning they never expire (backward-compatible).
|
||||
|
||||
### 7.2 Re-sending / redistributing
|
||||
|
||||
When `redistribute` is called on a PENDING document, `expiresAt` should be refreshed on all eligible recipients. Redistributing signals active intent, so the clock should restart.
|
||||
|
||||
**Implementation**: `resendDocument` refreshes `recipient.expiresAt` for all recipients that haven't signed/rejected yet.
|
||||
|
||||
### 7.3 Multi-recipient partial expiration
|
||||
|
||||
If some recipients have signed and others expire, the document stays PENDING. This is the key advantage over document-level expiration — the owner can resend to extend the expired recipients' deadlines without affecting those who've already signed.
|
||||
|
||||
### 7.4 Partial completion
|
||||
|
||||
Partial signatures are preserved. The document is not sealed/completed until all required recipients have signed (or the owner cancels).
|
||||
|
||||
### 7.5 Timezone handling
|
||||
|
||||
`expiresAt` is stored as UTC. Display in the sender's configured timezone.
|
||||
|
||||
### 7.6 Race condition: signing at expiration time
|
||||
|
||||
The signing guard checks `recipient.expiresAt` in application code before the signing operation. The notification job's guard (`expirationNotifiedAt IS NULL` + `signingStatus NOT IN (SIGNED, REJECTED)`) prevents double-notifications. If a recipient signs just before expiration, the sweep's `signingStatus` filter skips them.
|
||||
|
||||
### 7.7 Direct template flow
|
||||
|
||||
`create-document-from-direct-template.ts` creates envelopes directly as PENDING then calls `sendDocument`. Since `sendDocument` sets `recipient.expiresAt`, no special handling is needed in the direct template flow.
|
||||
|
||||
---
|
||||
|
||||
## 8. Migration Plan
|
||||
|
||||
1. Add Prisma schema changes (`expiresAt` + `expirationNotifiedAt` on Recipient, `Json?` fields on settings models, index)
|
||||
2. Generate and run migration
|
||||
3. Backfill: set `envelopeExpirationPeriod` to `{ "unit": "month", "amount": 1 }` on all existing `OrganisationGlobalSettings` rows
|
||||
4. No backfill on `Recipient.expiresAt` — existing recipients keep null (never expire)
|
||||
5. Deploy backend changes (jobs, guards, email template)
|
||||
6. Deploy frontend changes (settings UI, document editor, signing page, embeds)
|
||||
|
||||
---
|
||||
|
||||
## 9. Files to Create or Modify
|
||||
|
||||
### New Files
|
||||
|
||||
- `packages/lib/constants/envelope-expiration.ts` — `ZEnvelopeExpirationPeriod` schema, types, `DEFAULT_ENVELOPE_EXPIRATION_PERIOD`, `getEnvelopeExpirationDuration()`, `resolveExpiresAt()` helper
|
||||
- `packages/lib/jobs/definitions/internal/expire-recipients-sweep.ts` — cron sweep job definition
|
||||
- `packages/lib/jobs/definitions/internal/expire-recipients-sweep.handler.ts` — cron sweep handler
|
||||
- `packages/lib/jobs/definitions/internal/notify-recipient-expired.ts` — individual notification job definition
|
||||
- `packages/lib/jobs/definitions/internal/notify-recipient-expired.handler.ts` — notification handler (includes inline email sending)
|
||||
- `packages/email/templates/recipient-expired.tsx` — email template wrapper
|
||||
- `packages/email/template-components/template-recipient-expired.tsx` — email template body
|
||||
- `apps/remix/app/components/embed/embed-recipient-expired.tsx` — embed expired component
|
||||
|
||||
### Modified Files
|
||||
|
||||
**Job system (cron infrastructure):**
|
||||
|
||||
- `packages/lib/jobs/client/_internal/job.ts` — add optional `cron` field to `trigger` type
|
||||
- `packages/lib/jobs/client/local.ts` — add cron poller + deterministic `BackgroundJob.id` dedupe
|
||||
- `packages/lib/jobs/client/inngest.ts` — wire up `{ cron: ... }` in `createFunction`
|
||||
- `packages/lib/jobs/client/_internal/*` — add cron helper utilities (slot calc + run ID)
|
||||
- `packages/lib/jobs/client.ts` — register new jobs
|
||||
|
||||
**Schema & data layer:**
|
||||
|
||||
- `packages/prisma/schema.prisma` — model changes + index
|
||||
- `packages/lib/utils/document.ts` — `extractDerivedDocumentMeta` (add `envelopeExpirationPeriod`)
|
||||
- `packages/lib/server-only/document/send-document.ts` — resolve settings + compute and set `recipient.expiresAt`
|
||||
- `packages/lib/server-only/template/create-document-from-direct-template.ts` — no changes (sendDocument handles it)
|
||||
- `packages/lib/server-only/document/resend-document.ts` — refresh `recipient.expiresAt` on redistribute
|
||||
- `packages/lib/server-only/document/complete-document-with-token.ts` — recipient expiration guard
|
||||
- `packages/lib/server-only/field/sign-field-with-token.ts` — recipient expiration guard
|
||||
- `packages/lib/server-only/field/remove-signed-field-with-token.ts` — recipient expiration guard
|
||||
- `packages/lib/server-only/document/reject-document-with-token.ts` — recipient expiration guard
|
||||
|
||||
**Error handling:**
|
||||
|
||||
- `packages/lib/errors/app-error.ts` — add `RECIPIENT_EXPIRED` error code
|
||||
|
||||
**Audit logs:**
|
||||
|
||||
- `packages/lib/types/document-audit-logs.ts` — add `DOCUMENT_RECIPIENT_EXPIRED` type with `recipientEmail`/`recipientName` data fields
|
||||
- `packages/lib/utils/document-audit-logs.ts` — add human-readable rendering for `DOCUMENT_RECIPIENT_EXPIRED`
|
||||
|
||||
**Signing page:**
|
||||
|
||||
- `packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts` — derive `isExpired` from `recipient.expiresAt`
|
||||
- `apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx` — keep redirect to expired page using `isExpired`
|
||||
|
||||
**Embeds:**
|
||||
|
||||
- `apps/remix/app/routes/embed+/_v0+/sign.$token.tsx` — check recipient expiration in V1/V2 loaders
|
||||
- `apps/remix/app/routes/embed+/_v0+/_layout.tsx` — handle `embed-recipient-expired` in error boundary
|
||||
|
||||
**Webhook / API:**
|
||||
|
||||
- `packages/lib/types/recipient.ts` — add `expiresAt`/`expirationNotifiedAt` to recipient type
|
||||
- `packages/lib/types/webhook-payload.ts` — add `expiresAt`/`expirationNotifiedAt` to webhook recipient
|
||||
- `packages/lib/server-only/webhooks/trigger/generate-sample-data.ts` — update sample data
|
||||
- `packages/lib/server-only/webhooks/zapier/list-documents.ts` — update zapier recipient shape
|
||||
- `packages/api/v1/schema.ts` — add `expiresAt` to API recipient schema
|
||||
|
||||
**TRPC / settings:**
|
||||
|
||||
- `packages/trpc/server/organisation-router/update-organisation-settings.types.ts`
|
||||
- `packages/trpc/server/team-router/update-team-settings.types.ts`
|
||||
- `packages/lib/types/document-meta.ts`
|
||||
|
||||
**UI:**
|
||||
|
||||
- `apps/remix/app/components/forms/document-preferences-form.tsx` — add expiration period picker
|
||||
- `packages/ui/primitives/document-flow/add-settings.tsx` — add expiration field
|
||||
- `packages/ui/primitives/document-flow/add-settings.types.ts` — add to schema
|
||||
@@ -1,168 +0,0 @@
|
||||
---
|
||||
date: 2026-02-11
|
||||
title: Cert Page Width Mismatch
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Certificate and audit log pages are generated with hardcoded A4 dimensions (`PDF_SIZE_A4_72PPI`: 595×842) regardless of the actual document page sizes. When the source document uses a different page size (e.g., Letter, Legal, or custom dimensions), the certificate/audit log pages end up with a different width than the document pages. This causes problems with courts that expect uniform page dimensions throughout a PDF.
|
||||
|
||||
**Both width and height must match** the last page of the document so the entire PDF prints uniformly.
|
||||
|
||||
**Root cause**: In `seal-document.handler.ts` (lines 186-187), the certificate payload always uses:
|
||||
|
||||
```ts
|
||||
pageWidth: PDF_SIZE_A4_72PPI.width, // 595
|
||||
pageHeight: PDF_SIZE_A4_72PPI.height, // 842
|
||||
```
|
||||
|
||||
These hardcoded values flow into `generateCertificatePdf`, `generateAuditLogPdf`, `renderCertificate`, and `renderAuditLogs` — all of which use `pageWidth`/`pageHeight` to set Konva stage dimensions and layout content.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `packages/lib/jobs/definitions/internal/seal-document.handler.ts` | Orchestrates sealing; passes page dimensions to cert/audit generators |
|
||||
| `packages/lib/constants/pdf.ts` | Defines `PDF_SIZE_A4_72PPI` (595×842) |
|
||||
| `packages/lib/server-only/pdf/generate-certificate-pdf.ts` | Generates certificate PDF; accepts `pageWidth`/`pageHeight` |
|
||||
| `packages/lib/server-only/pdf/generate-audit-log-pdf.ts` | Generates audit log PDF; accepts `pageWidth`/`pageHeight` |
|
||||
| `packages/lib/server-only/pdf/render-certificate.ts` | Renders certificate pages via Konva; uses `pageWidth`/`pageHeight` for stage + layout |
|
||||
| `packages/lib/server-only/pdf/render-audit-logs.ts` | Renders audit log pages via Konva; uses `pageWidth`/`pageHeight` for stage + layout |
|
||||
| `packages/lib/server-only/pdf/get-page-size.ts` | Existing utility — extend with `@libpdf/core` version |
|
||||
| `packages/trpc/server/document-router/download-document-certificate.ts` | Standalone certificate download (also hardcodes A4) |
|
||||
| `packages/trpc/server/document-router/download-document-audit-logs.ts` | Standalone audit log download (also hardcodes A4) |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Current Flow
|
||||
|
||||
1. **One cert PDF + one audit log PDF** generated per envelope with hardcoded A4 dims
|
||||
2. Both appended to **every** envelope item (document) via `decorateAndSignPdf` → `pdfDoc.copyPagesFrom()`
|
||||
3. The audit log is envelope-level (all recipients, all events across all docs) — one per envelope, not per document
|
||||
|
||||
### Multi-Document Envelopes
|
||||
|
||||
- V1 envelopes: single document only
|
||||
- V2 envelopes: support multiple documents (envelope items)
|
||||
- Each envelope item gets both cert + audit log pages appended to it
|
||||
- If documents have different page sizes → need size-matched cert/audit for each
|
||||
|
||||
### Reading Page Dimensions (`@libpdf/core` only)
|
||||
|
||||
Use `@libpdf/core`'s `PDF` class — NOT `@cantoo/pdf-lib`:
|
||||
|
||||
```ts
|
||||
const pdfDoc = await PDF.load(pdfData);
|
||||
const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1);
|
||||
const { width, height } = lastPage; // e.g. 612, 792 for Letter
|
||||
```
|
||||
|
||||
Already used this way in `seal-document.handler.ts` lines 403-410 for V2 field insertion.
|
||||
"Last page" = last page of the original document, before cert/audit pages are appended.
|
||||
|
||||
### Content Layout Adaptation
|
||||
|
||||
Both renderers already handle variable dimensions gracefully:
|
||||
|
||||
- **Width**: `render-certificate.ts:713` / `render-audit-logs.ts:588` — `Math.min(pageWidth - minimumMargin * 2, contentMaxWidth)` with `contentMaxWidth = 768`. Wider pages get more margin, narrower pages tighter margins.
|
||||
- **Height**: Both renderers paginate content into pages using `groupRowsIntoPages()` which respects `pageHeight` via `maxTableHeight = pageHeight - pageTopMargin - pageBottomMargin`. Shorter pages just mean more pages; taller pages fit more rows per page.
|
||||
|
||||
### Playwright PDF Path — Out of Scope
|
||||
|
||||
The `NEXT_PRIVATE_USE_PLAYWRIGHT_PDF` toggle enables a deprecated Playwright-based PDF generation path (`get-certificate-pdf.ts`, `get-audit-logs-pdf.ts`) that also hardcodes `format: 'A4'` in `page.pdf()`. This path is **not being updated** as part of this fix:
|
||||
|
||||
- Both files are marked `@deprecated`
|
||||
- The Konva-based path is the default and recommended path
|
||||
- The Playwright path is behind a feature flag and will be removed
|
||||
|
||||
No changes needed. Add a code comment noting the A4 limitation if the Playwright path is ever re-enabled.
|
||||
|
||||
## Plan
|
||||
|
||||
### 1. Extend `get-page-size.ts` with `@libpdf/core` utility
|
||||
|
||||
Add a `getLastPageDimensions` function to the existing `packages/lib/server-only/pdf/get-page-size.ts` file. This consolidates page-size logic in one place (the file already has the legacy `@cantoo/pdf-lib` version).
|
||||
|
||||
```ts
|
||||
export const getLastPageDimensions = (pdfDoc: PDF): { width: number; height: number } => {
|
||||
const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1);
|
||||
const width = Math.round(lastPage.width);
|
||||
const height = Math.round(lastPage.height);
|
||||
|
||||
if (width < MIN_CERT_PAGE_WIDTH || height < MIN_CERT_PAGE_HEIGHT) {
|
||||
return { width: PDF_SIZE_A4_72PPI.width, height: PDF_SIZE_A4_72PPI.height };
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
```
|
||||
|
||||
**Dimension rounding**: `Math.round()` both width and height. PDF points at 72ppi are typically whole numbers; rounding avoids spurious float-precision mismatches (e.g., 612.0 vs 612.00001) that would cause unnecessary duplicate cert/audit PDF generation.
|
||||
|
||||
**Minimum page dimensions**: Enforce a minimum threshold (e.g., 300pt for both width and height). If either dimension falls below the minimum, fall back to A4 (595×842). The certificate and audit log renderers have headers, table rows, margins, and QR codes that require a minimum viable area.
|
||||
|
||||
### 2. Read last page dimensions from each envelope item's PDF
|
||||
|
||||
In `seal-document.handler.ts`, before generating cert/audit PDFs:
|
||||
|
||||
- For each `envelopeItem`, load the PDF and read the **last page's width and height** using `getLastPageDimensions`
|
||||
- Use `PDF.load()` then pass the loaded doc to the utility
|
||||
|
||||
**Resealing consideration**: When `isResealing` is true, envelope items are remapped to use `initialData` (lines 152-158) before this point. Page-size extraction must operate on the same data source that `decorateAndSignPdf` will use. Since the `envelopeItems` array is already remapped by the time we read dimensions, reading from `envelopeItem.documentData` will naturally give the correct (initial) data. No special handling needed beyond ensuring the dimension read happens **after** the resealing remap.
|
||||
|
||||
### 3. Generate cert/audit PDFs per unique page size
|
||||
|
||||
Current flow generates one cert + one audit log doc per envelope. Change to:
|
||||
|
||||
1. Collect `{ width, height }` of the last page for each envelope item
|
||||
2. Deduplicate by `"${width}x${height}"` key (using the already-rounded integers)
|
||||
3. For each unique size, generate cert PDF and audit log PDF with those dimensions
|
||||
4. Store in a `Map<string, { certificateDoc, auditLogDoc }>` keyed by `"${width}x${height}"`
|
||||
|
||||
For the common single-document case, this is one generation — same perf as today.
|
||||
|
||||
### 4. Thread the correct docs into `decorateAndSignPdf`
|
||||
|
||||
In the envelope item loop, look up the item's last-page dimensions in the map and pass the matching cert/audit docs. Signature of `decorateAndSignPdf` doesn't change — it still receives a single `certificateDoc` and `auditLogDoc`, just the right ones per item.
|
||||
|
||||
### 5. Update standalone download routes
|
||||
|
||||
`download-document-certificate.ts` and `download-document-audit-logs.ts` also hardcode A4:
|
||||
|
||||
- Both routes have `documentId` which maps to a specific envelope item
|
||||
- Fetch **that specific document's** PDF data, load it, read last page width + height via `getLastPageDimensions`
|
||||
- Pass `{ pageWidth, pageHeight }` to the generator
|
||||
- This ensures the standalone download matches the dimensions the user would see in the sealed PDF for that document
|
||||
|
||||
### 6. Edge cases
|
||||
|
||||
| Scenario | Behavior |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Mixed page sizes within one PDF | Use last page's dimensions (per spec) |
|
||||
| Page dimensions below minimum threshold | Fall back to A4 (595×842) |
|
||||
| Landscape pages | width/height just swap roles; renderers adapt via `Math.min()` capping. No special handling |
|
||||
| Fallback if page dims unreadable | Default to A4 (595×842) |
|
||||
| Resealing | Dimensions read after `initialData` remap — correct source automatically |
|
||||
| Playwright PDF path enabled | Remains A4 — out of scope, deprecated |
|
||||
| Single-doc envelope (most common) | One generation, same perf as today |
|
||||
| Multi-doc envelope, same page sizes | Dedup key matches → one generation |
|
||||
| Multi-doc envelope, different sizes | One generation per unique size |
|
||||
|
||||
### 7. Tests
|
||||
|
||||
- Add assertion-based E2E test (no visual regression / reference images needed)
|
||||
- Seal a Letter-size (612×792) PDF through the full flow
|
||||
- Load the sealed output and assert all pages (document + cert + audit) have matching width/height
|
||||
- Can be added to `envelope-alignment.spec.ts` or as a new focused test
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Extend `get-page-size.ts`** — add `getLastPageDimensions(pdfDoc: PDF): { width: number; height: number }` using `@libpdf/core`, with `Math.round()` and minimum dimension enforcement
|
||||
2. **In `seal-document.handler.ts`**:
|
||||
a. After the resealing remap (line ~159), load each envelope item's PDF via `PDF.load()` and collect last page `{ width, height }` using `getLastPageDimensions`
|
||||
b. Deduplicate by `"${width}x${height}"` key
|
||||
c. Generate cert/audit PDFs per unique size (parallel via `Promise.all`)
|
||||
d. In envelope item loop, look up matching cert/audit doc by size key
|
||||
3. **Fix `download-document-certificate.ts`** — load the specific document's PDF, read last page dims via `getLastPageDimensions`, pass to generator
|
||||
4. **Fix `download-document-audit-logs.ts`** — same as above, using the specific `documentId`'s PDF
|
||||
5. **Add E2E test** — assertion-based test with a Letter-size document verifying all page dimensions match after sealing
|
||||
@@ -1,551 +0,0 @@
|
||||
---
|
||||
date: 2026-02-19
|
||||
title: Database Rate Limiting
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the in-memory `hono-rate-limiter` with a database-backed rate limiting system using Prisma and PostgreSQL. The current in-memory approach is ineffective in multi-instance deployments since there are no sticky sessions. The new system uses **bucketed counters** (one row per key/action/time-bucket with atomic increment) to efficiently handle both high-throughput API rate limiting and granular auth/email rate limiting.
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- **Bucketed counters** over row-per-request: high-throughput consumers would create thousands of rows per minute; bucketed counters reduce this to one row per key per time bucket
|
||||
- **Fixed time windows**: simpler than sliding windows, the 2x burst-at-boundary scenario is acceptable for rate limiting purposes
|
||||
- **Dual-key rate limiting**: per-identifier (`max`) and per-IP (`globalMax`) checked independently via separate rows with a `key` prefix (`id:` / `ip:`)
|
||||
- **Accept slight over-count**: the upsert is atomic (increment + return count in one operation) but concurrent requests near the limit may both see a count just under the threshold before either commits, allowing a slight overshoot
|
||||
- **Fail-open on errors**: if the rate limit DB query fails, allow the request through rather than blocking legitimate users
|
||||
- **Prisma upsert** with `{ increment: 1 }` for atomic counter updates, returns the updated row so count check is a single operation
|
||||
- **Application cron job** for cleanup of expired bucket rows
|
||||
|
||||
### Rate Limit Check Flow
|
||||
|
||||
```
|
||||
check({ ip, identifier }) ->
|
||||
1. Upsert IP row (ip:{ip} / action / bucket) with count + 1, RETURNING count
|
||||
-> if globalMax is set and count >= globalMax, return { isLimited: true }
|
||||
2. Upsert identifier row (id:{identifier} / action / bucket) with count + 1, RETURNING count
|
||||
-> if count >= max, return { isLimited: true }
|
||||
3. Neither limited -> return { isLimited: false }
|
||||
```
|
||||
|
||||
Each upsert atomically increments and returns the new count in a single operation. Both counters always increment on every check — there's no conditional logic to skip one based on the other. This keeps the implementation simple and avoids read-then-write race conditions. If only IP is provided (API rate limiting), only step 1 runs.
|
||||
|
||||
---
|
||||
|
||||
## 1. Database Schema
|
||||
|
||||
### 1.1 Prisma model
|
||||
|
||||
Add to `packages/prisma/schema.prisma` after the `Counter` model:
|
||||
|
||||
```prisma
|
||||
model RateLimit {
|
||||
key String
|
||||
action String
|
||||
bucket DateTime
|
||||
count Int @default(1)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([key, action, bucket])
|
||||
@@index([createdAt])
|
||||
}
|
||||
```
|
||||
|
||||
- **Composite primary key** `(key, action, bucket)` serves as both the unique constraint for upserts and the lookup index
|
||||
- **`key`** is prefixed: `ip:1.2.3.4` or `id:user@example.com`
|
||||
- **`action`** is the rate limit action name: `auth.forgot-password`, `api.v1`, etc.
|
||||
- **`bucket`** is the start of the time window, truncated to the window size (e.g., `2026-02-19T10:05:00Z` for a 5-minute bucket)
|
||||
- **`createdAt` index** is for the cleanup job to efficiently delete old rows
|
||||
- **`count`** starts at 1 (set by the create side of the upsert)
|
||||
|
||||
### 1.2 Migration
|
||||
|
||||
Generate with `npx prisma migrate dev --name add-rate-limits`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rate Limit Library
|
||||
|
||||
### 2.1 Core module
|
||||
|
||||
Create `packages/lib/server-only/rate-limit/rate-limit.ts`:
|
||||
|
||||
```typescript
|
||||
type WindowUnit = 's' | 'm' | 'h' | 'd';
|
||||
type WindowStr = `${number}${WindowUnit}`;
|
||||
|
||||
type RateLimitConfig = {
|
||||
action: string;
|
||||
max: number;
|
||||
globalMax?: number;
|
||||
window: WindowStr;
|
||||
};
|
||||
|
||||
type CheckParams = {
|
||||
ip: string;
|
||||
identifier?: string;
|
||||
};
|
||||
|
||||
export const rateLimit = (config: RateLimitConfig) => {
|
||||
return {
|
||||
async check(params: CheckParams): Promise<{
|
||||
isLimited: boolean;
|
||||
remaining: number;
|
||||
limit: number;
|
||||
reset: Date;
|
||||
}> { ... }
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 Window parsing and bucket computation
|
||||
|
||||
```typescript
|
||||
const parseWindow = (window: WindowStr): number => {
|
||||
const value = parseInt(window.slice(0, -1), 10);
|
||||
const unit = window.slice(-1) as WindowUnit;
|
||||
const multipliers: Record<WindowUnit, number> = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
return value * multipliers[unit];
|
||||
};
|
||||
|
||||
const getBucket = (windowMs: number): Date => {
|
||||
const now = Date.now();
|
||||
return new Date(now - (now % windowMs));
|
||||
};
|
||||
```
|
||||
|
||||
### 2.3 Check implementation
|
||||
|
||||
The `check()` method:
|
||||
|
||||
1. Compute the current bucket from the window
|
||||
2. Compute `reset` as `bucket + windowMs` (the start of the next window)
|
||||
3. If `globalMax` is set, upsert the IP row and check count
|
||||
4. If `identifier` is provided, upsert the identifier row and check count
|
||||
5. Wrap in try/catch — **fail-open** on any database error (log the error, return `{ isLimited: false }`)
|
||||
|
||||
Each upsert uses Prisma's `upsert` with `{ increment: 1 }`:
|
||||
|
||||
```typescript
|
||||
const result = await prisma.rateLimit.upsert({
|
||||
where: {
|
||||
key_action_bucket: {
|
||||
key: `ip:${params.ip}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
key: `ip:${params.ip}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
count: 1,
|
||||
},
|
||||
update: {
|
||||
count: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
if (config.globalMax && result.count >= config.globalMax) {
|
||||
return { isLimited: true, remaining: 0, limit: config.globalMax };
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Rate limit definitions
|
||||
|
||||
Create `packages/lib/server-only/rate-limit/rate-limits.ts` with all rate limit instances:
|
||||
|
||||
```typescript
|
||||
// ---- Auth (Tier 1 - Critical, sends emails) ----
|
||||
export const signupRateLimit = rateLimit({
|
||||
action: 'auth.signup',
|
||||
max: 5,
|
||||
globalMax: 10,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const forgotPasswordRateLimit = rateLimit({
|
||||
action: 'auth.forgot-password',
|
||||
max: 3,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const resendVerifyEmailRateLimit = rateLimit({
|
||||
action: 'auth.resend-verify-email',
|
||||
max: 3,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const request2FAEmailRateLimit = rateLimit({
|
||||
action: 'auth.request-2fa-email',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
// ---- Auth (Tier 2 - Unauthenticated) ----
|
||||
export const loginRateLimit = rateLimit({
|
||||
action: 'auth.login',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const resetPasswordRateLimit = rateLimit({
|
||||
action: 'auth.reset-password',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const verifyEmailRateLimit = rateLimit({
|
||||
action: 'auth.verify-email',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const passkeyRateLimit = rateLimit({
|
||||
action: 'auth.passkey',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const oauthRateLimit = rateLimit({
|
||||
action: 'auth.oauth',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const linkOrgAccountRateLimit = rateLimit({
|
||||
action: 'auth.link-org-account',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
// ---- API (Tier 4 - Standard) ----
|
||||
export const apiV1RateLimit = rateLimit({
|
||||
action: 'api.v1',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const apiV2RateLimit = rateLimit({
|
||||
action: 'api.v2',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const apiTrpcRateLimit = rateLimit({
|
||||
action: 'api.trpc',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const aiRateLimit = rateLimit({
|
||||
action: 'api.ai',
|
||||
max: 3,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const fileUploadRateLimit = rateLimit({
|
||||
action: 'api.file-upload',
|
||||
max: 20,
|
||||
window: '1m',
|
||||
});
|
||||
```
|
||||
|
||||
Exact limits are initial values — tune based on observed traffic patterns. These should be easy to adjust.
|
||||
|
||||
---
|
||||
|
||||
## 3. Integration Points
|
||||
|
||||
### 3.1 Hono middleware for API routes
|
||||
|
||||
Create a reusable Hono middleware factory in `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` that wraps the `rateLimit` checker into Hono middleware:
|
||||
|
||||
```typescript
|
||||
import { type MiddlewareHandler } from 'hono';
|
||||
|
||||
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||
|
||||
export const createRateLimitMiddleware = (
|
||||
limiter: ReturnType<typeof rateLimit>,
|
||||
options?: { identifierFn?: (c: Context) => string | undefined },
|
||||
): MiddlewareHandler => {
|
||||
return async (c, next) => {
|
||||
let ip: string;
|
||||
try {
|
||||
ip = getIpAddress(c.req.raw);
|
||||
} catch {
|
||||
ip = 'unknown';
|
||||
}
|
||||
|
||||
const identifier = options?.identifierFn?.(c);
|
||||
|
||||
const result = await limiter.check({ ip, identifier });
|
||||
|
||||
c.header('X-RateLimit-Limit', String(result.limit));
|
||||
c.header('X-RateLimit-Remaining', String(result.remaining));
|
||||
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
|
||||
|
||||
if (result.isLimited) {
|
||||
c.header('Retry-After', String(Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
|
||||
return c.json({ error: 'Too many requests, please try again later.' }, 429);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 Replace existing Hono rate limiters
|
||||
|
||||
In `apps/remix/server/router.ts`:
|
||||
|
||||
- Remove `hono-rate-limiter` import and both `rateLimiter()` instances
|
||||
- Replace with `createRateLimitMiddleware()` calls using the defined rate limits
|
||||
- API routes use IP-only limiting (no identifier)
|
||||
- AI route uses IP-only limiting with the stricter 3/min limit
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
import { rateLimiter } from 'hono-rate-limiter';
|
||||
const rateLimitMiddleware = rateLimiter({ ... });
|
||||
|
||||
// After
|
||||
import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import { apiV1RateLimit, apiV2RateLimit, aiRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
|
||||
const apiV1RateLimitMiddleware = createRateLimitMiddleware(apiV1RateLimit);
|
||||
const apiV2RateLimitMiddleware = createRateLimitMiddleware(apiV2RateLimit);
|
||||
const aiRateLimitMiddleware = createRateLimitMiddleware(aiRateLimit);
|
||||
```
|
||||
|
||||
### 3.3 Response helpers for inline checks
|
||||
|
||||
For auth routes (Hono handlers) and tRPC routes where rate limiting is applied inline rather than via middleware, provide helpers that handle the response formatting and headers consistently.
|
||||
|
||||
**Hono helper** — returns a 429 `Response` with headers if limited, or `null` if allowed:
|
||||
|
||||
```typescript
|
||||
export const rateLimitResponse = (c: Context, result: RateLimitCheckResult): Response | null => {
|
||||
c.header('X-RateLimit-Limit', String(result.limit));
|
||||
c.header('X-RateLimit-Remaining', String(result.remaining));
|
||||
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
|
||||
|
||||
if (result.isLimited) {
|
||||
c.header('Retry-After', String(Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
|
||||
return c.json({ error: 'Too many requests, please try again later.' }, 429);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
```
|
||||
|
||||
Usage in auth routes:
|
||||
|
||||
```typescript
|
||||
const result = await loginRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: input.email,
|
||||
});
|
||||
|
||||
const limited = rateLimitResponse(c, result);
|
||||
if (limited) return limited;
|
||||
```
|
||||
|
||||
**tRPC helper** — throws a `TRPCError` with rate limit headers if limited:
|
||||
|
||||
```typescript
|
||||
export const assertRateLimit = (result: RateLimitCheckResult): void => {
|
||||
if (result.isLimited) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Usage in tRPC routes:
|
||||
|
||||
```typescript
|
||||
const result = await request2FAEmailRateLimit.check({
|
||||
ip: ctx.requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: input.recipientId,
|
||||
});
|
||||
|
||||
assertRateLimit(result);
|
||||
```
|
||||
|
||||
Both helpers live in `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` alongside the Hono middleware.
|
||||
|
||||
### 3.4 Auth endpoint rate limiting
|
||||
|
||||
In `packages/auth/server/routes/email-password.ts`, add rate limit checks at the start of each handler using the `rateLimitResponse` helper.
|
||||
|
||||
Apply to each endpoint per the tier list:
|
||||
|
||||
| Endpoint | Rate Limit |
|
||||
| --------------------------- | ----------------------------------------------------- |
|
||||
| `POST /signup` | `signupRateLimit` with `identifier: email` |
|
||||
| `POST /authorize` (login) | `loginRateLimit` with `identifier: email` |
|
||||
| `POST /forgot-password` | `forgotPasswordRateLimit` with `identifier: email` |
|
||||
| `POST /resend-verify-email` | `resendVerifyEmailRateLimit` with `identifier: email` |
|
||||
| `POST /verify-email` | `verifyEmailRateLimit` with `identifier: token` |
|
||||
| `POST /reset-password` | `resetPasswordRateLimit` with `identifier: token` |
|
||||
| `POST /passkey/authorize` | `passkeyRateLimit` (IP only, no identifier) |
|
||||
| `POST /oauth/authorize/*` | `oauthRateLimit` (IP only) |
|
||||
|
||||
### 3.4 tRPC unauthenticated route rate limiting
|
||||
|
||||
For unauthenticated tRPC routes that send emails, add rate limit checks at the start of the route handler:
|
||||
|
||||
| Route | Rate Limit | Identifier |
|
||||
| ---------------------------------------------------------- | ------------------------------------ | ---------------------- |
|
||||
| `document.accessAuth.request2FAEmail` | `request2FAEmailRateLimit` | `recipientId` or token |
|
||||
| `enterprise.organisation.authenticationPortal.linkAccount` | `linkOrgAccountRateLimit` | email |
|
||||
| `template.createDocumentFromDirectTemplate` | Dedicated direct template rate limit | IP only |
|
||||
|
||||
Access `requestMetadata` from the tRPC context (`ctx.requestMetadata.ipAddress`).
|
||||
|
||||
### 3.5 tRPC and file routes — general API rate limiting
|
||||
|
||||
Add rate limit middleware for currently unprotected routes:
|
||||
|
||||
- `/api/trpc/*` — apply `apiTrpcRateLimit` middleware
|
||||
- `/api/files/*` — apply `fileUploadRateLimit` middleware
|
||||
|
||||
---
|
||||
|
||||
## 4. Cleanup Job
|
||||
|
||||
### 4.1 Job definition
|
||||
|
||||
Create `packages/lib/jobs/definitions/internal/cleanup-rate-limits.ts`:
|
||||
|
||||
```typescript
|
||||
export const CLEANUP_RATE_LIMITS_JOB_DEFINITION = {
|
||||
id: 'internal.cleanup-rate-limits',
|
||||
name: 'Cleanup Rate Limits',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: 'internal.cleanup-rate-limits',
|
||||
schema: z.object({}),
|
||||
cron: '*/15 * * * *', // Every 15 minutes
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./cleanup-rate-limits.handler');
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<...>;
|
||||
```
|
||||
|
||||
### 4.2 Job handler
|
||||
|
||||
Create `packages/lib/jobs/definitions/internal/cleanup-rate-limits.handler.ts`:
|
||||
|
||||
- Delete all `RateLimit` rows where `createdAt` is older than 24 hours (covers all possible windows with margin)
|
||||
- Use batched deletes to avoid long-running transactions
|
||||
- Batch in chunks of 10,000 rows
|
||||
|
||||
```typescript
|
||||
export const run = async () => {
|
||||
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
let deleted = 0;
|
||||
do {
|
||||
// Prisma doesn't support DELETE with LIMIT, so use raw SQL for batching
|
||||
deleted = await prisma.$executeRaw`
|
||||
DELETE FROM "RateLimit"
|
||||
WHERE "createdAt" < ${cutoff}
|
||||
AND ctid IN (
|
||||
SELECT ctid FROM "RateLimit"
|
||||
WHERE "createdAt" < ${cutoff}
|
||||
LIMIT 10000
|
||||
)
|
||||
`;
|
||||
} while (deleted > 0);
|
||||
};
|
||||
```
|
||||
|
||||
### 4.3 Register in job client
|
||||
|
||||
Add `CLEANUP_RATE_LIMITS_JOB_DEFINITION` to the job registry in `packages/lib/jobs/client.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Remove hono-rate-limiter Dependency
|
||||
|
||||
After the migration is complete:
|
||||
|
||||
- Remove `hono-rate-limiter` from `apps/remix/package.json`
|
||||
- Run `npm install` to clean up
|
||||
|
||||
---
|
||||
|
||||
## 6. Files to Create or Modify
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/lib/server-only/rate-limit/rate-limit.ts` | Core rate limit factory (`rateLimit()`) with window parsing, bucket computation, Prisma upsert, fail-open |
|
||||
| `packages/lib/server-only/rate-limit/rate-limits.ts` | All rate limit instances (auth, API, AI, file upload) |
|
||||
| `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` | Hono middleware factory, `rateLimitResponse` helper for Hono handlers, `assertRateLimit` helper for tRPC routes |
|
||||
| `packages/lib/jobs/definitions/internal/cleanup-rate-limits.ts` | Cleanup cron job definition |
|
||||
| `packages/lib/jobs/definitions/internal/cleanup-rate-limits.handler.ts` | Cleanup handler (batched deletes) |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/prisma/schema.prisma` | Add `RateLimit` model |
|
||||
| `apps/remix/server/router.ts` | Replace `hono-rate-limiter` with DB-backed middleware, add rate limits for `/api/trpc/*` and `/api/files/*` |
|
||||
| `apps/remix/package.json` | Remove `hono-rate-limiter` dependency |
|
||||
| `packages/auth/server/routes/email-password.ts` | Add rate limit checks to signup, login, forgot-password, resend-verify-email, verify-email, reset-password |
|
||||
| `packages/auth/server/routes/passkey.ts` | Add rate limit check to passkey authorize |
|
||||
| `packages/auth/server/routes/oauth.ts` | Add rate limit check to OAuth authorize endpoints |
|
||||
| `packages/trpc/server/document-router/access-auth-request-2fa-email.ts` | Add rate limit check (sends email, unauthenticated) |
|
||||
| `packages/trpc/server/enterprise-router/link-organisation-account.ts` | Add rate limit check (sends email, unauthenticated) |
|
||||
| `packages/lib/jobs/client.ts` | Register cleanup-rate-limits job definition |
|
||||
|
||||
---
|
||||
|
||||
## 7. Considerations
|
||||
|
||||
### 7.1 Fail-open
|
||||
|
||||
All rate limit checks must be wrapped in try/catch. On any DB error, log the error and allow the request through. Rate limiting should never block legitimate traffic due to infrastructure issues.
|
||||
|
||||
### 7.2 Performance
|
||||
|
||||
- Each API request adds 1 upsert query (~1ms)
|
||||
- Auth requests add 2 upsert queries (~2ms total)
|
||||
- The composite primary key ensures all lookups and upserts are index-only operations
|
||||
- No `COUNT(*)` queries — the count is stored directly in the row
|
||||
|
||||
### 7.3 Monitoring
|
||||
|
||||
Log rate limit hits at `warn` level with the action, key type (IP/identifier), and count. This provides visibility into traffic patterns and helps tune limits.
|
||||
|
||||
### 7.4 Testing
|
||||
|
||||
The rate limit module should be mockable in tests. Consider exporting the bucket computation and window parsing as standalone functions for unit testing. Integration tests can verify the upsert + count logic against a test database.
|
||||
|
||||
### 7.5 Future improvements
|
||||
|
||||
- **Redis backend**: if DB pressure from rate limiting becomes measurable, swap the Prisma upsert for Redis `INCR` + `EXPIRE` with no API changes
|
||||
- **System-wide circuit breaker**: add a `systemMax` config option that counts all requests for an action regardless of key
|
||||
@@ -1,263 +0,0 @@
|
||||
---
|
||||
date: 2026-02-24
|
||||
title: Custom Email Domain Sync And Recovery
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Custom email domains configured via AWS SES can get stuck in a `PENDING` state or fail validation silently. Currently, there is **no automated verification** -- users must manually click "Sync" in the UI to check domain status. If a domain fails to validate, the only option is to delete it and recreate it, which generates new DKIM keys and requires the user to update their DNS records.
|
||||
|
||||
### Current Pain Points
|
||||
|
||||
1. **No background sync** -- Domain verification status is never checked automatically; users must manually click "Sync"
|
||||
2. **Stuck domains** -- Domains can remain in `PENDING` state indefinitely with no alerting or auto-recovery
|
||||
3. **Failed recovery requires DNS changes** -- Deleting and recreating a domain generates new keys, forcing the user to update DNS records
|
||||
4. **No visibility into failure duration** -- There's no tracking of how long a domain has been pending
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### 1. Hourly Background Sync Job
|
||||
|
||||
Create a new cron job (`internal.sync-email-domains`) that runs every hour to automatically verify all `PENDING` email domains.
|
||||
|
||||
**Job Definition:** `packages/lib/jobs/definitions/internal/sync-email-domains.ts`
|
||||
**Job Handler:** `packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts`
|
||||
|
||||
**Pattern:** Follow the existing `cleanup-rate-limits` cron job pattern:
|
||||
|
||||
- `cron: '0 * * * *'` (every hour, on the hour)
|
||||
- Empty `z.object({})` schema (no payload needed)
|
||||
- Register in `packages/lib/jobs/client.ts`
|
||||
|
||||
**Handler Logic:**
|
||||
|
||||
1. Query all `EmailDomain` records with `status: 'PENDING'`
|
||||
2. For each domain, call `verifyEmailDomain(emailDomainId)` which:
|
||||
- Calls AWS SES `GetEmailIdentityCommand` to check current verification status
|
||||
- Updates DB status to `ACTIVE` if verified, keeps `PENDING` otherwise
|
||||
3. Log results via `io.logger` (how many checked, how many transitioned to ACTIVE)
|
||||
4. Process domains in batches to avoid overwhelming SES API rate limits
|
||||
5. Add error handling per-domain so one failure doesn't stop the entire sweep
|
||||
|
||||
### 2. Schema Changes -- Track Pending Duration
|
||||
|
||||
Add a `lastVerifiedAt` column to the `EmailDomain` model to track when verification was last attempted, enabling "stale domain" detection.
|
||||
|
||||
**File:** `packages/prisma/schema.prisma`
|
||||
|
||||
```prisma
|
||||
model EmailDomain {
|
||||
// ... existing fields ...
|
||||
lastVerifiedAt DateTime? // Last time verification was checked against SES
|
||||
}
|
||||
```
|
||||
|
||||
**Migration:** Create a new Prisma migration for this column addition.
|
||||
|
||||
**Updates needed:**
|
||||
|
||||
- `verify-email-domain.ts` -- Update `lastVerifiedAt` when verification is checked
|
||||
- The sync job handler -- Use `lastVerifiedAt` to avoid re-checking domains that were just verified
|
||||
|
||||
### 3. Domain Re-registration (Recovery) -- Delete & Recreate in SES Without Changing Keys
|
||||
|
||||
Add a new "Re-register" action that deletes the SES identity and recreates it using the **same** DKIM key pair stored in the database, so the user's DNS records remain valid.
|
||||
|
||||
#### 3a. New Service Function
|
||||
|
||||
**File:** `packages/ee/server-only/lib/reregister-email-domain.ts`
|
||||
|
||||
```typescript
|
||||
export const reregisterEmailDomain = async (options: { emailDomainId: string }) => {
|
||||
// 1. Fetch the EmailDomain record (including encrypted privateKey)
|
||||
// 2. Decrypt the private key using DOCUMENSO_ENCRYPTION_KEY
|
||||
// 3. Call DeleteEmailIdentityCommand on SES (ignore NotFoundException)
|
||||
// 4. Call CreateEmailIdentityCommand with BYODKIM using the SAME selector + private key
|
||||
// 5. Update EmailDomain status back to PENDING, update lastVerifiedAt
|
||||
// 6. Return the updated domain
|
||||
};
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- Uses the existing encrypted `privateKey` from the DB -- no new key generation
|
||||
- Uses the existing `selector` -- DNS records stay the same
|
||||
- Deletes first, then recreates -- handles cases where SES state is corrupted
|
||||
- Resets status to `PENDING` since verification will need to re-occur
|
||||
- Uses `verifyDomainWithDKIM()` from `create-email-domain.ts` (may need to extract/export this helper)
|
||||
|
||||
#### 3b. Admin TRPC Routes (Find, Get, Re-register)
|
||||
|
||||
All email domain admin routes use `adminProcedure` -- requires system-level `Role.ADMIN`.
|
||||
|
||||
**Find (list) route:**
|
||||
**File:** `packages/trpc/server/admin-router/find-email-domains.ts`
|
||||
**Types:** `packages/trpc/server/admin-router/find-email-domains.types.ts`
|
||||
|
||||
- Query route: `admin.emailDomain.find`
|
||||
- Input: `{ query?: string, page?: number, perPage?: number, status?: EmailDomainStatus }`
|
||||
- Extends `ZFindSearchParamsSchema` with optional `status` filter
|
||||
- Returns standard `ZFindResultResponse` with email domain data including: id, domain, status, selector, createdAt, lastVerifiedAt, organisation name, email count
|
||||
- Prisma query filters by domain name (LIKE search on `query`), optional status, joins organisation for name, counts emails
|
||||
|
||||
**Get (detail) route:**
|
||||
**File:** `packages/trpc/server/admin-router/get-email-domain.ts`
|
||||
**Types:** `packages/trpc/server/admin-router/get-email-domain.types.ts`
|
||||
|
||||
- Query route: `admin.emailDomain.get`
|
||||
- Input: `{ emailDomainId: string }`
|
||||
- Returns full email domain detail: all fields (except privateKey), organisation info, list of associated emails, DNS records (generated from publicKey + selector)
|
||||
- Omits `privateKey` from response
|
||||
|
||||
**Re-register (mutation) route:**
|
||||
**File:** `packages/trpc/server/admin-router/reregister-email-domain.ts`
|
||||
**Types:** `packages/trpc/server/admin-router/reregister-email-domain.types.ts`
|
||||
|
||||
- Mutation route: `admin.emailDomain.reregister`
|
||||
- Input: `{ emailDomainId: string }`
|
||||
- Calls `reregisterEmailDomain()`
|
||||
- Rationale: Re-registration is a recovery/operational action that deletes and recreates an SES identity. This is a privileged operation that should only be performed by platform operators, not self-service by org admins.
|
||||
|
||||
#### 3c. Register in Admin Router
|
||||
|
||||
**File:** `packages/trpc/server/admin-router/router.ts`
|
||||
|
||||
Add a new `emailDomain` namespace to the admin router:
|
||||
|
||||
```typescript
|
||||
emailDomain: {
|
||||
find: findEmailDomainsRoute,
|
||||
get: getEmailDomainRoute,
|
||||
reregister: reregisterEmailDomainRoute,
|
||||
},
|
||||
```
|
||||
|
||||
#### 3d. Admin Panel UI -- Email Domains Section
|
||||
|
||||
**List page:** `apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx`
|
||||
|
||||
- New admin panel page at `/admin/email-domains`
|
||||
- Follow the existing admin documents list pattern (client-side TRPC data fetching)
|
||||
- Search input (debounced) filtering by domain name
|
||||
- Status filter dropdown (All / Pending / Active)
|
||||
- DataTable with columns: Domain, Organisation, Status (badge), Email Count, Created, Last Verified, Actions
|
||||
- Actions dropdown per row: View details, Re-register
|
||||
- Pagination via `DataTablePagination`
|
||||
|
||||
**Detail page:** `apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx`
|
||||
|
||||
- Shows full domain details: domain, selector, status, organisation, created date, last verified date
|
||||
- Shows DNS records (DKIM + SPF) with copy buttons (reuse `organisation-email-domain-records-dialog` pattern)
|
||||
- Table of associated organisation emails
|
||||
- "Re-register" button with confirmation dialog explaining the action (SES identity will be deleted and recreated with the same keys)
|
||||
- "Verify Now" button to manually trigger a verification check
|
||||
- Shows how long the domain has been pending (using `lastVerifiedAt` or `createdAt`)
|
||||
|
||||
**Navigation:** Add menu item to admin sidebar in `_layout.tsx`:
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/email-domains') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/email-domains">
|
||||
<MailIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Email Domains</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Table component:** `apps/remix/app/components/tables/admin-email-domains-table.tsx` (optional -- can be inline in the route file like the documents page)
|
||||
|
||||
#### 3e. Automatic Re-registration in Sync Job (Optional Enhancement)
|
||||
|
||||
In the hourly sync job, after checking verification status, if a domain has been `PENDING` for more than 48 hours:
|
||||
|
||||
- Automatically call `reregisterEmailDomain()` to attempt recovery
|
||||
- Log the auto-recovery attempt
|
||||
- This provides a self-healing mechanism without user intervention
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Background Sync Job (Core)
|
||||
|
||||
1. Create `sync-email-domains.ts` job definition with hourly cron
|
||||
2. Create `sync-email-domains.handler.ts` with batch verification logic
|
||||
3. Register job in `packages/lib/jobs/client.ts`
|
||||
4. Add error handling and logging
|
||||
|
||||
### Phase 2: Schema Enhancement
|
||||
|
||||
5. Add `lastVerifiedAt` column to `EmailDomain` model
|
||||
6. Create Prisma migration
|
||||
7. Update `verifyEmailDomain()` to set `lastVerifiedAt` on each check
|
||||
8. Update sync job to use `lastVerifiedAt` for intelligent scheduling
|
||||
|
||||
### Phase 3: Admin Email Domains Panel
|
||||
|
||||
9. Create `find-email-domains` admin TRPC route + types (list/search with pagination and status filter)
|
||||
10. Create `get-email-domain` admin TRPC route + types (detail view with org info, emails, DNS records)
|
||||
11. Register find + get routes in admin router under `emailDomain` namespace
|
||||
12. Create admin list page (`admin+/email-domains._index.tsx`) with search, status filter, DataTable
|
||||
13. Create admin detail page (`admin+/email-domains.$id.tsx`) with domain info, emails table, DNS records
|
||||
14. Add "Email Domains" menu item to admin sidebar (`_layout.tsx`)
|
||||
|
||||
### Phase 4: Re-registration Feature
|
||||
|
||||
15. Extract `verifyDomainWithDKIM()` as a shared helper (if not already exported)
|
||||
16. Create `reregisterEmailDomain()` service function
|
||||
17. Create `reregister-email-domain` admin TRPC mutation route + types
|
||||
18. Register reregister route in admin router under `emailDomain.reregister`
|
||||
19. Add "Re-register" button + confirmation dialog on admin detail page
|
||||
|
||||
### Phase 5: Auto-Recovery (Optional)
|
||||
|
||||
20. Add 48-hour stale detection logic to sync job
|
||||
21. Auto-trigger re-registration for stale domains
|
||||
22. Add logging/notifications for auto-recovery events
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
|
||||
- `packages/lib/jobs/definitions/internal/sync-email-domains.ts`
|
||||
- `packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts`
|
||||
- `packages/ee/server-only/lib/reregister-email-domain.ts`
|
||||
- `packages/trpc/server/admin-router/find-email-domains.ts`
|
||||
- `packages/trpc/server/admin-router/find-email-domains.types.ts`
|
||||
- `packages/trpc/server/admin-router/get-email-domain.ts`
|
||||
- `packages/trpc/server/admin-router/get-email-domain.types.ts`
|
||||
- `packages/trpc/server/admin-router/reregister-email-domain.ts`
|
||||
- `packages/trpc/server/admin-router/reregister-email-domain.types.ts`
|
||||
- `apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx`
|
||||
- `apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx`
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `packages/prisma/schema.prisma` -- Add `lastVerifiedAt` field
|
||||
- `packages/lib/jobs/client.ts` -- Register new sync job
|
||||
- `packages/ee/server-only/lib/verify-email-domain.ts` -- Update `lastVerifiedAt`
|
||||
- `packages/ee/server-only/lib/create-email-domain.ts` -- Export `verifyDomainWithDKIM` helper
|
||||
- `packages/trpc/server/admin-router/router.ts` -- Add `emailDomain.{find, get, reregister}` routes
|
||||
- `apps/remix/app/routes/_authenticated+/admin+/_layout.tsx` -- Add "Email Domains" nav item to sidebar
|
||||
- New Prisma migration file
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
1. **SES API Rate Limits** -- AWS SES has rate limits on `GetEmailIdentityCommand`. The sync job should process domains in batches with small delays between calls (e.g., 5-10 per batch with 1s delay).
|
||||
|
||||
2. **Concurrency** -- The local job provider has deterministic deduplication via SHA-256 IDs, so multiple app instances won't run the same cron tick twice.
|
||||
|
||||
3. **Error Isolation** -- Each domain verification in the sync job should be wrapped in try/catch so one failing domain doesn't prevent others from being checked.
|
||||
|
||||
4. **Re-registration Safety** -- The re-register function should be idempotent. Deleting a non-existent SES identity should be handled gracefully (already done in `deleteEmailDomain`).
|
||||
|
||||
5. **Private Key Security** -- The private key is encrypted at rest and should only be decrypted transiently during re-registration. It should never be logged or exposed in API responses.
|
||||
|
||||
6. **Feature Gating** -- The sync job should only process domains belonging to organisations with active `emailDomains` claim flags. This prevents processing domains for orgs that have downgraded.
|
||||
|
||||
7. **Observability** -- Add structured logging to the sync job so operations teams can monitor domain verification health across all tenants.
|
||||
@@ -175,8 +175,6 @@ GOOGLE_VERTEX_API_KEY=""
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
# OPTIONAL: Set to "true" to disable all rate limiting. Only use for E2E tests.
|
||||
DANGEROUS_BYPASS_RATE_LIMITS=
|
||||
|
||||
# [[LOGGER]]
|
||||
# OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
|
||||
|
||||
@@ -41,7 +41,6 @@ jobs:
|
||||
env:
|
||||
# Needed since we use next start which will set the NODE_ENV to production
|
||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH: './example/cert.p12'
|
||||
DANGEROUS_BYPASS_RATE_LIMITS: 'true'
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
|
||||
@@ -115,12 +115,10 @@ export function AssistantConfirmationDialog({
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
{!isEditingNextSigner && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
The next recipient to sign this document will be{' '}
|
||||
<span className="font-semibold">{form.watch('name')}</span> (
|
||||
<span className="font-semibold">{form.watch('email')}</span>).
|
||||
</Trans>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The next recipient to sign this document will be{' '}
|
||||
<span className="font-semibold">{form.watch('name')}</span> (
|
||||
<span className="font-semibold">{form.watch('email')}</span>).
|
||||
</p>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@@ -7,7 +5,6 @@ import { useNavigate } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,6 +13,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@@ -40,8 +38,6 @@ export const DocumentDuplicateDialog = ({
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||
trpcReact.envelope.item.getManyByToken.useQuery(
|
||||
{
|
||||
@@ -99,13 +95,12 @@ export const DocumentDuplicateDialog = ({
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollContainerRef} className="h-[50vh] overflow-y-scroll p-2">
|
||||
<PDFViewer
|
||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||
<PDFViewerLazy
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="initial"
|
||||
scrollParentRef={scrollContainerRef}
|
||||
version="original"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -82,6 +82,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
onDistribute,
|
||||
}: EnvelopeDistributeDialogProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
const hasOrganisationEmailDomains = Boolean(organisation.organisationClaim?.flags.emailDomains);
|
||||
|
||||
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
|
||||
|
||||
@@ -116,10 +117,15 @@ export const EnvelopeDistributeDialog = ({
|
||||
} = form;
|
||||
|
||||
const { data: emailData, isLoading: isLoadingEmails } =
|
||||
trpc.enterprise.organisation.email.find.useQuery({
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
});
|
||||
trpc.enterprise.organisation.email.find.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
},
|
||||
{
|
||||
enabled: isOpen && hasOrganisationEmailDomains,
|
||||
},
|
||||
);
|
||||
|
||||
const emails = emailData?.data || [];
|
||||
|
||||
@@ -258,17 +264,17 @@ export const EnvelopeDistributeDialog = ({
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
|
||||
<Trans>Email</Trans>
|
||||
Email
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
|
||||
<Trans>None</Trans>
|
||||
None
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div
|
||||
className={cn('min-h-72', {
|
||||
'min-h-[23rem]': organisation.organisationClaim.flags.emailDomains,
|
||||
'min-h-[23rem]': hasOrganisationEmailDomains,
|
||||
})}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
@@ -293,7 +299,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
className="mt-2 flex flex-col gap-y-4 rounded-lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{organisation.organisationClaim.flags.emailDomains && (
|
||||
{hasOrganisationEmailDomains && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.emailId"
|
||||
|
||||
@@ -127,11 +127,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
};
|
||||
|
||||
const mapTextToUrl = (text: string) => {
|
||||
return text
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-');
|
||||
return text.toLowerCase().replace(/\s+/g, '-');
|
||||
};
|
||||
|
||||
const dialogState = useMemo(() => {
|
||||
@@ -264,7 +260,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
{!form.formState.errors.teamUrl && (
|
||||
<span className="text-xs font-normal text-foreground/50">
|
||||
<span className="text-foreground/50 text-xs font-normal">
|
||||
{field.value ? (
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
|
||||
) : (
|
||||
@@ -292,7 +288,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
/>
|
||||
|
||||
<label
|
||||
className="ml-2 text-sm text-muted-foreground"
|
||||
className="text-muted-foreground ml-2 text-sm"
|
||||
htmlFor="inherit-members"
|
||||
>
|
||||
<Trans>Allow all organisation members to access this team</Trans>
|
||||
|
||||
@@ -8,8 +8,6 @@ import { useDropzone } from 'react-dropzone';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { pdfToImagesClientSide } from '@documenso/lib/server-only/ai/pdf-to-images.client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -54,17 +52,12 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
const pdfImages = await pdfToImagesClientSide(uint8Array, {
|
||||
scale: PDF_IMAGE_RENDER_SCALE,
|
||||
});
|
||||
|
||||
// Store file metadata and UInt8Array in form data
|
||||
form.setValue('documentData', {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
data: uint8Array, // Store as UInt8Array
|
||||
images: pdfImages,
|
||||
});
|
||||
|
||||
// Auto-populate title if it's empty
|
||||
@@ -151,7 +144,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-border bg-background transition',
|
||||
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
|
||||
{
|
||||
'border-primary/50 bg-primary/5': isDragActive,
|
||||
'hover:bg-muted/30':
|
||||
@@ -200,21 +193,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
||||
</FormControl>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
|
||||
<Loader className="h-10 w-10 animate-spin text-muted-foreground" />
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{documentData.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{formatFileSize(documentData.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,13 +46,6 @@ export const ZConfigureEmbedFormSchema = z.object({
|
||||
type: z.string(),
|
||||
size: z.number(),
|
||||
data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod
|
||||
images: z
|
||||
.object({
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
image: z.string(),
|
||||
})
|
||||
.array(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
@@ -15,7 +16,6 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -24,6 +24,7 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -83,7 +84,7 @@ export const ConfigureFieldsView = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const overrideImages = useMemo(() => {
|
||||
const normalizedDocumentData = useMemo(() => {
|
||||
if (envelopeItem) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -92,7 +93,7 @@ export const ConfigureFieldsView = ({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return configData.documentData.images;
|
||||
return base64.encode(configData.documentData.data);
|
||||
}, [configData.documentData]);
|
||||
|
||||
const normalizedEnvelopeItem = useMemo(() => {
|
||||
@@ -114,9 +115,7 @@ export const ConfigureFieldsView = ({
|
||||
templateId: null,
|
||||
token: '',
|
||||
documentDeletedAt: null,
|
||||
expired: null, // !: deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
expired: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
rejectionReason: null,
|
||||
@@ -178,6 +177,8 @@ export const ConfigureFieldsView = ({
|
||||
name: 'fields',
|
||||
});
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
@@ -545,16 +546,17 @@ export const ConfigureFieldsView = ({
|
||||
|
||||
<Form {...form}>
|
||||
<div>
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
presignToken={presignToken}
|
||||
overrideImages={overrideImages}
|
||||
overrideData={normalizedDocumentData}
|
||||
envelopeItem={normalizedEnvelopeItem}
|
||||
token={undefined}
|
||||
version="current"
|
||||
scrollParentRef="window"
|
||||
version="signed"
|
||||
/>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
export const EmbedClientLoading = () => {
|
||||
return (
|
||||
<div className="fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center bg-background">
|
||||
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
|
||||
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
<span>
|
||||
<Trans>Loading...</Trans>
|
||||
</span>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,11 +31,11 @@ import type {
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -101,6 +101,8 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
localFields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
@@ -339,11 +341,10 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
version="current"
|
||||
scrollParentRef="window"
|
||||
version="signed"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
@@ -477,7 +478,9 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
@@ -496,9 +499,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
|
||||
<span>
|
||||
<Trans>Powered by</Trans>
|
||||
</span>
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -50,8 +50,10 @@ export const EmbedDocumentFields = ({
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: EmbedDocumentFieldsProps) => {
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
return (
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
|
||||
@@ -19,11 +19,11 @@ import {
|
||||
DocumentReadOnlyFields,
|
||||
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -106,6 +106,8 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
fields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
@@ -285,11 +287,10 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="embed--DocumentViewer flex-1">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="current"
|
||||
scrollParentRef="window"
|
||||
version="signed"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
@@ -490,7 +491,9 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
@@ -507,9 +510,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
|
||||
<span>
|
||||
<Trans>Powered by</Trans>
|
||||
</span>
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
|
||||
@@ -26,7 +25,7 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName } =
|
||||
const { envelope, recipient, envelopeData, setFullName, fullName } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
|
||||
const { isCompleted, isRejected, recipientSignature } = envelopeData;
|
||||
@@ -36,7 +35,6 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT);
|
||||
|
||||
const onDocumentCompleted = (data: {
|
||||
token: string;
|
||||
@@ -134,17 +132,6 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
|
||||
if (envelope.type === EnvelopeType.TEMPLATE) {
|
||||
if (!isCompleted && data.email) {
|
||||
setEmail(data.email);
|
||||
}
|
||||
|
||||
if (data.email) {
|
||||
setIsEmailLocked(!!data.lockEmail);
|
||||
}
|
||||
}
|
||||
|
||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
@@ -226,7 +213,6 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
return (
|
||||
<EmbedSigningProvider
|
||||
isNameLocked={isNameLocked}
|
||||
isEmailLocked={isEmailLocked}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowDocumentRejection={allowDocumentRejection}
|
||||
onDocumentCompleted={onDocumentCompleted}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
export const EmbedRecipientExpired = () => {
|
||||
const [hasPostedMessage, setHasPostedMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.parent && !hasPostedMessage) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'recipient-expired',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasPostedMessage(true);
|
||||
}, [hasPostedMessage]);
|
||||
|
||||
if (!hasPostedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="embed--RecipientExpired relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-center text-2xl font-bold text-foreground">
|
||||
<Trans>Signing Window Expired</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mt-8 max-w-[50ch] text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Your signing window for this document has expired. Please contact the sender for a new
|
||||
invitation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
<Trans>Please check with the parent application for more information.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -17,12 +17,12 @@ import type {
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -66,8 +66,6 @@ export const MultiSignDocumentSigningView = ({
|
||||
|
||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
@@ -95,6 +93,8 @@ export const MultiSignDocumentSigningView = ({
|
||||
[],
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
||||
|
||||
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
||||
@@ -179,11 +179,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
|
||||
return (
|
||||
<div className="min-h-screen overflow-hidden bg-background">
|
||||
<div
|
||||
id="document-field-portal-root"
|
||||
ref={scrollContainerRef}
|
||||
className="relative h-full w-full overflow-y-auto p-8"
|
||||
>
|
||||
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
|
||||
{match({ isLoading, document })
|
||||
.with({ isLoading: true }, () => (
|
||||
<div className="flex min-h-[400px] w-full items-center justify-center">
|
||||
@@ -230,11 +226,10 @@ export const MultiSignDocumentSigningView = ({
|
||||
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
|
||||
})}
|
||||
>
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={token}
|
||||
version="current"
|
||||
scrollParentRef={scrollContainerRef}
|
||||
version="signed"
|
||||
onDocumentLoad={() => {
|
||||
setHasDocumentLoaded(true);
|
||||
onDocumentReady?.();
|
||||
@@ -368,7 +363,9 @@ export const MultiSignDocumentSigningView = ({
|
||||
</div>
|
||||
|
||||
{hasDocumentLoaded && (
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip
|
||||
key={pendingFields[0].id}
|
||||
|
||||
@@ -11,10 +11,6 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import {
|
||||
type TEnvelopeExpirationPeriod,
|
||||
ZEnvelopeExpirationPeriod,
|
||||
} from '@documenso/lib/constants/envelope-expiration';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
@@ -31,7 +27,6 @@ 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 { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@@ -75,7 +70,6 @@ export type TDocumentPreferencesFormSchema = {
|
||||
defaultRecipients: TDefaultRecipients | null;
|
||||
delegateDocumentOwnership: boolean | null;
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
|
||||
};
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
@@ -93,7 +87,6 @@ type SettingsSubset = Pick<
|
||||
| 'defaultRecipients'
|
||||
| 'delegateDocumentOwnership'
|
||||
| 'aiFeaturesEnabled'
|
||||
| 'envelopeExpirationPeriod'
|
||||
>;
|
||||
|
||||
export type DocumentPreferencesFormProps = {
|
||||
@@ -133,7 +126,6 @@ export const DocumentPreferencesForm = ({
|
||||
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
|
||||
delegateDocumentOwnership: z.boolean().nullable(),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
|
||||
});
|
||||
|
||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||
@@ -154,7 +146,6 @@ export const DocumentPreferencesForm = ({
|
||||
: null,
|
||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
@@ -678,35 +669,6 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="envelopeExpirationPeriod"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Envelope Expiration</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ExpirationPeriodPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls how long recipients have to complete signing before the document
|
||||
expires. After expiration, recipients can no longer sign the document.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAiFeaturesConfigured && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -97,13 +97,13 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
{menuNavigationLinks.map(({ href, text }) => (
|
||||
<Link
|
||||
key={href}
|
||||
className="flex items-center gap-2 text-2xl font-semibold text-foreground hover:text-foreground/80"
|
||||
className="text-foreground hover:text-foreground/80 flex items-center gap-2 text-2xl font-semibold"
|
||||
to={href}
|
||||
onClick={() => handleMenuItemClick()}
|
||||
>
|
||||
{text}
|
||||
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
|
||||
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full bg-primary px-1.5 text-xs font-semibold text-primary-foreground">
|
||||
<span className="bg-primary text-primary-foreground flex h-6 min-w-[1.5rem] items-center justify-center rounded-full px-1.5 text-xs font-semibold">
|
||||
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
||||
</span>
|
||||
)}
|
||||
@@ -111,7 +111,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
))}
|
||||
|
||||
<button
|
||||
className="text-2xl font-semibold text-foreground hover:text-foreground/80"
|
||||
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||
onClick={async () => authClient.signOut()}
|
||||
>
|
||||
<Trans>Sign Out</Trans>
|
||||
@@ -123,10 +123,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} Documenso, Inc.
|
||||
<br />
|
||||
<Trans>All rights reserved.</Trans>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
@@ -118,9 +118,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||
|
||||
{price.product.features && price.product.features.length > 0 && (
|
||||
<div className="mt-4 text-muted-foreground">
|
||||
<div className="text-sm font-medium">
|
||||
<Trans>Includes:</Trans>
|
||||
</div>
|
||||
<div className="text-sm font-medium">Includes:</div>
|
||||
|
||||
<ul className="mt-1 divide-y text-sm">
|
||||
{price.product.features.map((feature, index) => (
|
||||
|
||||
@@ -10,10 +10,10 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -151,12 +151,11 @@ export const DirectTemplatePageView = ({
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
key={template.id}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
token={directTemplateRecipient.token}
|
||||
version="current"
|
||||
scrollParentRef="window"
|
||||
version="signed"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -82,6 +82,8 @@ export const DirectTemplateSigningForm = ({
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
|
||||
|
||||
const fieldsRequiringValidation = useMemo(() => {
|
||||
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
|
||||
}, [localFields]);
|
||||
@@ -248,7 +250,9 @@ export const DirectTemplateSigningForm = ({
|
||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
@@ -28,6 +27,7 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
|
||||
|
||||
export type DocumentSigningAuth2FAProps = {
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
actionVerb?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
@@ -44,6 +44,7 @@ type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>;
|
||||
|
||||
export const DocumentSigningAuth2FA = ({
|
||||
actionTarget = 'FIELD',
|
||||
actionVerb = 'sign',
|
||||
onReauthFormSubmit,
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -100,39 +101,14 @@ export const DocumentSigningAuth2FA = ({
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
{match({ role: recipient.role, actionTarget })
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup 2FA to sign this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup 2FA to sign this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup 2FA to approve this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup 2FA to approve this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup 2FA to view this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup 2FA to view this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup 2FA to view this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup 2FA to assist with this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup 2FA to assist with this document.</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
|
||||
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
|
||||
) : (
|
||||
// Todo: Translate
|
||||
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p className="mt-2">
|
||||
<Trans>
|
||||
By enabling 2FA, you will be required to enter a code from your authenticator app
|
||||
@@ -162,9 +138,7 @@ export const DocumentSigningAuth2FA = ({
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>2FA token</Trans>
|
||||
</FormLabel>
|
||||
<FormLabel required>2FA token</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
|
||||
+28
-105
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
@@ -14,11 +13,13 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
|
||||
|
||||
export type DocumentSigningAuthAccountProps = {
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
actionVerb?: string;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const DocumentSigningAuthAccount = ({
|
||||
actionTarget = 'FIELD',
|
||||
actionVerb = 'sign',
|
||||
onOpenChange,
|
||||
}: DocumentSigningAuthAccountProps) => {
|
||||
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
||||
@@ -54,110 +55,32 @@ export const DocumentSigningAuthAccount = ({
|
||||
<fieldset disabled={isSigningOut} className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<span>
|
||||
{match({ role: recipient.role, actionTarget })
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To sign this field, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To sign this field, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To sign this document, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To sign this document, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To approve this field, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To approve this field, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To approve this document, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To approve this document, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To view this field, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To view this field, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To view this field, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To view this field, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To view this document, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To view this document, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To assist with this field, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To assist with this field, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To assist with this document, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To assist with this document, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.exhaustive()}
|
||||
</span>
|
||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||
<span>
|
||||
{isDirectTemplate ? (
|
||||
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{isDirectTemplate ? (
|
||||
<Trans>
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||
logged in.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||
logged in as <strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
+14
-100
@@ -8,7 +8,6 @@ import { RecipientRole } from '@prisma/client';
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
@@ -39,6 +38,7 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
|
||||
|
||||
export type DocumentSigningAuthPasskeyProps = {
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
actionVerb?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
@@ -52,6 +52,7 @@ type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
|
||||
|
||||
export const DocumentSigningAuthPasskey = ({
|
||||
actionTarget = 'FIELD',
|
||||
actionVerb = 'sign',
|
||||
onReauthFormSubmit,
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -127,62 +128,9 @@ export const DocumentSigningAuthPasskey = ({
|
||||
<div className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
{match({ role: recipient.role, actionTarget })
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to sign this field.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to sign this document.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to approve this field.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to approve this
|
||||
document.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to view this field.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to mark this document as
|
||||
viewed.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to view this field.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to view this document.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to assist with this
|
||||
field.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to assist with this
|
||||
document.
|
||||
</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
{/* Todo: Translate */}
|
||||
Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
|
||||
this {actionTarget.toLowerCase()}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -230,38 +178,10 @@ export const DocumentSigningAuthPasskey = ({
|
||||
<div className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
{match({ role: recipient.role, actionTarget })
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup a passkey to sign this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup a passkey to sign this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup a passkey to approve this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup a passkey to approve this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup a passkey to view this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup a passkey to mark this document as viewed.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup a passkey to view this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup a passkey to view this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup a passkey to assist with this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup a passkey to assist with this document.</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
{/* Todo: Translate */}
|
||||
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
|
||||
? 'You need to setup a passkey to mark this document as viewed.'
|
||||
: `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -293,9 +213,7 @@ export const DocumentSigningAuthPasskey = ({
|
||||
name="passkeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Passkey</Trans>
|
||||
</FormLabel>
|
||||
<FormLabel required>Passkey</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
@@ -323,24 +241,20 @@ export const DocumentSigningAuthPasskey = ({
|
||||
|
||||
{formErrorCode && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
<Trans>Unauthorized</Trans>
|
||||
</AlertTitle>
|
||||
<AlertTitle>Unauthorized</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
We were unable to verify your details. Please try again or contact support
|
||||
</Trans>
|
||||
We were unable to verify your details. Please try again or contact support
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||
<Trans>Sign</Trans>
|
||||
Sign
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,8 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningAuthPasswordProps = {
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
actionVerb?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
@@ -38,6 +40,8 @@ const ZPasswordAuthFormSchema = z.object({
|
||||
type TPasswordAuthFormSchema = z.infer<typeof ZPasswordAuthFormSchema>;
|
||||
|
||||
export const DocumentSigningAuthPassword = ({
|
||||
actionTarget = 'FIELD',
|
||||
actionVerb = 'sign',
|
||||
onReauthFormSubmit,
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -162,12 +162,10 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Automatically sign fields</Trans>
|
||||
</DialogTitle>
|
||||
<DialogTitle>Automatically sign fields</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-w-[50ch] text-muted-foreground">
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
<p>
|
||||
<Trans>
|
||||
When you sign a document, we can automatically fill in and sign the following fields
|
||||
|
||||
+1
-3
@@ -122,9 +122,7 @@ export const DocumentSigningMobileWidget = () => {
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<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>
|
||||
<Trans>Powered by</Trans>
|
||||
</span>
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+8
-5
@@ -27,10 +27,10 @@ import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/fie
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||
@@ -162,6 +162,8 @@ export const DocumentSigningPageViewV1 = ({
|
||||
: undefined;
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||
const hasPendingFields = pendingFields.length > 0;
|
||||
|
||||
@@ -272,12 +274,11 @@ export const DocumentSigningPageViewV1 = ({
|
||||
<div className="flex-1">
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
version="current"
|
||||
scrollParentRef="window"
|
||||
version="signed"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -399,7 +400,9 @@ export const DocumentSigningPageViewV1 = ({
|
||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||
)}
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
{fields
|
||||
.filter(
|
||||
(field) =>
|
||||
|
||||
+7
-16
@@ -1,4 +1,4 @@
|
||||
import { lazy, useMemo, useRef } from 'react';
|
||||
import { lazy, useMemo } from 'react';
|
||||
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
@@ -8,9 +8,8 @@ import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
@@ -41,8 +40,6 @@ const EnvelopeSignerPageRenderer = lazy(
|
||||
export const DocumentSigningPageViewV2 = () => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
isDirectTemplate,
|
||||
envelope,
|
||||
@@ -202,10 +199,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="embed--DocumentContainer flex-1 overflow-y-auto"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
{envelopeItems.length > 1 && (
|
||||
@@ -234,16 +228,15 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
{/* Document View */}
|
||||
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||
{currentEnvelopeItem ? (
|
||||
<EnvelopePdfViewer
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="signing"
|
||||
key={currentEnvelopeItem.id}
|
||||
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<p className="text-sm text-foreground">
|
||||
<Trans>No document selected</Trans>
|
||||
<Trans>No documents found</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -259,9 +252,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
target="_blank"
|
||||
className="fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:block"
|
||||
>
|
||||
<span>
|
||||
<Trans>Powered by</Trans>
|
||||
</span>
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, useEffect, useRef, useState } from 'react';
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
|
||||
@@ -9,11 +9,9 @@ import {
|
||||
EnvelopeRenderProvider,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -23,6 +21,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
|
||||
@@ -36,7 +35,7 @@ export type DocumentCertificateQRViewProps = {
|
||||
documentId: number;
|
||||
title: string;
|
||||
internalVersion: number;
|
||||
envelopeItems: (EnvelopeItem & { documentData: Omit<DocumentData, 'metadata'> })[];
|
||||
envelopeItems: (EnvelopeItem & { documentData: DocumentData })[];
|
||||
documentTeamUrl: string;
|
||||
recipientCount?: number;
|
||||
completedDate?: Date;
|
||||
@@ -105,13 +104,11 @@ export const DocumentCertificateQRView = ({
|
||||
|
||||
{internalVersion === 2 ? (
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={{
|
||||
id: envelopeItems[0].envelopeId,
|
||||
envelopeItems,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
}}
|
||||
envelopeItems={envelopeItems}
|
||||
token={token}
|
||||
>
|
||||
<DocumentCertificateQrV2
|
||||
@@ -152,12 +149,11 @@ export const DocumentCertificateQRView = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-12 w-full">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="current"
|
||||
scrollParentRef="window"
|
||||
version="signed"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -179,9 +175,7 @@ const DocumentCertificateQrV2 = ({
|
||||
formattedDate,
|
||||
token,
|
||||
}: DocumentCertificateQrV2Props) => {
|
||||
const { envelopeItems } = useCurrentEnvelopeRender();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-start">
|
||||
@@ -213,14 +207,10 @@ const DocumentCertificateQrV2 = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 max-h-[80vh] w-full overflow-y-auto" ref={scrollableContainerRef}>
|
||||
<div className="mt-12 w-full">
|
||||
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
||||
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import type { TDocument } from '@documenso/lib/types/document';
|
||||
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||
@@ -28,6 +27,7 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
|
||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -440,12 +440,11 @@ export const DocumentEditForm = ({
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="current"
|
||||
scrollParentRef="window"
|
||||
version="signed"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -9,21 +9,19 @@ import {
|
||||
AlertTriangle,
|
||||
CheckIcon,
|
||||
Clock,
|
||||
Clock8Icon,
|
||||
MailIcon,
|
||||
MailOpenIcon,
|
||||
PenIcon,
|
||||
PlusIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatSigningLink, isRecipientExpired } from '@documenso/lib/utils/recipients';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@@ -46,7 +44,7 @@ export const DocumentPageViewRecipients = ({
|
||||
envelope,
|
||||
documentRootPath,
|
||||
}: DocumentPageViewRecipientsProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -68,9 +66,9 @@ export const DocumentPageViewRecipients = ({
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||
<h1 className="font-medium text-foreground">
|
||||
<h1 className="text-foreground font-medium">
|
||||
<Trans>Recipients</Trans>
|
||||
</h1>
|
||||
|
||||
@@ -89,7 +87,7 @@ export const DocumentPageViewRecipients = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="divide-y border-t text-muted-foreground">
|
||||
<ul className="text-muted-foreground divide-y border-t">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
@@ -100,9 +98,9 @@ export const DocumentPageViewRecipients = ({
|
||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
secondaryText={
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
@@ -156,41 +154,12 @@ export const DocumentPageViewRecipients = ({
|
||||
)}
|
||||
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
isRecipientExpired(recipient) && (
|
||||
<Badge variant="destructive">
|
||||
<Clock8Icon className="mr-1 h-3 w-3" />
|
||||
<Trans>Expired</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
!isRecipientExpired(recipient) &&
|
||||
(recipient.expiresAt ? (
|
||||
<PopoverHover
|
||||
trigger={
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Expires{' '}
|
||||
{recipient.expiresAt
|
||||
? i18n.date(recipient.expiresAt, DateTime.DATETIME_MED)
|
||||
: 'N/A'}
|
||||
</Trans>
|
||||
</p>
|
||||
</PopoverHover>
|
||||
) : (
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
))}
|
||||
)}
|
||||
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.REJECTED && (
|
||||
@@ -206,7 +175,7 @@ export const DocumentPageViewRecipients = ({
|
||||
<Trans>Reason for rejection: </Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{recipient.rejectionReason}
|
||||
</p>
|
||||
</PopoverHover>
|
||||
@@ -214,8 +183,7 @@ export const DocumentPageViewRecipients = ({
|
||||
|
||||
{envelope.status === DocumentStatus.PENDING &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
recipient.role !== RecipientRole.CC &&
|
||||
!isRecipientExpired(recipient) && (
|
||||
recipient.role !== RecipientRole.CC && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
+12
-3
@@ -175,6 +175,15 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
console.log({
|
||||
top,
|
||||
left,
|
||||
height,
|
||||
width,
|
||||
rawPageX: event.pageX,
|
||||
rawPageY: event.pageY,
|
||||
});
|
||||
|
||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
@@ -269,13 +278,13 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
onMouseDown={() => setSelectedField(field.type)}
|
||||
data-selected={selectedField === field.type ? true : undefined}
|
||||
className={cn(
|
||||
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
|
||||
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
|
||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-x-1.5 font-noto text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
|
||||
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
field.className,
|
||||
{
|
||||
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
|
||||
@@ -297,7 +306,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
{selectedField && (
|
||||
<div
|
||||
className={cn(
|
||||
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size]',
|
||||
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
||||
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||
{
|
||||
|
||||
+32
-43
@@ -10,10 +10,7 @@ import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide
|
||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import {
|
||||
type PageRenderData,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
MIN_FIELD_HEIGHT_PX,
|
||||
@@ -25,15 +22,10 @@ import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { CommandDialog } from '@documenso/ui/primitives/command';
|
||||
|
||||
import { EnvelopePageImage } from '../envelope/envelope-page-image';
|
||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
|
||||
|
||||
export default function EnvelopeEditorFieldsPageRenderer({
|
||||
pageData,
|
||||
}: {
|
||||
pageData: PageRenderData;
|
||||
}) {
|
||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
const { t, i18n } = useLingui();
|
||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
@@ -48,24 +40,31 @@ export default function EnvelopeEditorFieldsPageRenderer({
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
renderStatus,
|
||||
imageProps,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
|
||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||
|
||||
const { scale, pageNumber } = pageData;
|
||||
const { _className, scale } = pageContext;
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
editorFields.localFields.filter(
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
),
|
||||
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
|
||||
[editorFields.localFields, pageContext.pageNumber],
|
||||
);
|
||||
|
||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||
const { current: container } = canvasElement;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDragEvent = event.type === 'dragend';
|
||||
|
||||
const fieldGroup = event.target as Konva.Group;
|
||||
@@ -345,6 +344,7 @@ export default function EnvelopeEditorFieldsPageRenderer({
|
||||
// Create a field if no items are selected or the size is too small.
|
||||
if (
|
||||
selectedFieldGroups.length === 0 &&
|
||||
canvasElement.current &&
|
||||
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
||||
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
||||
editorFields.selectedRecipient &&
|
||||
@@ -434,31 +434,15 @@ export default function EnvelopeEditorFieldsPageRenderer({
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
// Reconcile selection state with live field nodes after flush/sync updates.
|
||||
const liveSelectedFieldGroups = selectedKonvaFieldGroups.filter((fieldGroup) => {
|
||||
if (!fieldGroup.getStage() || !fieldGroup.getParent()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return localPageFields.some((field) => field.formId === fieldGroup.id());
|
||||
});
|
||||
|
||||
if (liveSelectedFieldGroups.length !== selectedKonvaFieldGroups.length) {
|
||||
setSelectedFields(liveSelectedFieldGroups);
|
||||
}
|
||||
|
||||
// Rerender the transformer
|
||||
interactiveTransformer.current?.forceUpdate();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields, selectedKonvaFieldGroups]);
|
||||
}, [localPageFields]);
|
||||
|
||||
const setSelectedFields = (nodes: Konva.Node[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const fieldGroups = nodes.filter(
|
||||
(node) =>
|
||||
node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
|
||||
) as Konva.Group[];
|
||||
const fieldGroups = nodes.filter((node) => node.hasName('field-group')) as Konva.Group[];
|
||||
|
||||
interactiveTransformer.current?.nodes(fieldGroups);
|
||||
setSelectedKonvaFieldGroups(fieldGroups);
|
||||
@@ -531,7 +515,7 @@ export default function EnvelopeEditorFieldsPageRenderer({
|
||||
|
||||
removePendingField();
|
||||
|
||||
if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -546,7 +530,7 @@ export default function EnvelopeEditorFieldsPageRenderer({
|
||||
|
||||
editorFields.addField({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
page: pageNumber,
|
||||
page: pageContext.pageNumber,
|
||||
type,
|
||||
positionX: fieldX,
|
||||
positionY: fieldY,
|
||||
@@ -575,7 +559,10 @@ export default function EnvelopeEditorFieldsPageRenderer({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
{selectedKonvaFieldGroups.length > 0 &&
|
||||
interactiveTransformer.current &&
|
||||
!isFieldChanging && (
|
||||
@@ -638,7 +625,13 @@ export default function EnvelopeEditorFieldsPageRenderer({
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
<EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
ref={canvasElement}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -681,10 +674,6 @@ const FieldActionButtons = ({
|
||||
selectedFieldFormId.includes(field.formId),
|
||||
);
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipient = envelope.recipients.find(
|
||||
(recipient) => recipient.id === fields[0].recipientId,
|
||||
);
|
||||
@@ -700,7 +689,7 @@ const FieldActionButtons = ({
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [editorFields.localFields, envelope.recipients, selectedFieldFormId]);
|
||||
}, [editorFields.localFields]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" {...props}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -12,7 +12,6 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import {
|
||||
FIELD_META_DEFAULT_VALUES,
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
@@ -50,10 +49,13 @@ import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||
import EnvelopeEditorFieldsPageRenderer from './envelope-editor-fields-page-renderer';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
||||
|
||||
const EnvelopeEditorFieldsPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
|
||||
);
|
||||
|
||||
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
||||
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
||||
@@ -73,8 +75,6 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
@@ -156,12 +156,12 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto" ref={scrollableContainerRef}>
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||
<div className="mt-4 flex flex-col items-center justify-center">
|
||||
{envelope.recipients.length === 0 && (
|
||||
<Alert
|
||||
variant="neutral"
|
||||
@@ -185,10 +185,9 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
)}
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<EnvelopePdfViewer
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="editor"
|
||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
@@ -297,31 +296,19 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-foreground">
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Pos X:</Trans>
|
||||
</span>
|
||||
|
||||
<span className="min-w-12 text-muted-foreground">Pos X: </span>
|
||||
{selectedField.positionX.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Pos Y:</Trans>
|
||||
</span>
|
||||
|
||||
<span className="min-w-12 text-muted-foreground">Pos Y: </span>
|
||||
{selectedField.positionY.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Width:</Trans>
|
||||
</span>
|
||||
|
||||
<span className="min-w-12 text-muted-foreground">Width: </span>
|
||||
{selectedField.width.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Height:</Trans>
|
||||
</span>
|
||||
|
||||
<span className="min-w-12 text-muted-foreground">Height: </span>
|
||||
{selectedField.height.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { faker } from '@faker-js/faker/locale/en';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@@ -11,13 +11,12 @@ import {
|
||||
EnvelopeRenderProvider,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
@@ -34,8 +33,6 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
|
||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
||||
'recipient',
|
||||
);
|
||||
@@ -203,9 +200,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
// Override the parent renderer provider so we can inject custom fields.
|
||||
return (
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={fieldsWithPlaceholders}
|
||||
recipients={envelope.recipients.map((recipient) => ({
|
||||
@@ -217,12 +212,12 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex w-full flex-col overflow-y-auto" ref={scrollableContainerRef}>
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||
<div className="mt-4 flex flex-col items-center justify-center">
|
||||
<Alert variant="warning" className="mb-4 max-w-[800px]">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
@@ -233,10 +228,9 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
</Alert>
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<EnvelopePdfViewer
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="editor"
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
|
||||
+1
-41
@@ -22,7 +22,6 @@ import {
|
||||
DOCUMENT_DISTRIBUTION_METHODS,
|
||||
DOCUMENT_SIGNATURE_TYPES,
|
||||
} from '@documenso/lib/constants/document';
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
@@ -63,7 +62,6 @@ import {
|
||||
DocumentVisibilitySelect,
|
||||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
@@ -137,7 +135,6 @@ export const ZAddSettingsFormSchema = z.object({
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -210,7 +207,6 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
|
||||
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
||||
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
||||
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -249,7 +245,6 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
message,
|
||||
subject,
|
||||
emailReplyTo,
|
||||
envelopeExpirationPeriod,
|
||||
} = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
@@ -278,7 +273,6 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
envelopeExpirationPeriod,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -379,7 +373,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 py-6"
|
||||
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 pt-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
key={activeTab}
|
||||
>
|
||||
@@ -642,40 +636,6 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.envelopeExpirationPeriod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Expiration</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
How long recipients have to complete this document after it is
|
||||
sent. Uses the team default when set to inherit.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ExpirationPeriodPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={envelopeHasBeenSent}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
.with('email', () => (
|
||||
|
||||
+28
-16
@@ -5,22 +5,17 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
|
||||
import type Konva from 'konva';
|
||||
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import {
|
||||
type PageRenderData,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
|
||||
|
||||
import { EnvelopePageImage } from '../envelope/envelope-page-image';
|
||||
|
||||
type GenericLocalField = TEnvelope['fields'][number] & {
|
||||
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||
};
|
||||
|
||||
export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: PageRenderData }) {
|
||||
export default function EnvelopeGenericPageRenderer() {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const {
|
||||
@@ -33,12 +28,19 @@ export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: Pa
|
||||
overrideSettings,
|
||||
} = useCurrentEnvelopeRender();
|
||||
|
||||
const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
|
||||
usePageRenderer(({ stage, pageLayer }) => {
|
||||
createPageCanvas(stage, pageLayer);
|
||||
}, pageData);
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => {
|
||||
createPageCanvas(stage, pageLayer);
|
||||
});
|
||||
|
||||
const { scale, pageNumber } = pageData;
|
||||
const { _className, scale } = pageContext;
|
||||
|
||||
const localPageFields = useMemo((): GenericLocalField[] => {
|
||||
if (envelopeStatus === DocumentStatus.COMPLETED) {
|
||||
@@ -47,7 +49,8 @@ export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: Pa
|
||||
|
||||
return fields
|
||||
.filter(
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
)
|
||||
.map((field) => {
|
||||
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
@@ -70,7 +73,7 @@ export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: Pa
|
||||
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
|
||||
fieldMeta?.readOnly,
|
||||
);
|
||||
}, [fields, pageNumber, currentEnvelopeItem?.id, recipients]);
|
||||
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
|
||||
|
||||
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
||||
if (!pageLayer.current) {
|
||||
@@ -157,7 +160,10 @@ export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: Pa
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
{overrideSettings?.showRecipientTooltip &&
|
||||
localPageFields.map((field) => (
|
||||
<EnvelopeRecipientFieldTooltip
|
||||
@@ -171,7 +177,13 @@ export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: Pa
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
<EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
ref={canvasElement}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+29
-16
@@ -14,10 +14,7 @@ import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import {
|
||||
type PageRenderData,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
@@ -47,13 +44,12 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
|
||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||
import { EnvelopePageImage } from '../envelope/envelope-page-image';
|
||||
|
||||
type GenericLocalField = TEnvelope['fields'][number] & {
|
||||
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||
};
|
||||
|
||||
export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: PageRenderData }) {
|
||||
export default function EnvelopeSignerPageRenderer() {
|
||||
const { t, i18n } = useLingui();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
const { sessionData } = useOptionalSession();
|
||||
@@ -81,10 +77,17 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
|
||||
|
||||
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
|
||||
|
||||
const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
|
||||
usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||
|
||||
const { scale, pageNumber } = pageData;
|
||||
const { _className, scale } = pageContext;
|
||||
|
||||
const { envelope } = envelopeData;
|
||||
|
||||
@@ -96,9 +99,10 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
|
||||
}
|
||||
|
||||
return fieldsToRender.filter(
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
);
|
||||
}, [recipientFields, selectedAssistantRecipientFields, pageNumber, currentEnvelopeItem?.id]);
|
||||
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
||||
|
||||
/**
|
||||
* Returns fields that have been fully signed by other recipients for this specific
|
||||
@@ -113,7 +117,7 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
|
||||
return recipient.fields
|
||||
.filter(
|
||||
(field) =>
|
||||
field.page === pageNumber &&
|
||||
field.page === pageContext.pageNumber &&
|
||||
field.envelopeItemId === currentEnvelopeItem?.id &&
|
||||
(field.inserted || field.fieldMeta?.readOnly),
|
||||
)
|
||||
@@ -128,7 +132,7 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
|
||||
},
|
||||
}));
|
||||
});
|
||||
}, [envelope.recipients, pageNumber]);
|
||||
}, [envelope.recipients, pageContext.pageNumber]);
|
||||
|
||||
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||
if (!pageLayer.current) {
|
||||
@@ -530,11 +534,14 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
{showPendingFieldTooltip &&
|
||||
recipientFieldsRemaining.length > 0 &&
|
||||
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
|
||||
recipientFieldsRemaining[0]?.page === pageNumber && (
|
||||
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
|
||||
<EnvelopeFieldToolTip
|
||||
key={recipientFieldsRemaining[0].id}
|
||||
field={recipientFieldsRemaining[0]}
|
||||
@@ -556,7 +563,13 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
<EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
ref={canvasElement}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-8
@@ -71,14 +71,6 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
|
||||
if (fieldTooltip) {
|
||||
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
// Tooltip not in DOM (page virtualized away) — signal the PDF viewer
|
||||
// to scroll to the correct page via the data attribute.
|
||||
const pdfContent = document.querySelector('[data-pdf-content]');
|
||||
|
||||
if (pdfContent) {
|
||||
pdfContent.setAttribute('data-scroll-to-page', String(nextField.page));
|
||||
}
|
||||
}
|
||||
},
|
||||
isEnvelopeItemSwitch ? 150 : 50,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||
|
||||
type EnvelopePageImageProps = {
|
||||
renderStatus: 'loading' | 'loaded' | 'error';
|
||||
imageProps: React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' };
|
||||
};
|
||||
|
||||
export const EnvelopePageImage = ({ renderStatus, imageProps }: EnvelopePageImageProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Loading State */}
|
||||
{renderStatus === 'loading' && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderStatus === 'error' && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||
<p>
|
||||
<Trans>Error loading page</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The PDF image. */}
|
||||
<img {...imageProps} alt="" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
||||
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||
@@ -312,12 +312,11 @@ export const TemplateEditForm = ({
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
key={template.envelopeItems[0].id}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="current"
|
||||
scrollParentRef="window"
|
||||
version="signed"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -21,17 +21,17 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center rounded-xl bg-neutral-100 p-4 dark:bg-background',
|
||||
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="inline-block max-w-full truncate rounded-md border border-border bg-background px-2.5 py-1.5 text-sm lowercase text-muted-foreground">
|
||||
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm lowercase">
|
||||
{baseUrl.host}/u/{user.url}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="rounded-full bg-primary/10 p-1.5">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full border-2 bg-background">
|
||||
<div className="bg-primary/10 rounded-full p-1.5">
|
||||
<div className="bg-background flex h-20 w-20 items-center justify-center rounded-full border-2">
|
||||
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,17 +41,17 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
||||
<div className="flex items-center justify-center gap-x-2">
|
||||
<h2 className="max-w-[12rem] truncate text-2xl font-semibold">{user.name}</h2>
|
||||
|
||||
<VerifiedIcon className="h-8 w-8 text-primary" />
|
||||
<VerifiedIcon className="text-primary h-8 w-8" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300 dark:bg-foreground/30" />
|
||||
<div className="mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200 dark:bg-foreground/20" />
|
||||
<div className="dark:bg-foreground/30 mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300" />
|
||||
<div className="dark:bg-foreground/20 mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200" />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 w-full">
|
||||
<div className="divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
|
||||
<div className="bg-neutral-50 p-4 font-medium text-muted-foreground dark:bg-foreground/20">
|
||||
<Trans>Documents</Trans>
|
||||
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
|
||||
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
|
||||
Documents
|
||||
</div>
|
||||
|
||||
{Array(rows)
|
||||
@@ -59,14 +59,14 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-x-6 bg-background p-4"
|
||||
className="bg-background flex items-center justify-between gap-x-6 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<File className="h-8 w-8 text-muted-foreground/80" strokeWidth={1.5} />
|
||||
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="h-1.5 w-24 rounded-full bg-neutral-300 md:w-36 dark:bg-foreground/30" />
|
||||
<div className="h-1.5 w-16 rounded-full bg-neutral-200 md:w-24 dark:bg-foreground/20" />
|
||||
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
|
||||
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
||||
@@ -42,11 +41,7 @@ export const WebhookMultiSelectCombobox = ({
|
||||
placeholder={_(msg`Select triggers`)}
|
||||
hideClearAllButton
|
||||
hidePlaceholderWhenSelected
|
||||
emptyIndicator={
|
||||
<p className="text-center text-sm">
|
||||
<Trans>No triggers available</Trans>
|
||||
</p>
|
||||
}
|
||||
emptyIndicator={<p className="text-center text-sm">No triggers available</p>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -96,9 +96,7 @@ export const AdminDocumentLogsTable = ({ envelopeId }: AdminDocumentLogsTablePro
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
<Trans>N/A</Trans>
|
||||
</p>
|
||||
<p>N/A</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } 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';
|
||||
@@ -89,9 +88,7 @@ export const DocumentLogsTable = ({ documentId, userId }: DocumentLogsTableProps
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
<Trans>N/A</Trans>
|
||||
</p>
|
||||
<p>N/A</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
BarChart3,
|
||||
Building2Icon,
|
||||
FileStack,
|
||||
MailIcon,
|
||||
Settings,
|
||||
Trophy,
|
||||
Users,
|
||||
@@ -123,20 +122,6 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/email-domains') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/email-domains">
|
||||
<MailIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Email Domains</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EmailDomainStatus } from '@prisma/client';
|
||||
import { CheckCircle2Icon, ClockIcon, CopyIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, redirect } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@documenso/ui/primitives/alert-dialog';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import type { Route } from './+types/email-domains.$id';
|
||||
|
||||
export function loader({ params }: Route.LoaderArgs) {
|
||||
const id = params.id;
|
||||
|
||||
if (!id) {
|
||||
throw redirect('/admin/email-domains');
|
||||
}
|
||||
|
||||
return { emailDomainId: id };
|
||||
}
|
||||
|
||||
export default function AdminEmailDomainDetailPage({ loaderData }: Route.ComponentProps) {
|
||||
const { emailDomainId } = loaderData;
|
||||
|
||||
const { _, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
data: emailDomain,
|
||||
isPending: isLoading,
|
||||
refetch,
|
||||
} = trpc.admin.emailDomain.get.useQuery({ emailDomainId });
|
||||
|
||||
const { mutate: reregisterDomain, isPending: isReregistering } =
|
||||
trpc.admin.emailDomain.reregister.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Domain re-registered`),
|
||||
description: _(
|
||||
msg`The SES identity has been deleted and recreated with the same keys. DNS records remain unchanged.`,
|
||||
),
|
||||
});
|
||||
|
||||
void refetch();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to re-register email domain`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const dnsRecords = useMemo(() => {
|
||||
if (!emailDomain) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return generateEmailDomainRecords(emailDomain.selector, emailDomain.publicKey);
|
||||
}, [emailDomain]);
|
||||
|
||||
const emailColumns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: _(msg`Email`),
|
||||
accessorKey: 'email',
|
||||
},
|
||||
{
|
||||
header: _(msg`Display Name`),
|
||||
accessorKey: 'emailName',
|
||||
},
|
||||
{
|
||||
header: _(msg`Created`),
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||
},
|
||||
] satisfies DataTableColumnDef<NonNullable<typeof emailDomain>['emails'][number]>[];
|
||||
}, []);
|
||||
|
||||
const onCopyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading || !emailDomain) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Email Domain</Trans>
|
||||
</h2>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
<Trans>Loading...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingDuration =
|
||||
emailDomain.status === EmailDomainStatus.PENDING
|
||||
? DateTime.fromJSDate(new Date(emailDomain.createdAt)).toRelative()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<h2 className="text-2xl font-semibold">{emailDomain.domain}</h2>
|
||||
|
||||
{match(emailDomain.status)
|
||||
.with(EmailDomainStatus.ACTIVE, () => (
|
||||
<Badge>
|
||||
<CheckCircle2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
|
||||
<Trans>Active</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(EmailDomainStatus.PENDING, () => (
|
||||
<Badge variant="warning">
|
||||
<ClockIcon className="mr-2 h-4 w-4 text-yellow-500 dark:text-yellow-200" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Trans>ID</Trans>: {emailDomain.id}
|
||||
</div>
|
||||
<div>
|
||||
<Trans>Organisation</Trans>:{' '}
|
||||
<Link
|
||||
to={`/admin/organisations/${emailDomain.organisation.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{emailDomain.organisation.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Trans>Selector</Trans>: {emailDomain.selector}
|
||||
</div>
|
||||
<div>
|
||||
<Trans>Created</Trans>: {i18n.date(emailDomain.createdAt, DateTime.DATETIME_MED)}
|
||||
</div>
|
||||
<div>
|
||||
<Trans>Last Verified</Trans>:{' '}
|
||||
{emailDomain.lastVerifiedAt
|
||||
? i18n.date(emailDomain.lastVerifiedAt, DateTime.DATETIME_MED)
|
||||
: '-'}
|
||||
</div>
|
||||
{pendingDuration && (
|
||||
<div className="mt-1 text-yellow-600 dark:text-yellow-400">
|
||||
<Trans>Pending since</Trans>: {pendingDuration}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Admin Actions</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mt-2 flex gap-x-4">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" loading={isReregistering}>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Re-register</Trans>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans>Re-register Email Domain</Trans>
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans>
|
||||
This will delete the existing SES identity for{' '}
|
||||
<strong>{emailDomain.domain}</strong> and recreate it using the same DKIM keys.
|
||||
The user will not need to update their DNS records. The domain status will be
|
||||
reset to Pending.
|
||||
</Trans>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans>Cancel</Trans>
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction
|
||||
onClick={() => reregisterDomain({ emailDomainId: emailDomain.id })}
|
||||
>
|
||||
<Trans>Re-register</Trans>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>DNS Records</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{dnsRecords.map((record, index) => (
|
||||
<div key={index} className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">
|
||||
{record.type} <Trans>Record</Trans>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void onCopyToClipboard(record.value)}
|
||||
>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Copy Value</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Name</Trans>:{' '}
|
||||
</span>
|
||||
<code className="rounded bg-muted px-1 py-0.5">{record.name}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Value</Trans>:{' '}
|
||||
</span>
|
||||
<code className="block break-all rounded bg-muted px-1 py-0.5">{record.value}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Emails</Trans> ({emailDomain.emails.length})
|
||||
</h3>
|
||||
|
||||
<div className="mt-4">
|
||||
{emailDomain.emails.length > 0 ? (
|
||||
<DataTable
|
||||
columns={emailColumns}
|
||||
data={emailDomain.emails}
|
||||
perPage={emailDomain.emails.length}
|
||||
currentPage={1}
|
||||
totalPages={1}
|
||||
onPaginationChange={() => {}}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>No emails configured for this domain.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EmailDomainStatus } from '@prisma/client';
|
||||
import { CheckCircle2Icon, ClockIcon, Loader } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
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 { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
export default function AdminEmailDomainsPage() {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const [term, setTerm] = useState(() => searchParams?.get?.('term') ?? '');
|
||||
const debouncedTerm = useDebouncedValue(term, 500);
|
||||
|
||||
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||
const statusParam = searchParams?.get?.('status') ?? 'ALL';
|
||||
|
||||
const statusFilter =
|
||||
statusParam === 'PENDING' || statusParam === 'ACTIVE' ? statusParam : undefined;
|
||||
|
||||
const { data: findEmailDomainsData, isPending: isFindEmailDomainsLoading } =
|
||||
trpc.admin.emailDomain.find.useQuery(
|
||||
{
|
||||
query: debouncedTerm,
|
||||
page: page || 1,
|
||||
perPage: perPage || 20,
|
||||
status: statusFilter,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
const results = findEmailDomainsData ?? {
|
||||
data: [],
|
||||
perPage: 20,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: _(msg`Domain`),
|
||||
accessorKey: 'domain',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/admin/email-domains/${row.original.id}`}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[15rem]"
|
||||
>
|
||||
{row.original.domain}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: _(msg`Organisation`),
|
||||
accessorKey: 'organisation',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/admin/organisations/${row.original.organisation.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{row.original.organisation.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: _(msg`Status`),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) =>
|
||||
match(row.original.status)
|
||||
.with(EmailDomainStatus.ACTIVE, () => (
|
||||
<Badge>
|
||||
<CheckCircle2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
|
||||
<Trans>Active</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(EmailDomainStatus.PENDING, () => (
|
||||
<Badge variant="warning">
|
||||
<ClockIcon className="mr-2 h-4 w-4 text-yellow-500 dark:text-yellow-200" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.exhaustive(),
|
||||
},
|
||||
{
|
||||
header: _(msg`Emails`),
|
||||
accessorKey: '_count',
|
||||
cell: ({ row }) => row.original._count.emails,
|
||||
},
|
||||
{
|
||||
header: _(msg`Created`),
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||
},
|
||||
{
|
||||
header: _(msg`Last Verified`),
|
||||
accessorKey: 'lastVerifiedAt',
|
||||
cell: ({ row }) =>
|
||||
row.original.lastVerifiedAt ? i18n.date(row.original.lastVerifiedAt) : '-',
|
||||
},
|
||||
{
|
||||
header: _(msg`Actions`),
|
||||
cell: ({ row }) => (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/admin/email-domains/${row.original.id}`}>
|
||||
<Trans>View</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
const onPaginationChange = (newPage: number, newPerPage: number) => {
|
||||
updateSearchParams({
|
||||
page: newPage,
|
||||
perPage: newPerPage,
|
||||
});
|
||||
};
|
||||
|
||||
const onStatusChange = (value: string) => {
|
||||
updateSearchParams({
|
||||
status: value === 'ALL' ? undefined : value,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Email Domains</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<Input
|
||||
className="flex-1"
|
||||
type="search"
|
||||
placeholder={_(msg`Search by domain or organisation name`)}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
<Select value={statusParam} onValueChange={onStatusChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={_(msg`Filter by status`)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">
|
||||
<Trans>All Statuses</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="PENDING">
|
||||
<Trans>Pending</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="ACTIVE">
|
||||
<Trans>Active</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage ?? 20}
|
||||
currentPage={results.currentPage ?? 1}
|
||||
totalPages={results.totalPages ?? 1}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isFindEmailDomainsLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,11 +107,7 @@ export default function OrganisationGroupSettingsPage({
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.name}</Link>
|
||||
{row.original.user.id === organisation?.ownerUserId && (
|
||||
<Badge>
|
||||
<Trans>Owner</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{row.original.user.id === organisation?.ownerUserId && <Badge>Owner</Badge>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -212,9 +208,7 @@ export default function OrganisationGroupSettingsPage({
|
||||
{SUBSCRIPTION_STATUS_MAP[organisation.subscription.status]} subscription found
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<Trans>No subscription found</Trans>
|
||||
</span>
|
||||
<span>No subscription found</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
@@ -91,7 +90,6 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
delegateDocumentOwnership: delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -17,12 +16,12 @@ import {
|
||||
DocumentReadOnlyFields,
|
||||
mapFieldsWithRecipients,
|
||||
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||
|
||||
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
||||
@@ -155,9 +154,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
{envelope.internalVersion === 2 ? (
|
||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={envelope.fields}
|
||||
recipients={envelope.recipients}
|
||||
@@ -172,10 +169,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<EnvelopePdfViewer
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="preview"
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
scrollParentRef="window"
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -197,12 +193,11 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelope.envelopeItems[0]}
|
||||
token={undefined}
|
||||
key={envelope.envelopeItems[0].id}
|
||||
version="current"
|
||||
scrollParentRef="window"
|
||||
version="signed"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
||||
|
||||
if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-2 text-foreground">
|
||||
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
|
||||
<Spinner />
|
||||
<Trans>Redirecting</Trans>
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
||||
|
||||
if (isLoadingEnvelope) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-2 text-foreground">
|
||||
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
|
||||
<Spinner />
|
||||
<Trans>Loading</Trans>
|
||||
</div>
|
||||
@@ -99,9 +99,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
||||
return (
|
||||
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={envelope.fields}
|
||||
recipients={envelope.recipients}
|
||||
|
||||
@@ -66,9 +66,7 @@ export default function DocumentsFoldersPage() {
|
||||
onClick={() => navigateToFolder(null)}
|
||||
>
|
||||
<HomeIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Home</Trans>
|
||||
</span>
|
||||
<span>Home</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +76,7 @@ export default function DocumentsFoldersPage() {
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md py-6">
|
||||
<SearchIcon className="absolute left-2 top-9 h-4 w-4 text-muted-foreground" />
|
||||
<SearchIcon className="text-muted-foreground absolute left-2 top-9 h-4 w-4" />
|
||||
<Input
|
||||
placeholder={t`Search folders...`}
|
||||
value={searchTerm}
|
||||
@@ -93,7 +91,7 @@ export default function DocumentsFoldersPage() {
|
||||
|
||||
{isFoldersLoading ? (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -128,7 +126,7 @@ export default function DocumentsFoldersPage() {
|
||||
|
||||
<div>
|
||||
{searchTerm && foldersData?.folders.filter(isFolderMatchingSearch).length === 0 && (
|
||||
<div className="mt-6 text-center text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-6 text-center">
|
||||
<Trans>No folders found matching "{searchTerm}"</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,6 @@ export default function TeamsSettingsPage() {
|
||||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
} = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
@@ -68,7 +67,6 @@ export default function TeamsSettingsPage() {
|
||||
includeAuditLog,
|
||||
defaultRecipients,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
...(signatureTypes.length === 0
|
||||
? {
|
||||
typedSignatureEnabled: null,
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-row items-center justify-center space-x-2 text-xs text-muted-foreground/50',
|
||||
'text-muted-foreground/50 flex flex-row items-center justify-center space-x-2 text-xs',
|
||||
{
|
||||
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
|
||||
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
|
||||
@@ -164,7 +164,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="max-w-[40ch] space-y-2 py-2 text-muted-foreground">
|
||||
<TooltipContent className="text-muted-foreground max-w-[40ch] space-y-2 py-2">
|
||||
{isPublicProfileVisible ? (
|
||||
<>
|
||||
<p>
|
||||
|
||||
@@ -8,16 +8,15 @@ import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||
|
||||
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
|
||||
@@ -174,9 +173,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
{envelope.internalVersion === 2 ? (
|
||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={envelope.fields}
|
||||
recipients={envelope.recipients}
|
||||
@@ -190,10 +187,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<EnvelopePdfViewer
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="preview"
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
scrollParentRef="window"
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -214,12 +210,11 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
documentMeta={mockedDocumentMeta}
|
||||
/>
|
||||
|
||||
<PDFViewer
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelope.envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="current"
|
||||
version="signed"
|
||||
key={envelope.envelopeItems[0].id}
|
||||
scrollParentRef="window"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -66,9 +66,7 @@ export default function TemplatesFoldersPage() {
|
||||
onClick={() => navigateToFolder(null)}
|
||||
>
|
||||
<HomeIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Home</Trans>
|
||||
</span>
|
||||
<span>Home</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +76,7 @@ export default function TemplatesFoldersPage() {
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md py-6">
|
||||
<SearchIcon className="absolute left-2 top-9 h-4 w-4 text-muted-foreground" />
|
||||
<SearchIcon className="text-muted-foreground absolute left-2 top-9 h-4 w-4" />
|
||||
<Input
|
||||
placeholder={t`Search folders...`}
|
||||
value={searchTerm}
|
||||
@@ -93,7 +91,7 @@ export default function TemplatesFoldersPage() {
|
||||
|
||||
{isFoldersLoading ? (
|
||||
<div className="mt- flex justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -128,7 +126,7 @@ export default function TemplatesFoldersPage() {
|
||||
|
||||
<div>
|
||||
{searchTerm && foldersData?.folders.filter(isFolderMatchingSearch).length === 0 && (
|
||||
<div className="mt-6 text-center text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-6 text-center">
|
||||
<Trans>No folders found matching "{searchTerm}"</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-4 sm:py-32">
|
||||
<div className="flex flex-col items-center">
|
||||
<Avatar className="h-24 w-24 border-2 border-solid dark:border-border">
|
||||
<Avatar className="dark:border-border h-24 w-24 border-2 border-solid">
|
||||
{publicProfile.avatarImageId && (
|
||||
<AvatarImage src={formatAvatarUrl(publicProfile.avatarImageId)} />
|
||||
)}
|
||||
@@ -99,10 +99,10 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
/>
|
||||
|
||||
<div className="ml-2">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
<p className="text-foreground text-base font-semibold">
|
||||
{BADGE_DATA[publicProfile.badge.type].name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
<Trans>
|
||||
Since {DateTime.fromJSDate(publicProfile.badge.since).toFormat('LLL ‘yy')}
|
||||
</Trans>
|
||||
@@ -113,7 +113,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1 text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-4 space-y-1">
|
||||
{(profile.bio ?? '').split('\n').map((line, index) => (
|
||||
<p
|
||||
key={index}
|
||||
@@ -127,7 +127,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
|
||||
{templates.length === 0 && (
|
||||
<div className="mt-4 w-full max-w-xl border-t pt-4">
|
||||
<p className="max-w-[60ch] whitespace-pre-wrap break-words text-center text-sm leading-relaxed text-muted-foreground">
|
||||
<p className="text-muted-foreground max-w-[60ch] whitespace-pre-wrap break-words text-center text-sm leading-relaxed">
|
||||
<Trans>
|
||||
It looks like {publicProfile.name} hasn't added any documents to their profile yet.
|
||||
</Trans>{' '}
|
||||
@@ -167,24 +167,24 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
<TableBody>
|
||||
{templates.map((template) => (
|
||||
<TableRow key={template.id}>
|
||||
<TableCell className="flex flex-col justify-between overflow-hidden text-sm text-muted-foreground sm:flex-row">
|
||||
<TableCell className="text-muted-foreground flex flex-col justify-between overflow-hidden text-sm sm:flex-row">
|
||||
<div className="flex flex-1 items-start justify-start gap-2">
|
||||
<FileIcon
|
||||
className="h-8 w-8 flex-shrink-0 text-muted-foreground/40"
|
||||
className="text-muted-foreground/40 h-8 w-8 flex-shrink-0"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-none text-foreground">
|
||||
<p className="text-foreground text-sm font-semibold leading-none">
|
||||
{template.publicTitle}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-3 max-w-[70ch] whitespace-normal text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 max-w-[70ch] whitespace-normal text-xs">
|
||||
{template.publicDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button asChild className="w-fit">
|
||||
<Button asChild className="w-20">
|
||||
<Link to={formatDirectTemplatePath(template.directLink.token)}>
|
||||
<Trans>Sign</Trans>
|
||||
</Link>
|
||||
|
||||
@@ -198,7 +198,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mb-8 mt-2.5 flex items-center gap-x-2 text-muted-foreground">
|
||||
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
<p className="text-muted-foreground/80">
|
||||
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
|
||||
@@ -246,12 +246,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||
<DocumentSigningPageViewV2 />
|
||||
</EnvelopeRenderProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
|
||||
@@ -25,7 +25,6 @@ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settin
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
|
||||
@@ -141,10 +140,6 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
throw redirect(`/sign/${token}/rejected`);
|
||||
}
|
||||
|
||||
if (isRecipientExpired(recipient)) {
|
||||
throw redirect(`/sign/${token}/expired`);
|
||||
}
|
||||
|
||||
if (
|
||||
document.status === DocumentStatus.COMPLETED ||
|
||||
recipient.signingStatus === SigningStatus.SIGNED
|
||||
@@ -206,8 +201,7 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
return envelopeForSigning;
|
||||
}
|
||||
|
||||
const { envelope, recipient, isCompleted, isRejected, isExpired, isRecipientsTurn } =
|
||||
envelopeForSigning;
|
||||
const { envelope, recipient, isCompleted, isRejected, isRecipientsTurn } = envelopeForSigning;
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw redirect(`/sign/${token}/waiting`);
|
||||
@@ -239,6 +233,12 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
} as const;
|
||||
}
|
||||
|
||||
await viewedDocument({
|
||||
token,
|
||||
requestMetadata,
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
}).catch(() => null);
|
||||
|
||||
if (isRejected) {
|
||||
throw redirect(`/sign/${token}/rejected`);
|
||||
}
|
||||
@@ -247,16 +247,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
throw redirect(envelope.documentMeta.redirectUrl || `/sign/${token}/complete`);
|
||||
}
|
||||
|
||||
if (isExpired) {
|
||||
throw redirect(`/sign/${token}/expired`);
|
||||
}
|
||||
|
||||
await viewedDocument({
|
||||
token,
|
||||
requestMetadata,
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
}).catch(() => null);
|
||||
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
envelopeForSigning,
|
||||
@@ -504,12 +494,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||
<DocumentSigningPageViewV2 />
|
||||
</EnvelopeRenderProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { TimerOffIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
import { truncateTitle } from '~/utils/truncate-title';
|
||||
|
||||
import type { Route } from './+types/expired';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const document = await getDocumentAndSenderByToken({
|
||||
token,
|
||||
requireAccessAuth: false,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const title = document.title;
|
||||
|
||||
const recipient = await getRecipientByToken({ token }).catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
const recipientEmail = recipient.email;
|
||||
|
||||
if (isDocumentAccessValid) {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
recipientEmail,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
recipientEmail,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExpiredSigningPage({ loaderData }: Route.ComponentProps) {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
|
||||
const { isDocumentAccessValid, recipientEmail, title } = loaderData;
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={recipientEmail} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge
|
||||
variant="neutral"
|
||||
size="default"
|
||||
title={title}
|
||||
className="mb-6 rounded-xl border bg-transparent"
|
||||
>
|
||||
{truncateTitle(title ?? '')}
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<TimerOffIcon className="h-10 w-10 text-orange-500" />
|
||||
|
||||
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Signing Deadline Expired</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-[60ch] text-center text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
The signing deadline for this document has passed. Please contact the document owner if
|
||||
you need a new copy to sign.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
|
||||
|
||||
import {
|
||||
@@ -13,7 +12,6 @@ import { EmbedDocumentCompleted } from '~/components/embed/embed-document-comple
|
||||
import { EmbedDocumentRejected } from '~/components/embed/embed-document-rejected';
|
||||
import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
|
||||
import { EmbedPaywall } from '~/components/embed/embed-paywall';
|
||||
import { EmbedRecipientExpired } from '~/components/embed/embed-recipient-expired';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
@@ -80,10 +78,6 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
return <EmbedDocumentWaitingForTurn />;
|
||||
}
|
||||
|
||||
if (error.status === 403 && error.data.type === 'embed-recipient-expired') {
|
||||
return <EmbedRecipientExpired />;
|
||||
}
|
||||
|
||||
// !: Not used at the moment, may be removed in the future.
|
||||
if (error.status === 403 && error.data.type === 'embed-document-rejected') {
|
||||
return <EmbedDocumentRejected />;
|
||||
@@ -95,9 +89,5 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Trans>Not Found</Trans>
|
||||
</div>
|
||||
);
|
||||
return <div>Not Found</div>;
|
||||
}
|
||||
|
||||
@@ -320,12 +320,7 @@ const EmbedDirectTemplatePageV2 = ({
|
||||
user={user}
|
||||
isDirectTemplate={true}
|
||||
>
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||
<EmbedSignDocumentV2ClientPage
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { EmbedSignDocumentV1ClientPage } from '~/components/embed/embed-document-signing-page-v1';
|
||||
@@ -79,17 +78,6 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isRecipientExpired(recipient)) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-recipient-expired',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
@@ -202,7 +190,7 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
const { envelope, recipient, isRecipientsTurn, isExpired } = envelopeForSigning;
|
||||
const { envelope, recipient, isRecipientsTurn } = envelopeForSigning;
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
|
||||
|
||||
@@ -220,17 +208,6 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isExpired) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-recipient-expired',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw data(
|
||||
{
|
||||
@@ -405,12 +382,7 @@ const EmbedSignDocumentPageV2 = ({
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={token}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={token}>
|
||||
<EmbedSignDocumentV2ClientPage
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Outlet, useLoaderData } from 'react-router';
|
||||
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
@@ -76,11 +75,7 @@ export default function AuthoringLayout() {
|
||||
}, []);
|
||||
|
||||
if (!hasValidToken) {
|
||||
return (
|
||||
<div>
|
||||
<Trans>Invalid embedding presign token provided</Trans>
|
||||
</div>
|
||||
);
|
||||
return <div>Invalid embedding presign token provided</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { useRevalidator } from 'react-router';
|
||||
|
||||
@@ -283,10 +282,8 @@ export default function MultisignPage() {
|
||||
</DocumentSigningProvider>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
|
||||
<span>
|
||||
<Trans>Powered by</Trans>
|
||||
</span>
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
@@ -300,10 +297,8 @@ export default function MultisignPage() {
|
||||
<MultiSignDocumentList envelopes={envelopes} onDocumentSelect={onSelectDocument} />
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
|
||||
<span>
|
||||
<Trans>Powered by</Trans>
|
||||
</span>
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"content-disposition": "^1.0.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"hono": "4.11.4",
|
||||
"hono-rate-limiter": "^0.4.2",
|
||||
"hono-react-router-adapter": "^0.6.5",
|
||||
"input-otp": "^1.4.2",
|
||||
"isbot": "^5.1.32",
|
||||
@@ -105,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.6.1"
|
||||
"version": "2.6.0"
|
||||
}
|
||||
|
||||
@@ -23,10 +23,6 @@ import {
|
||||
ZGetPresignedPostUrlRequestSchema,
|
||||
ZUploadPdfRequestSchema,
|
||||
} from './files.types';
|
||||
import getEnvelopeItemImageRoute from './routes/get-envelope-item-image';
|
||||
import getEnvelopeItemImageByTokenRoute from './routes/get-envelope-item-image-by-token';
|
||||
import getEnvelopeItemMetaRoute from './routes/get-envelope-item-meta';
|
||||
import getEnvelopeItemMetaByTokenRoute from './routes/get-envelope-item-meta-by-token';
|
||||
|
||||
export const filesRoute = new Hono<HonoEnv>()
|
||||
/**
|
||||
@@ -323,11 +319,3 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Envelope item meta routes for both tokens and auth based
|
||||
filesRoute.route('/', getEnvelopeItemMetaRoute);
|
||||
filesRoute.route('/', getEnvelopeItemMetaByTokenRoute);
|
||||
|
||||
// Image routes for both tokens and auth based
|
||||
filesRoute.route('/', getEnvelopeItemImageRoute);
|
||||
filesRoute.route('/', getEnvelopeItemImageByTokenRoute);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DocumentDataType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
@@ -73,24 +72,3 @@ export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
|
||||
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
|
||||
typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema
|
||||
>;
|
||||
|
||||
export const ZGetEnvelopeItemMetaSchema = z.object({
|
||||
envelopeItemId: z.string(),
|
||||
documentDataId: z.string(),
|
||||
documentDataType: z.nativeEnum(DocumentDataType),
|
||||
pages: z
|
||||
.object({
|
||||
originalWidth: z.number(),
|
||||
originalHeight: z.number(),
|
||||
scale: z.number(),
|
||||
scaledWidth: z.number(),
|
||||
scaledHeight: z.number(),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
export const ZGetEnvelopeItemsMetaResponseSchema = z.object({
|
||||
envelopeItems: z.array(ZGetEnvelopeItemMetaSchema),
|
||||
});
|
||||
|
||||
export type TGetEnvelopeItemsMetaResponse = z.infer<typeof ZGetEnvelopeItemsMetaResponseSchema>;
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../../router';
|
||||
import { handleEnvelopeItemPageRequest } from './get-envelope-item-image';
|
||||
|
||||
const route = new Hono<HonoEnv>();
|
||||
|
||||
const ZGetEnvelopeItemPageTokenParamsSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
envelopeId: z.string().min(1),
|
||||
envelopeItemId: z.string().min(1),
|
||||
documentDataId: z.string().min(1),
|
||||
version: z.enum(['initial', 'current']),
|
||||
pageIndex: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a single PDF page as a JPEG image using a token.
|
||||
*/
|
||||
route.get(
|
||||
'/token/:token/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/:pageIndex/image.jpeg',
|
||||
sValidator('param', ZGetEnvelopeItemPageTokenParamsSchema),
|
||||
async (c) => {
|
||||
const { token, envelopeId, envelopeItemId, documentDataId, version, pageIndex } =
|
||||
c.req.valid('param');
|
||||
|
||||
// Validate envelope access.
|
||||
const envelopeItem = await prisma.envelopeItem.findFirst({
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
documentDataId,
|
||||
envelope: {
|
||||
id: envelopeId,
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelopeItem) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemPageRequest({
|
||||
c,
|
||||
envelopeItem,
|
||||
version,
|
||||
pageIndex,
|
||||
cacheStrategy: 'public',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default route;
|
||||
@@ -1,180 +0,0 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
||||
import { type Context, Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { pdfToImage } from '@documenso/lib/server-only/ai/pdf-to-images';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import type { DocumentDataVersion } from '@documenso/lib/types/document-data';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { UNSAFE_getS3File } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { getEnvelopeItemPageImageS3Key } from '@documenso/lib/utils/envelope-images';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../../router';
|
||||
|
||||
const route = new Hono<HonoEnv>();
|
||||
|
||||
const ZGetEnvelopeItemPageRequestParamsSchema = z.object({
|
||||
envelopeId: z.string().min(1),
|
||||
envelopeItemId: z.string().min(1),
|
||||
documentDataId: z.string().min(1),
|
||||
version: z.enum(['initial', 'current']),
|
||||
pageIndex: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
const ZGetEnvelopeItemPageRequestQuerySchema = z.object({
|
||||
presignToken: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a single PDF page as a JPEG image.
|
||||
*/
|
||||
route.get(
|
||||
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/:pageIndex/image.jpeg',
|
||||
sValidator('param', ZGetEnvelopeItemPageRequestParamsSchema),
|
||||
sValidator('query', ZGetEnvelopeItemPageRequestQuerySchema),
|
||||
async (c) => {
|
||||
const { envelopeId, envelopeItemId, documentDataId, version, pageIndex } = c.req.valid('param');
|
||||
|
||||
const { presignToken } = c.req.valid('query');
|
||||
|
||||
const session = await getOptionalSession(c);
|
||||
|
||||
let userId = session.user?.id;
|
||||
|
||||
// Check presignToken if provided
|
||||
if (presignToken) {
|
||||
const verifiedToken = await verifyEmbeddingPresignToken({
|
||||
token: presignToken,
|
||||
}).catch(() => undefined);
|
||||
|
||||
userId = verifiedToken?.userId;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: { id: envelopeId },
|
||||
include: {
|
||||
envelopeItems: {
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
documentDataId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
const [envelopeItem] = envelope.envelopeItems;
|
||||
|
||||
if (!envelopeItem?.documentData) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
// Check team access
|
||||
const team = await getTeamById({
|
||||
userId,
|
||||
teamId: envelope.teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!team) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemPageRequest({
|
||||
c,
|
||||
envelopeItem,
|
||||
version,
|
||||
pageIndex,
|
||||
cacheStrategy: 'private',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
type HandleEnvelopeItemPageRequestOptions = {
|
||||
c: Context<HonoEnv>;
|
||||
envelopeItem: EnvelopeItem & {
|
||||
documentData: DocumentData;
|
||||
};
|
||||
pageIndex: number;
|
||||
version: DocumentDataVersion;
|
||||
|
||||
/**
|
||||
* The type of cache strategy to use.
|
||||
*
|
||||
* For access via tokens, we can use a public cache to allow the CDN to cache it.
|
||||
*
|
||||
* For access via session, we must use a private cache.
|
||||
*/
|
||||
cacheStrategy: 'private' | 'public';
|
||||
};
|
||||
|
||||
export const handleEnvelopeItemPageRequest = async ({
|
||||
c,
|
||||
envelopeItem,
|
||||
pageIndex,
|
||||
version,
|
||||
cacheStrategy,
|
||||
}: HandleEnvelopeItemPageRequestOptions) => {
|
||||
// Determine which PDF data to use based on version requested.
|
||||
const documentDataToUse =
|
||||
version === 'current' ? envelopeItem.documentData.data : envelopeItem.documentData.initialData;
|
||||
|
||||
// Return the image if it already exists in S3.
|
||||
if (envelopeItem.documentData.type === 'S3_PATH') {
|
||||
const s3Key = getEnvelopeItemPageImageS3Key(documentDataToUse, pageIndex);
|
||||
|
||||
const image = await UNSAFE_getS3File(s3Key).catch(() => null);
|
||||
|
||||
if (image) {
|
||||
// Note: Only set these headers on success.
|
||||
c.header('Content-Type', 'image/jpeg');
|
||||
c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
|
||||
|
||||
return c.body(image);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch PDF to render the page on the spot if it doesn't exist in S3.
|
||||
const pdfBytes = await getFileServerSide({
|
||||
type: envelopeItem.documentData.type,
|
||||
data: documentDataToUse,
|
||||
});
|
||||
|
||||
// Render page to image.
|
||||
const { image } = await pdfToImage(pdfBytes, {
|
||||
scale: PDF_IMAGE_RENDER_SCALE,
|
||||
pageIndex,
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
return {
|
||||
image: null,
|
||||
};
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
return c.json({ error: 'Failed to render page to image' }, 500);
|
||||
}
|
||||
|
||||
// Note: Only set these headers on success.
|
||||
c.header('Content-Type', 'image/jpeg');
|
||||
c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
|
||||
|
||||
return c.body(image);
|
||||
};
|
||||
|
||||
export default route;
|
||||
@@ -1,54 +0,0 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../../router';
|
||||
import { handleEnvelopeItemsMetaRequest } from './get-envelope-item-meta';
|
||||
|
||||
const route = new Hono<HonoEnv>();
|
||||
|
||||
const ZGetEnvelopeMetaByTokenParamSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
envelopeId: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns metadata for all envelope items including page counts and dimensions using a token.
|
||||
*/
|
||||
route.get(
|
||||
'/token/:token/envelope/:envelopeId/meta',
|
||||
sValidator('param', ZGetEnvelopeMetaByTokenParamSchema),
|
||||
async (c) => {
|
||||
const { token, envelopeId } = c.req.valid('param');
|
||||
|
||||
// Validate token belongs to envelope
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
envelopeId,
|
||||
},
|
||||
select: {
|
||||
envelope: {
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: { documentData: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemsMetaRequest({
|
||||
c,
|
||||
envelopeItems: recipient.envelope.envelopeItems,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default route;
|
||||
@@ -1,140 +0,0 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
||||
import { type Context, Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { extractAndStorePdfImages } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../../router';
|
||||
import type { TGetEnvelopeItemsMetaResponse } from '../files.types';
|
||||
|
||||
const route = new Hono<HonoEnv>();
|
||||
|
||||
const ZGetEnvelopeMetaParamsSchema = z.object({
|
||||
envelopeId: z.string().min(1),
|
||||
});
|
||||
|
||||
const ZGetEnvelopeMetaQuerySchema = z.object({
|
||||
presignToken: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns metadata for all envelope items including page counts and dimensions.
|
||||
*/
|
||||
route.get(
|
||||
'/envelope/:envelopeId/meta',
|
||||
sValidator('param', ZGetEnvelopeMetaParamsSchema),
|
||||
sValidator('query', ZGetEnvelopeMetaQuerySchema),
|
||||
async (c) => {
|
||||
const { envelopeId } = c.req.valid('param');
|
||||
const { presignToken } = c.req.valid('query');
|
||||
|
||||
const session = await getOptionalSession(c);
|
||||
|
||||
let userId = session.user?.id;
|
||||
|
||||
// Check presignToken if provided
|
||||
if (presignToken) {
|
||||
const verifiedToken = await verifyEmbeddingPresignToken({
|
||||
token: presignToken,
|
||||
}).catch(() => undefined);
|
||||
|
||||
userId = verifiedToken?.userId;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
// Note: Access is verified in the getTeamById call after this.
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: { documentData: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
// Check access to envelope.
|
||||
const team = await getTeamById({
|
||||
userId,
|
||||
teamId: envelope.teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!team) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemsMetaRequest({
|
||||
c,
|
||||
envelopeItems: envelope.envelopeItems,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
type HandleEnvelopeItemsMetaRequestOptions = {
|
||||
c: Context<HonoEnv>;
|
||||
envelopeItems: (EnvelopeItem & {
|
||||
documentData: DocumentData;
|
||||
})[];
|
||||
};
|
||||
|
||||
export const handleEnvelopeItemsMetaRequest = async ({
|
||||
c,
|
||||
envelopeItems,
|
||||
}: HandleEnvelopeItemsMetaRequestOptions) => {
|
||||
const response = await Promise.all(
|
||||
envelopeItems.map(async (item) => {
|
||||
let pageMetadata = item.documentData.metadata;
|
||||
|
||||
// Runtime backfill if pageMetadata is missing.
|
||||
if (!pageMetadata) {
|
||||
const pdfBytes = await getFileServerSide({
|
||||
type: item.documentData.type,
|
||||
data: item.documentData.data,
|
||||
});
|
||||
|
||||
const pdfPageMetadata: TDocumentDataMeta['pages'] = await extractAndStorePdfImages(
|
||||
new Uint8Array(pdfBytes).buffer,
|
||||
item.documentData.id,
|
||||
);
|
||||
|
||||
pageMetadata = {
|
||||
pages: pdfPageMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
const pages = pageMetadata.pages ?? [];
|
||||
|
||||
return {
|
||||
envelopeItemId: item.id,
|
||||
documentDataId: item.documentData.id,
|
||||
documentDataType: item.documentData.type,
|
||||
pages: pages.map((page) => ({
|
||||
originalWidth: page.originalWidth,
|
||||
originalHeight: page.originalHeight,
|
||||
scale: page.scale,
|
||||
scaledWidth: page.scaledWidth,
|
||||
scaledHeight: page.scaledHeight,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return c.json({ envelopeItems: response } satisfies TGetEnvelopeItemsMetaResponse);
|
||||
};
|
||||
|
||||
export default route;
|
||||
+47
-41
@@ -1,25 +1,18 @@
|
||||
import { Hono } from 'hono';
|
||||
import { rateLimiter } from 'hono-rate-limiter';
|
||||
import { contextStorage } from 'hono/context-storage';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { RequestIdVariables } from 'hono/request-id';
|
||||
import { requestId } from 'hono/request-id';
|
||||
import type { RequestIdVariables } from 'hono/request-id';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import { tsRestHonoApp } from '@documenso/api/hono';
|
||||
import { auth } from '@documenso/auth/server';
|
||||
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
|
||||
import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import {
|
||||
aiRateLimit,
|
||||
apiTrpcRateLimit,
|
||||
apiV1RateLimit,
|
||||
apiV2RateLimit,
|
||||
fileUploadRateLimit,
|
||||
} from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
|
||||
import { migrateDeletedAccountServiceAccount } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
|
||||
import { migrateLegacyServiceAccount } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
|
||||
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||
@@ -42,13 +35,38 @@ export interface HonoEnv {
|
||||
const app = new Hono<HonoEnv>();
|
||||
|
||||
/**
|
||||
* Database-backed rate limiting for API routes.
|
||||
* Rate limiting for v1 and v2 API routes only.
|
||||
* - 100 requests per minute per IP address
|
||||
*/
|
||||
const apiV1RateLimitMiddleware = createRateLimitMiddleware(apiV1RateLimit);
|
||||
const apiV2RateLimitMiddleware = createRateLimitMiddleware(apiV2RateLimit);
|
||||
const aiRateLimitMiddleware = createRateLimitMiddleware(aiRateLimit);
|
||||
const trpcRateLimitMiddleware = createRateLimitMiddleware(apiTrpcRateLimit);
|
||||
const fileRateLimitMiddleware = createRateLimitMiddleware(fileUploadRateLimit);
|
||||
const rateLimitMiddleware = rateLimiter({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
limit: 100, // 100 requests per window
|
||||
keyGenerator: (c) => {
|
||||
try {
|
||||
return getIpAddress(c.req.raw);
|
||||
} catch (error) {
|
||||
return 'unknown';
|
||||
}
|
||||
},
|
||||
message: {
|
||||
error: 'Too many requests, please try again later.',
|
||||
},
|
||||
});
|
||||
|
||||
const aiRateLimitMiddleware = rateLimiter({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
limit: 3, // 3 requests per window
|
||||
keyGenerator: (c) => {
|
||||
try {
|
||||
return getIpAddress(c.req.raw);
|
||||
} catch (error) {
|
||||
return 'unknown';
|
||||
}
|
||||
},
|
||||
message: {
|
||||
error: 'Too many requests, please try again later.',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Attach session and context to requests.
|
||||
@@ -66,7 +84,6 @@ app.use(async (c, next) => {
|
||||
|
||||
const honoLogger = logger.child({
|
||||
requestId: c.var.requestId,
|
||||
requestPath: c.req.path,
|
||||
ipAddress: metadata.ipAddress,
|
||||
userAgent: metadata.userAgent,
|
||||
});
|
||||
@@ -76,20 +93,14 @@ app.use(async (c, next) => {
|
||||
await next();
|
||||
});
|
||||
|
||||
// Apply cors and rate limits to API routes.
|
||||
app.use(`/api/v1/*`, cors());
|
||||
app.use('/api/v1/*', apiV1RateLimitMiddleware);
|
||||
app.use(`/api/v2/*`, cors());
|
||||
app.use('/api/v2/*', apiV2RateLimitMiddleware);
|
||||
app.use(`/api/v2-beta/*`, cors());
|
||||
app.use('/api/v2-beta/*', apiV2RateLimitMiddleware);
|
||||
// Apply rate limit to /api/v1/*
|
||||
app.use('/api/v1/*', rateLimitMiddleware);
|
||||
app.use('/api/v2/*', rateLimitMiddleware);
|
||||
|
||||
// Auth server.
|
||||
app.route('/api/auth', auth);
|
||||
|
||||
// Files route.
|
||||
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
|
||||
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
|
||||
app.route('/api/files', filesRoute);
|
||||
|
||||
// AI route.
|
||||
@@ -97,26 +108,28 @@ app.use('/api/ai/*', aiRateLimitMiddleware);
|
||||
app.route('/api/ai', aiRoute);
|
||||
|
||||
// API servers.
|
||||
app.use(`/api/v1/*`, cors());
|
||||
app.route('/api/v1', tsRestHonoApp);
|
||||
app.use('/api/jobs/*', jobsClient.getApiHandler());
|
||||
app.use('/api/trpc/*', trpcRateLimitMiddleware);
|
||||
app.use('/api/trpc/*', reactRouterTrpcServer);
|
||||
|
||||
// Unstable API server routes. Order matters for these two.
|
||||
app.get(`/api/v2/openapi.json`, (c) => c.json(openApiDocument));
|
||||
app.get(`${API_V2_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||
app.use(`${API_V2_URL}/*`, cors());
|
||||
// Shadows the download routes that tRPC defines since tRPC-to-openapi doesn't support their return types.
|
||||
app.route(`/api/v2`, downloadRoute);
|
||||
app.use(`/api/v2/*`, async (c) =>
|
||||
app.route(`${API_V2_URL}`, downloadRoute);
|
||||
app.use(`${API_V2_URL}/*`, async (c) =>
|
||||
openApiTrpcServerHandler(c, {
|
||||
isBeta: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Unstable API server routes. Order matters for these two.
|
||||
app.get(`/api/v2-beta/openapi.json`, (c) => c.json(openApiDocument));
|
||||
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||
app.use(`${API_V2_BETA_URL}/*`, cors());
|
||||
// Shadows the download routes that tRPC defines since tRPC-to-openapi doesn't support their return types.
|
||||
app.route(`/api/v2-beta`, downloadRoute);
|
||||
app.use(`/api/v2-beta/*`, async (c) =>
|
||||
app.route(`${API_V2_BETA_URL}`, downloadRoute);
|
||||
app.use(`${API_V2_BETA_URL}/*`, async (c) =>
|
||||
openApiTrpcServerHandler(c, {
|
||||
isBeta: true,
|
||||
}),
|
||||
@@ -131,11 +144,4 @@ if (env('NODE_ENV') !== 'development') {
|
||||
// Start license client to verify license on startup.
|
||||
void LicenseClient.start();
|
||||
|
||||
// Start cron scheduler for background jobs (e.g. envelope expiration sweep).
|
||||
// No-op for Inngest provider which handles cron externally.
|
||||
jobsClient.startCron();
|
||||
|
||||
void migrateDeletedAccountServiceAccount();
|
||||
void migrateLegacyServiceAccount();
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
%PDF-1.7
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [4 0 R]
|
||||
/Count 1
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 1 0 R
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Title (Untitled)
|
||||
/Author (Unknown)
|
||||
/Creator (@libpdf/core)
|
||||
/Producer (@libpdf/core)
|
||||
/CreationDate (D:20260211083727Z)
|
||||
/ModDate (D:20260211083727Z)
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [0 0 595 842]
|
||||
/Resources <<
|
||||
>>
|
||||
/Parent 1 0 R
|
||||
>>
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000072 00000 n
|
||||
0000000121 00000 n
|
||||
0000000290 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 5
|
||||
/Root 2 0 R
|
||||
/Info 3 0 R
|
||||
/ID [<B051F100F1EED01A592FC6119F589603> <B051F100F1EED01A592FC6119F589603>]
|
||||
>>
|
||||
startxref
|
||||
378
|
||||
%%EOF
|
||||
@@ -1,51 +0,0 @@
|
||||
%PDF-1.7
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [4 0 R]
|
||||
/Count 1
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 1 0 R
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Title (Untitled)
|
||||
/Author (Unknown)
|
||||
/Creator (@libpdf/core)
|
||||
/Producer (@libpdf/core)
|
||||
/CreationDate (D:20260211081729Z)
|
||||
/ModDate (D:20260211081729Z)
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [0 0 612 792]
|
||||
/Resources <<
|
||||
>>
|
||||
/Parent 1 0 R
|
||||
>>
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000072 00000 n
|
||||
0000000121 00000 n
|
||||
0000000290 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 5
|
||||
/Root 2 0 R
|
||||
/Info 3 0 R
|
||||
/ID [<94A5FB5DCF5A94AD8C472C493420962C> <94A5FB5DCF5A94AD8C472C493420962C>]
|
||||
>>
|
||||
startxref
|
||||
378
|
||||
%%EOF
|
||||
@@ -1,51 +0,0 @@
|
||||
%PDF-1.7
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [4 0 R]
|
||||
/Count 1
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 1 0 R
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Title (Untitled)
|
||||
/Author (Unknown)
|
||||
/Creator (@libpdf/core)
|
||||
/Producer (@libpdf/core)
|
||||
/CreationDate (D:20260211084535Z)
|
||||
/ModDate (D:20260211084535Z)
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [0 0 1224 792]
|
||||
/Resources <<
|
||||
>>
|
||||
/Parent 1 0 R
|
||||
>>
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000072 00000 n
|
||||
0000000121 00000 n
|
||||
0000000290 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 5
|
||||
/Root 2 0 R
|
||||
/Info 3 0 R
|
||||
/ID [<694452F2208AC8E3DD2D2488544F9F0C> <694452F2208AC8E3DD2D2488544F9F0C>]
|
||||
>>
|
||||
startxref
|
||||
379
|
||||
%%EOF
|
||||
Generated
+101
-40
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -15,12 +15,10 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.2.9",
|
||||
"@libpdf/core": "^0.2.5",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
"ai": "^5.0.104",
|
||||
"cron-parser": "^5.5.0",
|
||||
"luxon": "^3.7.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"posthog-node": "4.18.0",
|
||||
@@ -110,7 +108,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
@@ -144,6 +142,7 @@
|
||||
"content-disposition": "^1.0.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"hono": "4.11.4",
|
||||
"hono-rate-limiter": "^0.4.2",
|
||||
"hono-react-router-adapter": "^0.6.5",
|
||||
"input-otp": "^1.4.2",
|
||||
"isbot": "^5.1.32",
|
||||
@@ -4168,15 +4167,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@libpdf/core": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.2.9.tgz",
|
||||
"integrity": "sha512-BpZvJr9mf2ALQwv7jPMNLmOjiYxytF8+2UO4mt/S3cg5Brw62/UEpBs8IYa4zXsAR/yQW4vVkkfE4OnOYd6Ktw==",
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.2.5.tgz",
|
||||
"integrity": "sha512-+oTpNRkEdL1kVmeJr6qz2wf0yqJx5FmUVN2u0kDuX81wvxyzYOlMjmFD8qbbJqyYiNZp0J7IAcW6VsZr+MW1Uw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.1.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@scure/base": "^2.0.0",
|
||||
"lru-cache": "^11.2.6",
|
||||
"pako": "^2.1.0",
|
||||
"pkijs": "^3.3.3"
|
||||
},
|
||||
@@ -4229,15 +4227,6 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@libpdf/core/node_modules/lru-cache": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@libpdf/core/node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
@@ -12194,15 +12183,6 @@
|
||||
"integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/extension-read-replicas": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/extension-read-replicas/-/extension-read-replicas-0.4.1.tgz",
|
||||
"integrity": "sha512-mCMDloqUKUwx2o5uedTs1FHX3Nxdt1GdRMoeyp1JggjiwOALmIYWhxfIN08M2BZ0w8SKwvJqicJZMjkQYkkijw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@prisma/client": "^6.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz",
|
||||
@@ -20212,18 +20192,6 @@
|
||||
"typescript": ">=5"
|
||||
}
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz",
|
||||
"integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.7.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
@@ -25113,6 +25081,15 @@
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hono-rate-limiter": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/hono-rate-limiter/-/hono-rate-limiter-0.4.2.tgz",
|
||||
"integrity": "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"hono": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hono-react-router-adapter": {
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/hono-react-router-adapter/-/hono-react-router-adapter-0.6.5.tgz",
|
||||
@@ -27388,6 +27365,24 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/make-cancellable-promise": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
|
||||
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-event-props": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
|
||||
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/map-stream": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
|
||||
@@ -27834,6 +27829,23 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-refs": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
|
||||
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
@@ -31863,6 +31875,44 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-pdf": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.3.0.tgz",
|
||||
"integrity": "sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"dequal": "^2.0.3",
|
||||
"make-cancellable-promise": "^2.0.0",
|
||||
"make-event-props": "^2.0.0",
|
||||
"merge-refs": "^2.0.0",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"tiny-invariant": "^1.0.0",
|
||||
"warning": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-pdf/node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-promise-suspense": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
|
||||
@@ -36432,6 +36482,15 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wcwidth": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
||||
@@ -37375,6 +37434,7 @@
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"react-pdf": "^10.3.0",
|
||||
"remeda": "^2.32.0",
|
||||
"sharp": "0.34.5",
|
||||
"skia-canvas": "^3.0.8",
|
||||
@@ -37532,6 +37592,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-pdf": "^10.3.0",
|
||||
"react-rnd": "^10.5.2",
|
||||
"remeda": "^2.32.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
|
||||
+2
-4
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
@@ -86,12 +86,10 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.2.9",
|
||||
"@libpdf/core": "^0.2.5",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
"ai": "^5.0.104",
|
||||
"cron-parser": "^5.5.0",
|
||||
"luxon": "^3.7.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"posthog-node": "4.18.0",
|
||||
|
||||
@@ -437,8 +437,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().nullish(),
|
||||
token: z.string(),
|
||||
expiresAt: z.date().nullish(),
|
||||
expirationNotifiedAt: z.date().nullish(),
|
||||
// !: Not used for now
|
||||
// expired: z.string(),
|
||||
signedAt: z.date().nullable(),
|
||||
readStatus: z.nativeEnum(ReadStatus),
|
||||
signingStatus: z.nativeEnum(SigningStatus),
|
||||
@@ -576,8 +576,7 @@ export const ZRecipientSchema = z.object({
|
||||
token: z.string(),
|
||||
signingOrder: z.number().nullish(),
|
||||
documentDeletedAt: z.date().nullish(),
|
||||
expiresAt: z.date().nullish(),
|
||||
expirationNotifiedAt: z.date().nullish(),
|
||||
expired: z.date().nullish(),
|
||||
signedAt: z.date().nullish(),
|
||||
authOptions: z.unknown(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
|
||||
@@ -171,7 +171,6 @@ test.describe('API V2 Envelopes', () => {
|
||||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
@@ -181,7 +180,6 @@ test.describe('API V2 Envelopes', () => {
|
||||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -207,7 +205,6 @@ test.describe('API V2 Envelopes', () => {
|
||||
documentPending: false,
|
||||
documentCompleted: false,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
@@ -47,7 +46,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -55,7 +54,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -75,7 +74,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -101,7 +100,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -109,7 +108,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -129,7 +128,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -163,7 +162,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -171,7 +170,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -191,7 +190,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -225,7 +224,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -233,7 +232,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
@@ -29,7 +28,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -107,12 +106,10 @@ test.describe('AutoSave Subject Step', () => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
|
||||
|
||||
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
|
||||
await page.getByText('Email the owner when a recipient signs').click();
|
||||
await page.getByText("Email recipients when they're removed from a pending document").click();
|
||||
await page
|
||||
.getByText('Email recipients when the document is completed', { exact: true })
|
||||
.click();
|
||||
await page.getByText('Email recipients when a pending document is deleted').click();
|
||||
await page.getByText('Send recipient signed email').click();
|
||||
await page.getByText('Send recipient removed email').click();
|
||||
await page.getByText('Send document completed email', { exact: true }).click();
|
||||
await page.getByText('Send document deleted email').click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -129,34 +126,26 @@ test.describe('AutoSave Subject Step', () => {
|
||||
|
||||
const emailSettings = retrievedDocumentData.documentMeta?.emailSettings;
|
||||
|
||||
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
|
||||
await expect(page.getByText('Send recipient signed email')).toBeChecked({
|
||||
checked: emailSettings?.recipientSigned,
|
||||
});
|
||||
await expect(
|
||||
page.getByText("Email recipients when they're removed from a pending document"),
|
||||
).toBeChecked({
|
||||
await expect(page.getByText('Send recipient removed email')).toBeChecked({
|
||||
checked: emailSettings?.recipientRemoved,
|
||||
});
|
||||
await expect(
|
||||
page.getByText('Email recipients when the document is completed', { exact: true }),
|
||||
).toBeChecked({
|
||||
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
|
||||
checked: emailSettings?.documentCompleted,
|
||||
});
|
||||
await expect(
|
||||
page.getByText('Email recipients when a pending document is deleted'),
|
||||
).toBeChecked({
|
||||
await expect(page.getByText('Send document deleted email')).toBeChecked({
|
||||
checked: emailSettings?.documentDeleted,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
|
||||
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
|
||||
checked: emailSettings?.recipientSigningRequest,
|
||||
});
|
||||
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
|
||||
{
|
||||
checked: emailSettings?.documentPending,
|
||||
},
|
||||
);
|
||||
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
|
||||
await expect(page.getByText('Send document pending email')).toBeChecked({
|
||||
checked: emailSettings?.documentPending,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
|
||||
checked: emailSettings?.ownerDocumentCompleted,
|
||||
});
|
||||
}).toPass();
|
||||
@@ -172,12 +161,10 @@ test.describe('AutoSave Subject Step', () => {
|
||||
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
|
||||
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
|
||||
|
||||
await page.getByText('Email the owner when a recipient signs').click();
|
||||
await page.getByText("Email recipients when they're removed from a pending document").click();
|
||||
await page
|
||||
.getByText('Email recipients when the document is completed', { exact: true })
|
||||
.click();
|
||||
await page.getByText('Email recipients when a pending document is deleted').click();
|
||||
await page.getByText('Send recipient signed email').click();
|
||||
await page.getByText('Send recipient removed email').click();
|
||||
await page.getByText('Send document completed email', { exact: true }).click();
|
||||
await page.getByText('Send document deleted email').click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -203,34 +190,26 @@ test.describe('AutoSave Subject Step', () => {
|
||||
retrievedDocumentData.documentMeta?.message ?? '',
|
||||
);
|
||||
|
||||
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
|
||||
await expect(page.getByText('Send recipient signed email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
|
||||
});
|
||||
await expect(
|
||||
page.getByText("Email recipients when they're removed from a pending document"),
|
||||
).toBeChecked({
|
||||
await expect(page.getByText('Send recipient removed email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientRemoved,
|
||||
});
|
||||
await expect(
|
||||
page.getByText('Email recipients when the document is completed', { exact: true }),
|
||||
).toBeChecked({
|
||||
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
|
||||
});
|
||||
await expect(
|
||||
page.getByText('Email recipients when a pending document is deleted'),
|
||||
).toBeChecked({
|
||||
await expect(page.getByText('Send document deleted email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
|
||||
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
|
||||
});
|
||||
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
|
||||
{
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
|
||||
},
|
||||
);
|
||||
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
|
||||
await expect(page.getByText('Send document pending email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
|
||||
});
|
||||
}).toPass();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -34,14 +33,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
|
||||
|
||||
// Step 3: Add fields
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
// Switch to second duplicate and add field
|
||||
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Continue to send
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
@@ -44,21 +44,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
|
||||
// Step 3: Add fields for each recipient
|
||||
// Add signature field for first duplicate recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to second duplicate recipient and add their field
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to unique recipient and add their field
|
||||
await page.getByText('Unique Recipient (unique@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
|
||||
// Continue to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -122,7 +122,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Save the document by going to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -149,7 +149,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Complete the flow
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -270,24 +270,24 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
|
||||
// Add signature for first recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Add name field for second recipient
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
await page.getByText('Approver Role (signer@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Add date field for second recipient
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 150 } });
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
|
||||
|
||||
// If second recipient is still a SIGNER (role change wasn't available),
|
||||
// add a signature field for them to pass validation
|
||||
if (!secondRecipientIsApprover) {
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 200 } });
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 200 } });
|
||||
}
|
||||
|
||||
// Complete the document
|
||||
@@ -349,7 +349,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
|
||||
// Add another field to the second duplicate
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 250, y: 150 } });
|
||||
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
|
||||
|
||||
// Save changes
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { DateTime } from 'luxon';
|
||||
import path from 'node:path';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
@@ -93,7 +92,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -101,7 +100,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -159,7 +158,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -167,7 +166,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -178,7 +177,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByText('User 2 (user2@example.com)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
@@ -186,7 +185,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
@@ -257,7 +256,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -265,7 +264,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -276,7 +275,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
@@ -284,7 +283,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
@@ -577,7 +576,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100 * i,
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import { type APIRequestContext, type Page, expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
|
||||
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
|
||||
import { RecipientRole } from '../../../prisma/generated/types';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '../../../trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
const signAndVerifyPageDimensions = async ({
|
||||
page,
|
||||
request,
|
||||
pdfFile,
|
||||
identifier,
|
||||
title,
|
||||
expectedWidth,
|
||||
expectedHeight,
|
||||
}: {
|
||||
page: Page;
|
||||
request: APIRequestContext;
|
||||
pdfFile: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
expectedWidth: number;
|
||||
expectedHeight: number;
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const pdfBuffer = fs.readFileSync(path.join(__dirname, `../../../../assets/${pdfFile}`));
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
const createEnvelopePayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title,
|
||||
recipients: [
|
||||
{
|
||||
email: user.email,
|
||||
name: user.name || '',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
identifier,
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: { type: 'signature' },
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 40,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
formData.append('payload', JSON.stringify(createEnvelopePayload));
|
||||
formData.append('files', new File([pdfBuffer], identifier, { type: 'application/pdf' }));
|
||||
|
||||
const createResponse = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
|
||||
const { id: envelopeId }: TCreateEnvelopeResponse = await createResponse.json();
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelopeId },
|
||||
include: { recipients: true },
|
||||
});
|
||||
|
||||
const distributeResponse = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId: envelope.id } satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeResponse.ok()).toBeTruthy();
|
||||
|
||||
// Pre-insert all fields via Prisma so we can skip the UI field interaction.
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id, inserted: false },
|
||||
});
|
||||
|
||||
for (const field of fields) {
|
||||
await prisma.field.update({
|
||||
where: { id: field.id },
|
||||
data: {
|
||||
inserted: true,
|
||||
signature: {
|
||||
create: {
|
||||
recipientId: envelope.recipients[0].id,
|
||||
typedSignature: 'Test Signature',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const recipientToken = envelope.recipients[0].token;
|
||||
const signUrl = `/sign/${recipientToken}`;
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: signUrl,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: envelope.id },
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: envelope.id },
|
||||
include: {
|
||||
envelopeItems: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { documentData: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const item of completedEnvelope.envelopeItems) {
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: item,
|
||||
token: recipientToken,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(pdfData) });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
expect(pdf.numPages).toBeGreaterThan(1);
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const pdfPage = await pdf.getPage(i);
|
||||
const viewport = pdfPage.getViewport({ scale: 1 });
|
||||
|
||||
expect(Math.round(viewport.width)).toBe(expectedWidth);
|
||||
expect(Math.round(viewport.height)).toBe(expectedHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test('cert and audit log pages match letter page dimensions', async ({ page, request }) => {
|
||||
await signAndVerifyPageDimensions({
|
||||
page,
|
||||
request,
|
||||
pdfFile: 'letter-size.pdf',
|
||||
identifier: 'letter-doc',
|
||||
title: 'Letter Size Dimension Test',
|
||||
expectedWidth: 612,
|
||||
expectedHeight: 792,
|
||||
});
|
||||
});
|
||||
|
||||
test('cert and audit log pages match A4 page dimensions', async ({ page, request }) => {
|
||||
await signAndVerifyPageDimensions({
|
||||
page,
|
||||
request,
|
||||
pdfFile: 'a4-size.pdf',
|
||||
identifier: 'a4-doc',
|
||||
title: 'A4 Size Dimension Test',
|
||||
expectedWidth: 595,
|
||||
expectedHeight: 842,
|
||||
});
|
||||
});
|
||||
|
||||
test('cert and audit log pages match tabloid landscape page dimensions', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await signAndVerifyPageDimensions({
|
||||
page,
|
||||
request,
|
||||
pdfFile: 'tabloid-landscape.pdf',
|
||||
identifier: 'tabloid-doc',
|
||||
title: 'Tabloid Landscape Dimension Test',
|
||||
expectedWidth: 1224,
|
||||
expectedHeight: 792,
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import type { TestInfo } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import pixelMatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
import { pdfToImages } from '@documenso/lib/server-only/ai/pdf-to-images';
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
|
||||
@@ -297,34 +298,18 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
|
||||
*
|
||||
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
|
||||
*/
|
||||
test.skip('download envelope images', async ({ page, request }) => {
|
||||
test.skip('download envelope images', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const envelope = await seedAlignmentTestDocument({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
recipientName: user.name || '',
|
||||
recipientEmail: user.email,
|
||||
insertFields: true,
|
||||
status: DocumentStatus.DRAFT,
|
||||
status: DocumentStatus.PENDING,
|
||||
});
|
||||
|
||||
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
|
||||
|
||||
const token = envelope.recipients[0].token;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
@@ -393,14 +378,39 @@ test.skip('download envelope images', async ({ page, request }) => {
|
||||
});
|
||||
|
||||
async function renderPdfToImage(pdfBytes: Uint8Array) {
|
||||
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
// Increase for higher resolution
|
||||
const scale = 4;
|
||||
|
||||
return (await pdfToImages(pdfBytes, { scale, imageFormat: 'png' })).map((image) => ({
|
||||
image: image.image,
|
||||
width: Math.floor(image.scaledWidth),
|
||||
height: Math.floor(image.scaledHeight),
|
||||
}));
|
||||
return await Promise.all(
|
||||
Array.from({ length: pdf.numPages }, async (_, index) => {
|
||||
const page = await pdf.getPage(index + 1);
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = createCanvas(viewport.width, viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
canvasContext.imageSmoothingEnabled = false;
|
||||
|
||||
await page.render({
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvas,
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvasContext,
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
return {
|
||||
image: await canvas.encode('png'),
|
||||
|
||||
// Rounded down because the certificate page somehow gives dimensions with decimals
|
||||
width: Math.floor(viewport.width),
|
||||
height: Math.floor(viewport.height),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
type CompareSignedPdfWithImagesOptions = {
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
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 { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: sending document sets expiresAt on recipients', async ({
|
||||
request,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-expiration-send',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const createPayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: '[TEST] Expiration Send Test',
|
||||
recipients: [
|
||||
{
|
||||
email: 'signer-expiry@test.documenso.com',
|
||||
name: 'Signer Expiry',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 10,
|
||||
height: 5,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(createPayload));
|
||||
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
|
||||
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
|
||||
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
|
||||
// Check that recipients now have expiresAt set.
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(recipients.length).toBe(1);
|
||||
expect(recipients[0].expiresAt).not.toBeNull();
|
||||
|
||||
// The default expiration period is 3 months. Verify it's roughly correct.
|
||||
const expiresAt = recipients[0].expiresAt!;
|
||||
const now = new Date();
|
||||
const diffMs = expiresAt.getTime() - now.getTime();
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
// 3 months is roughly 89-92 days. Allow a generous range.
|
||||
expect(diffDays).toBeGreaterThan(80);
|
||||
expect(diffDays).toBeLessThan(100);
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: sending document with custom org expiration period', async ({
|
||||
request,
|
||||
}) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
// Set org expiration to 7 days.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { unit: 'day', amount: 7 } },
|
||||
});
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-expiration-custom',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const createPayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: '[TEST] Custom Expiration Send Test',
|
||||
recipients: [
|
||||
{
|
||||
email: 'signer-custom@test.documenso.com',
|
||||
name: 'Signer Custom',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 10,
|
||||
height: 5,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(createPayload));
|
||||
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
|
||||
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
|
||||
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(recipients.length).toBe(1);
|
||||
expect(recipients[0].expiresAt).not.toBeNull();
|
||||
|
||||
// 7 days expiration.
|
||||
const expiresAt = recipients[0].expiresAt!;
|
||||
const now = new Date();
|
||||
const diffMs = expiresAt.getTime() - now.getTime();
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
expect(diffDays).toBeGreaterThan(6);
|
||||
expect(diffDays).toBeLessThan(8);
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: sending document with expiration disabled', async ({ request }) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
// Disable expiration at org level.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { disabled: true } },
|
||||
});
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-expiration-disabled',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const createPayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: '[TEST] Disabled Expiration Send Test',
|
||||
recipients: [
|
||||
{
|
||||
email: 'signer-disabled@test.documenso.com',
|
||||
name: 'Signer Disabled',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 10,
|
||||
height: 5,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(createPayload));
|
||||
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
|
||||
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
|
||||
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(recipients.length).toBe(1);
|
||||
expect(recipients[0].expiresAt).toBeNull();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, ['resend-target@test.documenso.com']);
|
||||
|
||||
const recipient = document.recipients[0];
|
||||
|
||||
// Set an initial expiresAt that's 1 day from now.
|
||||
const initialExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: initialExpiresAt },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
// Open the document action menu and click Resend.
|
||||
const actionBtn = page.getByTestId('document-table-action-btn').first();
|
||||
await expect(actionBtn).toBeAttached();
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Resend' }).click();
|
||||
|
||||
// Select the recipient and send.
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Verify expiresAt was refreshed.
|
||||
await expect(async () => {
|
||||
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.expiresAt).not.toBeNull();
|
||||
expect(updatedRecipient.expiresAt!.getTime()).toBeGreaterThan(initialExpiresAt.getTime());
|
||||
}).toPass({ timeout: 10_000 });
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
// Wait for the form to load.
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Change the amount to 2.
|
||||
const amountInput = page.getByRole('spinbutton');
|
||||
await amountInput.clear();
|
||||
await amountInput.fill('2');
|
||||
|
||||
// Find all triggers, the unit picker is the one showing Months/Days/etc.
|
||||
// In the duration mode, there's a mode select and a unit select.
|
||||
// The unit select is inside the duration row, after the number input.
|
||||
// Let's find the select trigger that contains the unit text.
|
||||
const unitTrigger = page
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Months|Days|Weeks|Years/ });
|
||||
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Weeks' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(orgSettings.envelopeExpirationPeriod).toEqual({ unit: 'week', amount: 2 });
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Find the mode select (shows "Custom duration") and change to "Never expires".
|
||||
const modeTrigger = page
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: 'Custom duration' });
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Never expires' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(orgSettings.envelopeExpirationPeriod).toEqual({ disabled: true });
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: team inherits expiration from organisation', async () => {
|
||||
const { organisation, team } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Set org expiration to 2 weeks directly.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { unit: 'week', amount: 2 } },
|
||||
});
|
||||
|
||||
// Verify team settings inherit the org setting.
|
||||
const teamSettings = await getTeamSettings({ teamId: team.id });
|
||||
|
||||
expect(teamSettings.envelopeExpirationPeriod).toEqual({ unit: 'week', amount: 2 });
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ page }) => {
|
||||
const { user, organisation, team } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Set org expiration to 2 weeks.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { unit: 'week', amount: 2 } },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Scope to the "Default Envelope Expiration" form field section.
|
||||
const expirationSection = page.getByText('Default Envelope Expiration').locator('..');
|
||||
|
||||
// The expiration picker mode select should show "Inherit from organisation" by default.
|
||||
const modeTrigger = expirationSection.locator('button[role="combobox"]').first();
|
||||
await expect(modeTrigger).toBeVisible();
|
||||
|
||||
// Switch to custom duration.
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Custom duration' }).click();
|
||||
|
||||
// Set to 5 days.
|
||||
const amountInput = expirationSection.getByRole('spinbutton');
|
||||
await amountInput.clear();
|
||||
await amountInput.fill('5');
|
||||
|
||||
const unitTrigger = expirationSection
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Months|Days|Weeks|Years/ });
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Days' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify team setting is overridden.
|
||||
const teamSettings = await getTeamSettings({ teamId: team.id });
|
||||
|
||||
expect(teamSettings.envelopeExpirationPeriod).toEqual({ unit: 'day', amount: 5 });
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: expired recipient is redirected to expired page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['expired-recipient@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Set expiresAt to the past so the recipient is expired.
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
await page.waitForURL(`/sign/${recipient.token}/expired`);
|
||||
|
||||
await expect(page.getByText('Signing Deadline Expired')).toBeVisible();
|
||||
await expect(page.getByText('The signing deadline for this document has passed')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: non-expired recipient can access signing page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['active-recipient@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Set expiresAt to 1 hour in the future.
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: recipient with null expiresAt can sign normally', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['null-expiry@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Verify expiresAt is null (default from seed).
|
||||
const dbRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(dbRecipient.expiresAt).toBeNull();
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: expired recipient cannot complete signing', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Use only a SIGNATURE field to simplify the signing flow.
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: [user],
|
||||
teamId: team.id,
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/sign/${recipient.token}`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
// Now expire the recipient while they're on the signing page.
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: new Date(Date.now() - 1_000) },
|
||||
});
|
||||
|
||||
// Set up signature.
|
||||
await signSignaturePad(page);
|
||||
|
||||
// Click the signature field to attempt to insert it.
|
||||
// The server will reject because the recipient is now expired.
|
||||
const signatureField = recipient.fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
if (signatureField) {
|
||||
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
|
||||
}
|
||||
|
||||
// The server should reject the signing attempt because the recipient has expired.
|
||||
// Verify the field was NOT inserted (stays data-inserted="false").
|
||||
if (signatureField) {
|
||||
await expect(async () => {
|
||||
const field = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: signatureField.id },
|
||||
});
|
||||
|
||||
expect(field.inserted).toBe(false);
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
@@ -205,13 +205,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Reply to email' }).fill('organisation@documenso.com');
|
||||
|
||||
// Update email document settings by enabling/disabling some checkboxes
|
||||
await page.getByRole('checkbox', { name: 'Email the owner when a recipient signs' }).uncheck();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Email the signer if the document is still pending' })
|
||||
.uncheck();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' })
|
||||
.uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Send recipient signed email' }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Send document pending email' }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Send document deleted email' }).uncheck();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
@@ -229,7 +225,6 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: false, // unchecked
|
||||
documentCompleted: true,
|
||||
documentDeleted: false, // unchecked
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
});
|
||||
|
||||
@@ -245,12 +240,12 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'Override organisation settings' }).click();
|
||||
|
||||
// Update some email settings
|
||||
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Send recipient signing request email' }).uncheck();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true })
|
||||
.getByRole('checkbox', { name: 'Send document completed email', exact: true })
|
||||
.uncheck();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Email the owner when the document is completed' })
|
||||
.getByRole('checkbox', { name: 'Send document completed email to the owner' })
|
||||
.uncheck();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
@@ -269,7 +264,6 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: true,
|
||||
documentCompleted: false,
|
||||
documentDeleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: false,
|
||||
});
|
||||
|
||||
@@ -290,7 +284,6 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: true,
|
||||
documentCompleted: false,
|
||||
documentDeleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: false,
|
||||
});
|
||||
|
||||
@@ -316,7 +309,6 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: false,
|
||||
documentCompleted: true,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
});
|
||||
|
||||
@@ -337,7 +329,6 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
documentPending: false,
|
||||
documentCompleted: true,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedPendingDocumentWithFullFields,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const PDF_PAGE_SELECTOR = 'img[data-page-number]';
|
||||
|
||||
async function addSecondEnvelopeItem(envelopeId: string) {
|
||||
const firstItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId },
|
||||
orderBy: { order: 'asc' },
|
||||
include: { documentData: true },
|
||||
});
|
||||
|
||||
const newDocumentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: firstItem.documentData.type,
|
||||
data: firstItem.documentData.data,
|
||||
initialData: firstItem.documentData.initialData,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.envelopeItem.create({
|
||||
data: {
|
||||
id: prefixedId('envelope_item'),
|
||||
title: `${firstItem.title} - Page 2`,
|
||||
documentDataId: newDocumentData.id,
|
||||
order: 2,
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('PDF Viewer Rendering', () => {
|
||||
test.describe('Authenticated Pages', () => {
|
||||
test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const documentV1 = await seedBlankDocument(user, team.id);
|
||||
const documentV2 = await seedBlankDocument(user, team.id, { internalVersion: 2 });
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
const templateV1 = await seedBlankTemplate(user, team.id);
|
||||
const templateV2 = await seedBlankTemplate(user, team.id, {
|
||||
createTemplateOptions: { internalVersion: 2 },
|
||||
});
|
||||
await addSecondEnvelopeItem(templateV2.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${documentV1.id}`,
|
||||
});
|
||||
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV2.id}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV1.id}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV2.id}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV1.id}/edit`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV2.id}/edit?step=addFields`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV1.id}/edit`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV2.id}/edit?step=addFields`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV1.id}`);
|
||||
await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Recipient Signing', () => {
|
||||
test('should render PDF on signing page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['signer-v1@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const { document: documentV2, recipients: recipientsV2 } =
|
||||
await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['signer-v2@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
await page.goto(`/sign/${recipientsV1[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/sign/${recipientsV2[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Direct Template', () => {
|
||||
test('should render PDF on direct template page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const templateV1 = await seedDirectTemplate({
|
||||
title: 'PDF Viewer Test Template V1',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const templateV2 = await seedDirectTemplate({
|
||||
title: 'PDF Viewer Test Template V2',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
await addSecondEnvelopeItem(templateV2.id);
|
||||
|
||||
await page.goto(formatDirectTemplatePath(templateV1.directLink?.token || ''));
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(formatDirectTemplatePath(templateV2.directLink?.token || ''));
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Embed Pages', () => {
|
||||
test('should render PDF on embed sign page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['embed-signer-v1@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const { document: documentV2, recipients: recipientsV2 } =
|
||||
await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['embed-signer-v2@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
await page.goto(`/embed/sign/${recipientsV1[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/embed/sign/${recipientsV2[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed direct template page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const templateV1 = await seedDirectTemplate({
|
||||
title: 'Embed Direct Template V1',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const templateV2 = await seedDirectTemplate({
|
||||
title: 'Embed Direct Template V2',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
await addSecondEnvelopeItem(templateV2.id);
|
||||
|
||||
await page.goto(`/embed/direct/${templateV1.directLink?.token || ''}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/embed/direct/${templateV2.directLink?.token || ''}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed authoring document create page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'pdf-viewer-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const { token: presignToken } = await createEmbeddingPresignToken({
|
||||
apiToken,
|
||||
});
|
||||
|
||||
const embedParams = { darkModeDisabled: false, features: {} };
|
||||
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
|
||||
|
||||
await page.goto(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/create?token=${presignToken}#${hash}`,
|
||||
);
|
||||
|
||||
await expect(page.getByText('Configure Document')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const titleInput = page.getByLabel('Title');
|
||||
await titleInput.click();
|
||||
await titleInput.fill('PDF Viewer E2E Test');
|
||||
|
||||
const emailInput = page.getByPlaceholder('Email').first();
|
||||
await emailInput.click();
|
||||
await emailInput.fill('test-signer@documenso.com');
|
||||
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page
|
||||
.locator('input[type=file]')
|
||||
.first()
|
||||
.evaluate((el) => {
|
||||
if (el instanceof HTMLInputElement) {
|
||||
el.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,21 +42,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
|
||||
// Step 3: Add fields for each recipient instance
|
||||
// Add signature field for first instance
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Switch to second instance and add their field
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Second Instance').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Switch to different recipient and add their fields
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 150 } });
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 150 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
@@ -209,17 +209,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
|
||||
// Add fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Duplicate Recipient 2').first().click();
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200 } });
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
@@ -272,7 +272,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 300 } });
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
@@ -48,7 +47,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -56,7 +55,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -76,7 +75,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -111,7 +110,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -119,7 +118,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -139,7 +138,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -180,7 +179,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -188,7 +187,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -208,7 +207,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -251,7 +250,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -259,7 +258,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user