mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 20:32:07 +10:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3f5903760 | |||
| 57eb40d6aa | |||
| 684fab1909 | |||
| d794ceb8da | |||
| 87315adb0f | |||
| 0a7794be61 | |||
| f15d6f0150 | |||
| 0b86ece1d5 | |||
| a197bf113f | |||
| ec8728b33e | |||
| 22122f51da | |||
| 8671f269e8 | |||
| edbf65969b | |||
| 207135d6f3 | |||
| 4877d1964a | |||
| f66751668a | |||
| bc3aa9c858 | |||
| b79b4bd111 | |||
| 36c10d1a92 | |||
| 8c0e029b1b | |||
| f10d3284ba | |||
| 6a6ef8d2ad | |||
| 690491c3b1 | |||
| 6243a514af | |||
| a697832b43 | |||
| aebb5e2067 | |||
| e19b1d00d0 | |||
| c428170b5c | |||
| 84fc866cfb | |||
| 5d92aaf20a | |||
| ae497092d7 | |||
| 2f4c3893a3 | |||
| 61338af216 | |||
| 2c7a1be051 | |||
| 8bad62cc92 | |||
| 19c2f7b4a1 | |||
| 135b676cd4 | |||
| 8f3e1893c7 | |||
| e063af628f | |||
| dc575f5c80 | |||
| e5da5bca38 | |||
| d38d703fd3 | |||
| 3249f855fb | |||
| 34b31c0d80 | |||
| f153a7c437 | |||
| f9cb8d84ed | |||
| 198dafc8ec | |||
| 2f1aaa2b5d | |||
| f54a8ed72f | |||
| 5082226e08 | |||
| bc82b2e70e | |||
| 4935f387bf | |||
| 6d7bd212bf | |||
| 283334921b | |||
| 1af83ea854 | |||
| 7cb64c3d04 | |||
| 4c69cb9c66 | |||
| 14b0b4805d | |||
| 9bfaa08d38 | |||
| 229cd2f7e9 | |||
| 6f650e1c2f | |||
| 0b9a23c550 | |||
| 3cca8cdae8 | |||
| b13ec8909c | |||
| e3b7a9e7cb | |||
| 74d79dc6b2 | |||
| 1c82595c12 | |||
| ad559f72dd | |||
| 025a27d385 | |||
| a71c44570b | |||
| f5b3babcbb | |||
| 2346de83a6 | |||
| 814f6e62de | |||
| 0434bdfacf | |||
| 53b6078fa9 | |||
| 5be71cca21 | |||
| ace472c294 | |||
| b2d395e00b | |||
| dd1b6d7dfe | |||
| bef3ea483d | |||
| e87aa29823 | |||
| 4f8132be61 | |||
| 9cf8ed1d00 | |||
| 108d422a2e | |||
| 48fb066b9a | |||
| 0b605d61c6 | |||
| 5dcdac7ecd | |||
| f48aa84c9e | |||
| 455fef70bd | |||
| 647dc5fc2d | |||
| cc67454513 | |||
| 665a0d0ea0 | |||
| 02ff8df09b | |||
| d4bee4aed1 | |||
| f4cfa71379 | |||
| b1763e422c | |||
| 64e0695811 | |||
| 8a4205d808 | |||
| 74db3d7a1c | |||
| 3976531045 | |||
| 7a499270be |
@@ -0,0 +1,239 @@
|
||||
---
|
||||
date: 2026-03-26
|
||||
title: Bullmq Background Jobs
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The codebase has a well-designed background job provider abstraction (`BaseJobProvider`) with two existing implementations:
|
||||
|
||||
- **InngestJobProvider** — cloud/SaaS provider, externally hosted
|
||||
- **LocalJobProvider** — database-backed (Postgres via Prisma), uses HTTP self-calls to dispatch
|
||||
|
||||
The goal is to add a third provider backed by a proper job queue library for self-hosted deployments that need more reliability than the Local provider offers.
|
||||
|
||||
### Current Architecture
|
||||
|
||||
All code lives in `packages/lib/jobs/`:
|
||||
|
||||
- `client/base.ts` — Abstract `BaseJobProvider` with 4 methods: `defineJob()`, `triggerJob()`, `getApiHandler()`, `startCron()`
|
||||
- `client/client.ts` — `JobClient` facade, selects provider via `NEXT_PRIVATE_JOBS_PROVIDER` env var
|
||||
- `client/inngest.ts` — Inngest implementation
|
||||
- `client/local.ts` — Local/Postgres implementation
|
||||
- `client/_internal/job.ts` — Core types: `JobDefinition`, `JobRunIO`, `SimpleTriggerJobOptions`
|
||||
- `definitions/` — 19 job definitions (15 event-triggered, 4 cron)
|
||||
|
||||
The `JobRunIO` interface provided to handlers includes:
|
||||
|
||||
- `runTask(cacheKey, callback)` — idempotent task execution (cached via `BackgroundJobTask` table)
|
||||
- `triggerJob(cacheKey, options)` — chain jobs from within handlers
|
||||
- `wait(cacheKey, ms)` — delay/sleep (not implemented in Local provider)
|
||||
- `logger` — structured logging
|
||||
|
||||
### Local Provider Limitations
|
||||
|
||||
The current Local provider has several issues that motivate this work:
|
||||
|
||||
1. `io.wait()` throws "Not implemented"
|
||||
2. HTTP self-call with 150ms fire-and-forget `Promise.race` is fragile
|
||||
3. No concurrency control — jobs run in the web server process
|
||||
4. No real retry backoff (immediate re-dispatch)
|
||||
5. No monitoring/visibility into job status
|
||||
6. Jobs compete for resources with HTTP request handling
|
||||
|
||||
---
|
||||
|
||||
## Provider Evaluation
|
||||
|
||||
Three alternatives were evaluated against the existing provider interface and project requirements.
|
||||
|
||||
### BullMQ (Redis-backed) — Recommended
|
||||
|
||||
| Attribute | Detail |
|
||||
| ------------------- | -------------------------- |
|
||||
| Backend | Redis 7.x |
|
||||
| npm downloads/month | ~15M |
|
||||
| TypeScript | Native |
|
||||
| Delayed jobs | Yes (ms precision) |
|
||||
| Cron/repeatable | Yes (`upsertJobScheduler`) |
|
||||
| Retries + backoff | Yes (exponential, custom) |
|
||||
| Concurrency control | Yes (per-worker) |
|
||||
| Rate limiting | Yes (per-queue, per-group) |
|
||||
| Dashboard | Bull Board (mature) |
|
||||
| New infrastructure | Yes — Redis required |
|
||||
|
||||
**Why BullMQ**: Most mature and widely-adopted Node.js queue. Native delayed jobs solve the `io.wait()` gap. Redis is purpose-built for queue workloads and keeps Postgres clean for application data. Bull Board gives immediate operational visibility. The provider abstraction already exists so wrapping BullMQ is straightforward.
|
||||
|
||||
**Trade-off**: Requires Redis, which is additional infrastructure. However, Redis is a single Docker Compose service or a free Upstash tier, and the operational benefit is significant.
|
||||
|
||||
### pg-boss (PostgreSQL-backed) — Strong Alternative
|
||||
|
||||
| Attribute | Detail |
|
||||
| ------------------- | ----------------------------- |
|
||||
| Backend | PostgreSQL (existing) |
|
||||
| npm downloads/month | ~1.4M |
|
||||
| TypeScript | Native |
|
||||
| Delayed jobs | Yes (`startAfter`) |
|
||||
| Cron/repeatable | Yes (`schedule()`) |
|
||||
| New infrastructure | No — reuses existing Postgres |
|
||||
|
||||
**Why it could work**: Zero new infrastructure since the project already uses Postgres. API maps well to existing patterns.
|
||||
|
||||
**Why it's second choice**: Polling-based (no LISTEN/NOTIFY), adds write amplification to the primary database, smaller ecosystem, no dashboard. At scale, queue operations on the primary database become a concern.
|
||||
|
||||
### Graphile Worker (PostgreSQL-backed) — Less Suitable
|
||||
|
||||
Uses LISTEN/NOTIFY for instant pickup but has a file-based task convention and separate schema that don't mesh well with the existing Prisma-centric architecture. Would require more adapter work.
|
||||
|
||||
### Improving the Local Provider — Not Recommended
|
||||
|
||||
Fixing the Local provider's issues (wait support, replacing HTTP self-calls, adding concurrency control, backoff) essentially means rebuilding a queue library from scratch with less robustness and no community maintenance.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Proceed with BullMQ.** It's the most capable option, maps cleanly to the existing provider interface, and is the standard choice for production Node.js applications. Redis is lightweight infrastructure with managed options available at every cloud provider.
|
||||
|
||||
**If Redis is a hard blocker**, pg-boss is the clear fallback — but the plan below assumes BullMQ.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: BullMQ Provider Core
|
||||
|
||||
**File: `packages/lib/jobs/client/bullmq.ts`**
|
||||
|
||||
Create `BullMQJobProvider extends BaseJobProvider` with singleton pattern matching the existing providers.
|
||||
|
||||
Key implementation details:
|
||||
|
||||
1. **Constructor / `getInstance()`**
|
||||
- Initialize a Redis `IORedis` connection using new env var: `NEXT_PRIVATE_REDIS_URL`
|
||||
- Create a single `Queue` instance for dispatching jobs, using `NEXT_PRIVATE_REDIS_PREFIX` as the BullMQ `prefix` option (defaults to `documenso` if unset). This namespaces all Redis keys so multiple environments (worktrees, branches, developers) sharing the same Redis instance don't collide.
|
||||
- Create a single `Worker` instance for processing jobs (in-process, same prefix)
|
||||
- Store job definitions in a `_jobDefinitions` record (same pattern as Local provider)
|
||||
|
||||
2. **`defineJob()`**
|
||||
- Store definition in `_jobDefinitions` keyed by ID
|
||||
- If the definition has a `trigger.cron`, register it via `queue.upsertJobScheduler()` with the cron expression
|
||||
|
||||
3. **`triggerJob(options)`**
|
||||
- Find eligible definitions by `trigger.name` (same lookup as Local provider)
|
||||
- For each, call `queue.add(jobDefinitionId, payload)` with appropriate options
|
||||
- Support `options.id` for deduplication via BullMQ's `jobId` option
|
||||
|
||||
4. **`getApiHandler()`**
|
||||
- Return a minimal health-check / queue-status handler. Unlike the Local provider, BullMQ workers don't need an HTTP endpoint to receive jobs — they pull from Redis directly. The API handler can return queue metrics for monitoring.
|
||||
|
||||
5. **`startCron()`**
|
||||
- No-op — cron is handled by BullMQ's `upsertJobScheduler` registered during `defineJob()`
|
||||
|
||||
6. **Worker setup**
|
||||
- Single worker processes all job types by dispatching to the correct handler from `_jobDefinitions`
|
||||
- Configure concurrency with a default of 10 (overridable via `NEXT_PRIVATE_BULLMQ_CONCURRENCY` env var for those who need to tune it)
|
||||
- Configure retry with exponential backoff: `backoff: { type: 'exponential', delay: 1000 }`
|
||||
- Default 3 retries (matching current Local provider behavior)
|
||||
|
||||
7. **`createJobRunIO(jobId)`** — Implement `JobRunIO`:
|
||||
- `runTask()`: Reuse the existing `BackgroundJobTask` Prisma table for idempotent task tracking (same pattern as Local provider)
|
||||
- `triggerJob()`: Delegate to `this.triggerJob()`
|
||||
- `wait()`: Throw "Not implemented" (same as Local provider). No handler uses `io.wait()` so this has zero impact
|
||||
- `logger`: Same console-based logger pattern as Local provider
|
||||
|
||||
### Phase 2: Provider Registration
|
||||
|
||||
**File: `packages/lib/jobs/client/client.ts`**
|
||||
|
||||
Add `'bullmq'` case to the provider match:
|
||||
|
||||
```typescript
|
||||
this._provider = match(env('NEXT_PRIVATE_JOBS_PROVIDER'))
|
||||
.with('inngest', () => InngestJobProvider.getInstance())
|
||||
.with('bullmq', () => BullMQJobProvider.getInstance())
|
||||
.otherwise(() => LocalJobProvider.getInstance());
|
||||
```
|
||||
|
||||
**File: `packages/tsconfig/process-env.d.ts`**
|
||||
|
||||
Add `'bullmq'` to the `NEXT_PRIVATE_JOBS_PROVIDER` type union and add Redis env var types:
|
||||
|
||||
```typescript
|
||||
NEXT_PRIVATE_JOBS_PROVIDER?: 'inngest' | 'local' | 'bullmq';
|
||||
NEXT_PRIVATE_REDIS_URL?: string;
|
||||
NEXT_PRIVATE_REDIS_PREFIX?: string;
|
||||
NEXT_PRIVATE_BULLMQ_CONCURRENCY?: string;
|
||||
```
|
||||
|
||||
**File: `.env.example`**
|
||||
|
||||
Add Redis configuration examples:
|
||||
|
||||
```env
|
||||
NEXT_PRIVATE_JOBS_PROVIDER="local" # Options: local, inngest, bullmq
|
||||
NEXT_PRIVATE_REDIS_URL="redis://localhost:63790"
|
||||
NEXT_PRIVATE_REDIS_PREFIX="documenso" # Namespace for Redis keys (useful when sharing a Redis instance)
|
||||
```
|
||||
|
||||
**File: `turbo.json`**
|
||||
|
||||
Add `NEXT_PRIVATE_REDIS_URL`, `NEXT_PRIVATE_REDIS_PREFIX`, and `NEXT_PRIVATE_BULLMQ_CONCURRENCY` to the env vars list for cache invalidation.
|
||||
|
||||
### Phase 3: Infrastructure & Dependencies
|
||||
|
||||
**File: `packages/lib/package.json`**
|
||||
|
||||
Add dependencies:
|
||||
|
||||
- `bullmq` — the queue library
|
||||
- `ioredis` — Redis client (peer dependency of BullMQ, but explicit is better)
|
||||
|
||||
**File: `docker-compose.yml` (or equivalent)**
|
||||
|
||||
Add Redis service for local development:
|
||||
|
||||
```yaml
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- '6379:6379'
|
||||
```
|
||||
|
||||
### Phase 4: Optional Enhancements
|
||||
|
||||
These are not required for the initial implementation but worth considering for follow-up:
|
||||
|
||||
1. **Bull Board integration** — Add a `/api/jobs/dashboard` route that serves Bull Board UI for monitoring. Gate behind an admin auth check.
|
||||
|
||||
2. **Separate worker process** — Add an `apps/worker` entry point that runs BullMQ workers without the web server, for deployments that want to isolate job processing from request handling.
|
||||
|
||||
3. **Graceful shutdown** — Register `SIGTERM`/`SIGINT` handlers to call `worker.close()` and `queue.close()` for clean shutdown.
|
||||
|
||||
4. **BackgroundJob table integration** — Optionally continue writing to the `BackgroundJob` Prisma table for audit/history, using BullMQ events (`completed`, `failed`) to update status. This preserves the existing database-level visibility.
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Action | Description |
|
||||
| ------------------------------------ | ---------- | ---------------------------------------- |
|
||||
| `packages/lib/jobs/client/bullmq.ts` | **Create** | BullMQ provider implementation |
|
||||
| `packages/lib/jobs/client/client.ts` | Modify | Add `'bullmq'` provider case |
|
||||
| `packages/tsconfig/process-env.d.ts` | Modify | Add type for `'bullmq'` + Redis env vars |
|
||||
| `.env.example` | Modify | Add Redis config example |
|
||||
| `turbo.json` | Modify | Add Redis env var to cache keys |
|
||||
| `packages/lib/package.json` | Modify | Add `bullmq` + `ioredis` dependencies |
|
||||
| `docker-compose.yml` | Modify | Add Redis service |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should the BullMQ provider also write to the `BackgroundJob` Prisma table?** This would maintain audit history and allow existing admin tooling to query job status. Trade-off is dual-write complexity.
|
||||
|
||||
2. **Redis connection resilience**: Should the provider gracefully degrade if Redis is unavailable (e.g., fall back to Local provider), or fail hard? Failing hard is simpler and more predictable.
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
- **`io.wait()`**: Not a concern. Only Inngest implements it (via `step.sleep`), the Local provider throws "Not implemented", and no job handler calls `io.wait()`. The BullMQ provider can throw "Not implemented" identically to the Local provider.
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
date: 2026-05-06
|
||||
title: Platform Signing Page Branding
|
||||
---
|
||||
|
||||
## What
|
||||
|
||||
Platform-plan organisations (and their teams) can customise the **non-embed
|
||||
signing pages** (`/sign/:token`, `/d/:token`, and the sibling
|
||||
complete/expired/rejected/waiting pages) with:
|
||||
|
||||
- Six brand colour tokens (background, foreground, primary, primary-foreground,
|
||||
border, ring) plus a border-radius length.
|
||||
- A free-text custom CSS block (up to 256 KB).
|
||||
|
||||
Settings live on `OrganisationGlobalSettings` and `TeamGlobalSettings`. Teams
|
||||
inherit from the org via the existing `brandingEnabled === null` mechanism.
|
||||
|
||||
## Why
|
||||
|
||||
- Embed customers already have white-label CSS; Platform customers want the
|
||||
same coverage on direct signing URLs that they iframe or link to.
|
||||
- Persisting on org/team (not per envelope) means it's set-and-forget.
|
||||
- Sanitising **on save** lets us inline the verbatim string at SSR — no
|
||||
per-render parsing cost, no `<style>.innerHTML` injection on the client.
|
||||
- Reusing the existing `embedSigningWhiteLabel` claim flag keeps "if you can
|
||||
white-label an embed, you can white-label this" as one decision.
|
||||
|
||||
## How
|
||||
|
||||
### Storage (`packages/prisma/schema.prisma`)
|
||||
|
||||
Two new fields on each settings model. No new tables.
|
||||
|
||||
| Field | Org type | Team type |
|
||||
| ---------------- | ------------------ | ------------------ |
|
||||
| `brandingColors` | `Json?` (nullable) | `Json?` (nullable) |
|
||||
| `brandingCss` | `String @default("")` | `String?` |
|
||||
|
||||
Colours are validated against `ZCssVarsSchema`. The team's `null` means
|
||||
"inherit"; an empty colour object is collapsed to `null` server-side so a
|
||||
team toggling `brandingEnabled = true` without filling in colours doesn't
|
||||
silently override the org's defaults with nothing.
|
||||
|
||||
### Sanitiser (`packages/lib/utils/sanitize-branding-css.ts`)
|
||||
|
||||
PostCSS + `postcss-selector-parser`. Runs on save only.
|
||||
|
||||
- Drops selectors containing `::before`/`::after`/`::backdrop`/`::marker` or
|
||||
the universal `*`.
|
||||
- Drops integrity-breaking properties (`display`, `position`, `transform`,
|
||||
layout-affecting dimensions, text-hiding properties).
|
||||
- Drops declaration values containing `url(`, `expression(`, `@import`,
|
||||
`javascript:`.
|
||||
- Strips `!important`.
|
||||
- Allows `@media` only; drops other at-rules.
|
||||
- **Does not** rewrite selectors. Scoping happens at render time via native
|
||||
CSS nesting under `.documenso-branded { ... }`.
|
||||
- Final-pass tripwire: if a literal `</style` somehow survives serialization,
|
||||
reject the entire output. PostCSS already escapes `<` to `\3c` whenever it
|
||||
would form `</...`; the explicit check is belt-and-braces in case a future
|
||||
serializer regresses.
|
||||
- Returns `{ css, warnings[] }`. Warnings are surfaced in the UI.
|
||||
|
||||
Border-radius is the only token interpolated raw into a `<style>` block; it
|
||||
is regex-validated (`CSS_LENGTH_REGEX`) at both the Zod schema and the
|
||||
runtime `toNativeCssVars` call. Belt-and-braces against schema drift.
|
||||
|
||||
### Render (`apps/remix/app/components/general/recipient-branding.tsx`)
|
||||
|
||||
Each recipient loader calls `loadRecipientBrandingByTeamId` and threads the
|
||||
payload through to `<RecipientBranding>`, which emits a single
|
||||
nonce-attributed `<style>`:
|
||||
|
||||
```
|
||||
.documenso-branded {
|
||||
--background: ...; ...
|
||||
<user css>
|
||||
}
|
||||
```
|
||||
|
||||
Native CSS nesting expands user rules under the wrapper. The body class is
|
||||
applied unconditionally to recipient routes in `root.tsx` via `useMatches()`
|
||||
so portaled Radix content (dialogs, popovers, tooltips, dropdowns) inherits
|
||||
the scope.
|
||||
|
||||
CSP for recipient routes already supports `<style nonce>`; no policy
|
||||
changes needed.
|
||||
|
||||
### Plan gate
|
||||
|
||||
`organisationClaim.flags.embedSigningWhiteLabel || !IS_BILLING_ENABLED()`.
|
||||
Self-hosted instances always allow. The outer paywall for logo/URL/details
|
||||
stays on `allowCustomBranding` (Team plan and up); only the new
|
||||
colour/CSS section is Platform-only.
|
||||
|
||||
### UI (`apps/remix/app/components/forms/branding-preferences-form.tsx`)
|
||||
|
||||
Extends the existing branding form. Six `<ColorPicker showHex>` (rewritten
|
||||
to use the native `<input type="color">` instead of `react-colorful`, which
|
||||
was removed) in a 2-col grid, plus a free-text radius input and an
|
||||
`<Accordion>` revealing a mono `<Textarea>`. Defaults come from
|
||||
`packages/lib/constants/theme.ts` (light-mode hex mirror of `theme.css`).
|
||||
|
||||
Warnings from the sanitiser are surfaced in an `<Alert variant="warning">`
|
||||
after save, and the `brandingCss` textarea is re-synced from the persisted
|
||||
value so the user sees exactly what was stored. Other fields are
|
||||
deliberately NOT reset on settings refetch — that would clobber in-flight
|
||||
edits.
|
||||
|
||||
### TRPC
|
||||
|
||||
`update-organisation-settings` and `update-team-settings` accept the new
|
||||
fields, run them through `sanitizeBrandingCss` + `normalizeBrandingColors`,
|
||||
and return any sanitiser warnings to the client. The team route treats
|
||||
`null` as "inherit"; an empty post-sanitisation string is collapsed to
|
||||
`null` (team) so an empty override doesn't mask the org's CSS.
|
||||
|
||||
## Known accepted limitations
|
||||
|
||||
- The sanitiser does not prevent hostile-but-syntactically-valid CSS
|
||||
(`color: transparent`, low-contrast values, etc.). The customer is
|
||||
branding **their own** signing pages — we focus on integrity (no
|
||||
overlay/hide/exfiltrate), not aesthetic policing.
|
||||
- User rules targeting `body`/`html`/`:root` no-op once nested under the
|
||||
wrapper class. Documented for users.
|
||||
- CSS nesting baseline is Chrome 120+ / Firefox 117+ / Safari 16.5+.
|
||||
Acceptable for the Platform-tier audience.
|
||||
- No automated `theme.css` ↔ `theme.ts` sync check; fat comment in
|
||||
`theme.ts` reminds devs to update both.
|
||||
- Per-section team inherit is coarse — `brandingEnabled = null` inherits
|
||||
everything from the org. Per-field inherit toggles are deferred.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Live preview, embed-route sanitiser unification, email/PDF certificate
|
||||
branding, custom font upload, the full ~30 colour tokens in the picker UI,
|
||||
wiring `hidePoweredBy` through to the actual footer.
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
date: 2026-04-22
|
||||
title: Partial Signed Pdf Download
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Let team members fetch a PDF with all currently-inserted fields burned in while the envelope is still in `PENDING` status. Today the only available bytes for a pending envelope are the original (no fields) - the sealed PDF only materialises after the last recipient signs and the `seal-document` job runs.
|
||||
|
||||
Exposed in two places:
|
||||
|
||||
- v2 API: `GET /api/v2/envelope/item/{envelopeItemId}/download?version=pending` (API-token auth)
|
||||
- UI: a `Partial` button in the existing `EnvelopeDownloadDialog`, alongside `Original`. Replaces the `Signed` slot when the envelope is `PENDING`. Backed by the existing session-authed file route `GET /api/files/envelope/{envelopeId}/envelopeItem/{id}/download/pending`.
|
||||
|
||||
## Scope
|
||||
|
||||
- v2 API only (no v1).
|
||||
- `internalVersion === 2` envelopes only. Legacy v1 returns 400 `ENVELOPE_LEGACY`.
|
||||
- Team-side / owner only. No recipient-token download path - recipients have the in-app overlay viewer for verification, and a downloadable half-signed PDF is a leak vector for partially-executed contracts. Enforced both at the server (the recipient-token file route does not accept `pending`) and at the UI (the dialog hides the Partial button when a recipient token is set).
|
||||
- No PKI signature, no certificate page, no audit log appendix - the response is explicitly not a final executed document.
|
||||
- No watermark or banner text. The filename suffix (`_pending.pdf`), the `Cache-Control: no-store, private` header, and the absence of a PKI signature are sufficient to signal draft status.
|
||||
|
||||
## Behaviour
|
||||
|
||||
API response matrix (both `/api/v2/envelope/item/{id}/download?version=pending` and the UI-facing `/api/files/envelope/{envelopeId}/envelopeItem/{id}/download/pending`):
|
||||
|
||||
| Envelope status | Response |
|
||||
|---|---|
|
||||
| `PENDING` (v2) | 200, PDF with currently-inserted fields burned in |
|
||||
| `PENDING` (v1) | 400 `ENVELOPE_LEGACY` |
|
||||
| `DRAFT` | 400 `ENVELOPE_DRAFT` |
|
||||
| `COMPLETED` | 400 `ENVELOPE_COMPLETED` |
|
||||
| `REJECTED` | 400 `ENVELOPE_REJECTED` |
|
||||
|
||||
All v1-vs-v2 / status-mismatch errors are 4xx so callers can cleanly separate them from real server failures (5xx). Specifically v1 PENDING returns 400 not 501: 5xx is reserved for actual server problems, while "this envelope can't satisfy this request shape" is a client-addressable condition.
|
||||
|
||||
Filename: `{title}_pending.pdf`.
|
||||
|
||||
ETag is content-addressed over `sha256(envelope.status + sorted((field.id, field.customText, field.signature?.id, field.signature?.created) for inserted===true fields))`. Returns 304 on `If-None-Match` match.
|
||||
|
||||
No persistent caching. Generated on-demand per request when ETag misses.
|
||||
|
||||
Error response shape (envelope item v2 download route and the team-side file route): preserves the existing `{ error: <message> }` field for backwards compatibility and adds `code: <APP_ERROR_CODE>` as a new field for callers that want to branch on it. The document download route (`/document/{documentId}/download`) is untouched.
|
||||
|
||||
## UI
|
||||
|
||||
`apps/remix/app/components/dialogs/envelope-download-dialog.tsx`:
|
||||
|
||||
- The dialog shows `Original` plus one of:
|
||||
- `Signed` when status is `COMPLETED` (existing behaviour)
|
||||
- `Partial` when status is `PENDING`, there is no recipient token, and the envelope is not legacy (`!isLegacy`)
|
||||
- nothing otherwise
|
||||
- New optional prop `isLegacy?: boolean`. Only consulted to gate the `Partial` button, so callers whose status can never be `PENDING` (DRAFT/COMPLETED/REJECTED hardcoded, or `isComplete: true` matchers) and callers that always set a recipient token can omit it. Three call sites pass it (`isLegacy={envelope.internalVersion === 1}`): `documents-table-action-dropdown.tsx`, `envelope-editor.tsx`, `document-page-view-dropdown.tsx`. The other eight callers were left alone.
|
||||
|
||||
Trade-off: a future team-side dialog usage where status could be PENDING but the dev forgets `isLegacy` will silently not render the Partial button. The status gate prevents an actively broken click; missing button is discoverable in testing. Required-prop alternative was rejected because eight of eleven call sites would carry a meaningless value.
|
||||
|
||||
## Files
|
||||
|
||||
Server:
|
||||
|
||||
- `apps/remix/server/api/download/download.types.ts` - added `'pending'` to the `version` enum; split the validator into `param` (envelopeItemId) + `query` (version). The original wiring as a path-param validator was a pre-existing bug: requests like `?version=original` were silently returning the signed PDF since `version` actually arrives as a query string. Fixed as a side effect.
|
||||
- `packages/trpc/server/envelope-router/download-envelope-item.types.ts` - mirrored the enum change in the OpenAPI schema.
|
||||
- `apps/remix/server/api/download/download.ts` - the envelope item v2 route now fetches envelope recipients alongside the envelope, branches on `version` when calling the helper, and emits AppError responses as `{ error, code }` consistently across all status codes.
|
||||
- `apps/remix/server/api/files/files.types.ts` - added `'pending'` to the team-side download schema only. The recipient-token download schema is untouched, so `/api/files/token/.../download/pending` is rejected by the schema validator.
|
||||
- `apps/remix/server/api/files/files.ts` - the team-side download handler fetches envelope recipients and dispatches the `pending` branch through the same `handleEnvelopeItemFileRequest` helper. Wrapped in a try/catch that returns `{ error, code }` for AppErrors.
|
||||
- `apps/remix/server/api/files/files.helpers.ts` - `handleEnvelopeItemFileRequest` is now a single entry point taking a discriminated-union options type. The static-file flow (`signed`/`original`) and the on-demand pending flow are private helpers in the same module.
|
||||
- `packages/lib/server-only/pdf/generate-partial-signed-pdf.ts` (new) - small orchestrator that loads the original PDF, groups inserted fields by page, calls the existing `insertFieldInPDFV2` overlay helper for each page, flattens, and saves.
|
||||
- `packages/lib/errors/app-error.ts` - added `ENVELOPE_DRAFT`, `ENVELOPE_COMPLETED`, `ENVELOPE_REJECTED`, `ENVELOPE_LEGACY` codes, all mapped to 400. The legacy-envelope case deliberately returns 4xx rather than 501 to keep "this resource can't satisfy this operation" distinct from real 5xx server failures in caller logs/metrics.
|
||||
|
||||
Client:
|
||||
|
||||
- `packages/lib/utils/envelope-download.ts` - `EnvelopeItemPdfUrlOptions` download variant now allows `'pending'` as a version. The recipient-token URL builder will produce a URL the server rejects, but the dialog gates on no-token at the call site.
|
||||
- `packages/lib/client-only/download-pdf.ts` - `DocumentVersion` extended; filename suffix logic moved into a small switch (`_signed.pdf`, `_pending.pdf`, `.pdf`).
|
||||
- `apps/remix/app/components/dialogs/envelope-download-dialog.tsx` - secondary download derivation with the new `Partial` branch, optional `isLegacy` prop.
|
||||
- `apps/remix/app/components/tables/documents-table-action-dropdown.tsx`, `apps/remix/app/components/general/envelope-editor/envelope-editor.tsx`, `apps/remix/app/components/general/document/document-page-view-dropdown.tsx` - pass `isLegacy={envelope.internalVersion === 1}` (or `row.internalVersion === 1`) to the dialog.
|
||||
|
||||
## Verification
|
||||
|
||||
1. E2E (`packages/app-tests/e2e/api/v2/partial-signed-pdf-download.spec.ts`):
|
||||
- Pending envelope, recipient 1 signs, API token download with `?version=pending` returns 200 + PDF; subsequent call with `If-None-Match: <etag>` returns 304; after recipient 2 completes the envelope flips to `COMPLETED` and the same call returns 400 `ENVELOPE_COMPLETED`; `?version=signed` then succeeds.
|
||||
- Draft envelope returns 400 `ENVELOPE_DRAFT`.
|
||||
- `internalVersion === 1` pending envelope returns 400 `ENVELOPE_LEGACY`.
|
||||
|
||||
2. `npx tsc --noEmit -p apps/remix/tsconfig.json` and `npm run lint`.
|
||||
|
||||
3. Manual: open the Documents table or envelope editor on a PENDING envelope (v2), open the download dialog, confirm `Partial` appears alongside `Original` and produces a `_pending.pdf` with current fields burned in. Same dialog on a COMPLETED envelope shows `Signed`. Same dialog on a v1 PENDING envelope shows neither (status gate would show Partial, but the `isLegacy` flag filters it out).
|
||||
|
||||
## Out of Scope / Follow-ups
|
||||
|
||||
- Recipient-token download path (API and UI) - decided against. Revisit if there is concrete demand and a story for limiting the leak vector.
|
||||
- v1 API parity / v1 partial rendering - not building. Implementing partial for v1 would require porting `legacy_insertFieldInPDF` / `insertFieldInPDFV1` into a partial-only flow, which is code with no long-term home as v1 is being phased out.
|
||||
- Document download route (`/document/{documentId}/download`) - untouched. Same error shape and validator wiring as before. Consider normalising to the same `{ error, code }` shape in a follow-up if any caller wants to branch on `code` from that route.
|
||||
- Persistent caching layer / job-queue generation - revisit if p95 latency on large PDFs becomes an issue.
|
||||
- Specific toast for `ENVELOPE_LEGACY` in the dialog - currently the catch-all "Something went wrong" handles it. Worth a polish if v1 PENDING envelopes are common in your data and we see complaints. (Note: with the `isLegacy` gate at the UI, the error is unreachable from the dialog itself; the API can still surface it for direct callers.)
|
||||
+26
-1
@@ -35,6 +35,8 @@ NEXT_PRIVATE_OIDC_PROMPT="login"
|
||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||
# URL used by the web app to request itself (e.g. local background jobs)
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
||||
# OPTIONAL: Comma-separated hostnames or IPs whose webhooks are allowed to resolve to private/loopback addresses. (e.g., internal.example.com,192.168.1.5).
|
||||
NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS=
|
||||
|
||||
# [[SERVER]]
|
||||
# OPTIONAL: The port the server will listen on. Defaults to 3000.
|
||||
@@ -143,16 +145,31 @@ NEXT_PRIVATE_STRIPE_API_KEY=
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# [[BACKGROUND JOBS]]
|
||||
# Available options: local (default) | inngest | bullmq
|
||||
NEXT_PRIVATE_JOBS_PROVIDER="local"
|
||||
NEXT_PRIVATE_INNGEST_EVENT_KEY=
|
||||
# OPTIONAL: Redis URL for the BullMQ jobs provider.
|
||||
NEXT_PRIVATE_REDIS_URL="redis://localhost:63790"
|
||||
# OPTIONAL: Key prefix for Redis to namespace queues (useful when sharing a Redis instance).
|
||||
NEXT_PRIVATE_REDIS_PREFIX="documenso"
|
||||
# OPTIONAL: Number of concurrent jobs to process. Defaults to 10.
|
||||
# NEXT_PRIVATE_BULLMQ_CONCURRENCY=10
|
||||
|
||||
# [[FEATURES]]
|
||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
# OPTIONAL: Leave blank to disable billing.
|
||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||
# OPTIONAL: Set to "true" to disable all signup methods (email, Google, Microsoft, OIDC, including the organisation OIDC portal).
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||
# OPTIONAL: Set to "true" to disable email/password signup only.
|
||||
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=
|
||||
# OPTIONAL: Set to "true" to block new-account creation through Google. Existing linked users can still sign in.
|
||||
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=
|
||||
# OPTIONAL: Set to "true" to block new-account creation through Microsoft. Existing linked users can still sign in.
|
||||
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=
|
||||
# OPTIONAL: Set to "true" to block new-account creation through OIDC (including the organisation portal).
|
||||
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
|
||||
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
|
||||
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
|
||||
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
|
||||
@@ -173,6 +190,14 @@ GOOGLE_VERTEX_LOCATION="global"
|
||||
# https://console.cloud.google.com/vertex-ai/studio/settings/api-keys
|
||||
GOOGLE_VERTEX_API_KEY=""
|
||||
|
||||
# [[CLOUDFLARE TURNSTILE]]
|
||||
# OPTIONAL: Cloudflare Turnstile site key (public). When configured, Turnstile challenges
|
||||
# will be shown on sign-up (visible) and sign-in (invisible) pages.
|
||||
# See: https://developers.cloudflare.com/turnstile/
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
# OPTIONAL: Cloudflare Turnstile secret key (server-side verification).
|
||||
NEXT_PRIVATE_TURNSTILE_SECRET_KEY=
|
||||
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Config files
|
||||
*.config.js
|
||||
*.config.cjs
|
||||
|
||||
# Statically hosted javascript files
|
||||
apps/*/public/*.js
|
||||
apps/*/public/*.cjs
|
||||
scripts/
|
||||
@@ -1,16 +0,0 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['@documenso/eslint-config'],
|
||||
rules: {
|
||||
'@next/next/no-img-element': 'off',
|
||||
'no-unreachable': 'error',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: ['apps/*/'],
|
||||
},
|
||||
},
|
||||
ignorePatterns: ['lingui.config.ts', 'packages/lib/translations/**/*.js'],
|
||||
};
|
||||
@@ -12,6 +12,10 @@ runs:
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
|
||||
- name: Enable corepack
|
||||
shell: bash
|
||||
run: corepack enable npm
|
||||
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
'apps: web':
|
||||
- apps/web/**
|
||||
- apps/remix/**
|
||||
|
||||
'type: documentation':
|
||||
- apps/docs/**
|
||||
|
||||
'version bump 👀':
|
||||
- '**/package.json'
|
||||
|
||||
@@ -71,3 +71,6 @@ scripts/bench-*
|
||||
|
||||
# tmp
|
||||
tmp/
|
||||
|
||||
# opencode
|
||||
.opencode/package-lock.json
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
legacy-peer-deps = true
|
||||
prefer-dedupe = true
|
||||
prefer-dedupe = true
|
||||
min-release-age = 7
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
public
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
packages/lib/translations/**/*.js
|
||||
|
||||
*.lock
|
||||
*.log
|
||||
*.test.ts
|
||||
|
||||
.gitignore
|
||||
.npmignore
|
||||
.prettierignore
|
||||
.DS_Store
|
||||
.eslintignore
|
||||
|
||||
# Docs MDX - Prettier strips indentation from code blocks inside components
|
||||
apps/docs/content/**/*.mdx
|
||||
Vendored
+18
-6
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
"javascript.preferences.useAliasesForRenames": false,
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
@@ -15,8 +15,20 @@
|
||||
"[prisma]": {
|
||||
"editor.defaultFormatter": "Prisma.prisma"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "vscode.html-language-features"
|
||||
},
|
||||
"prisma.pinToPrisma6": true
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"prisma.pinToPrisma6": true,
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- `npm run test:e2e` - Run E2E tests with Playwright
|
||||
- `npm run test:dev -w @documenso/app-tests` - Run single E2E test in dev mode
|
||||
- `npm run test-ui:dev -w @documenso/app-tests` - Run E2E tests with UI
|
||||
- `npm run format` - Format code with Prettier
|
||||
- `npm run format` - Format code with Biome
|
||||
- `npm run dev` - Start development server for Remix app
|
||||
|
||||
**Important:** Do not run `npm run build` to verify changes unless explicitly asked. Builds take a long time (~2 minutes). Use `npx tsc --noEmit` for type checking specific packages if needed.
|
||||
|
||||
+1
-2
@@ -65,8 +65,6 @@ Documenso is an open-source document signing platform built as a **monorepo** us
|
||||
| Package | Description |
|
||||
| ---------------------------- | ------------------------- |
|
||||
| `@documenso/app-tests` | E2E tests (Playwright) |
|
||||
| `@documenso/eslint-config` | Shared ESLint config |
|
||||
| `@documenso/prettier-config` | Shared Prettier config |
|
||||
| `@documenso/tailwind-config` | Shared Tailwind config |
|
||||
| `@documenso/tsconfig` | Shared TypeScript configs |
|
||||
|
||||
@@ -235,6 +233,7 @@ Processes async jobs.
|
||||
| Provider | Description | Env Value |
|
||||
| -------- | --------------------- | ----------------- |
|
||||
| Local | Database-backed queue | `local` (default) |
|
||||
| BullMQ | Redis-backed queue | `bullmq` |
|
||||
| Inngest | Managed cloud service | `inngest` |
|
||||
|
||||
**Config**: `NEXT_PRIVATE_JOBS_PROVIDER`
|
||||
|
||||
@@ -182,6 +182,9 @@ git clone https://github.com/<your-username>/documenso
|
||||
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document.
|
||||
- Optional: Create your own signing certificate.
|
||||
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL), see **[Create your own signing certificate](./SIGNING.md)**.
|
||||
- Optional: Configure job provider for document reminders.
|
||||
- The default local job provider does not support scheduled jobs required for document reminders.
|
||||
- To enable reminders, set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and provide `NEXT_PRIVATE_INNGEST_EVENT_KEY` in your `.env` file.
|
||||
|
||||
### Run in Gitpod
|
||||
|
||||
|
||||
+1
-1
@@ -10,4 +10,4 @@
|
||||
"baseDir": "src",
|
||||
"uiLibrary": "radix-ui",
|
||||
"commands": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"title": "Concepts",
|
||||
"pages": [
|
||||
"document-lifecycle",
|
||||
"recipient-roles",
|
||||
"field-types",
|
||||
"signing-workflow",
|
||||
"signing-certificates"
|
||||
]
|
||||
"pages": ["document-lifecycle", "recipient-roles", "field-types", "signing-workflow", "signing-certificates"]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
---
|
||||
title: Developer Mode
|
||||
description: Advanced development tools for debugging field coordinates and integrating with the Documenso API.
|
||||
description: Advanced development tools for debugging field IDs, recipient IDs, coordinates and integrating with the Documenso API.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Developer mode provides additional tools and features to help you integrate and debug Documenso.
|
||||
|
||||
## Field Coordinates
|
||||
## Field Information
|
||||
|
||||
Field coordinates represent the position of a field in a document. They are returned in the `pageX`, `pageY`, `width` and `height` properties of the field.
|
||||
When enabled, developer mode displays the following information for each field:
|
||||
|
||||
To enable field coordinates, add the `devmode=true` query parameter to the editor URL.
|
||||
- **Field ID** - The unique identifier of the field
|
||||
- **Recipient ID** - The ID of the recipient assigned to the field
|
||||
- **Pos X / Pos Y** - The position of the field on the page
|
||||
- **Width / Height** - The dimensions of the field
|
||||
|
||||
To enable developer mode, add the `devmode=true` query parameter to the editor URL.
|
||||
|
||||
```bash
|
||||
# Legacy editor
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
{
|
||||
"title": "API Reference",
|
||||
"pages": [
|
||||
"documents",
|
||||
"recipients",
|
||||
"fields",
|
||||
"templates",
|
||||
"teams",
|
||||
"rate-limits",
|
||||
"versioning",
|
||||
"developer-mode"
|
||||
]
|
||||
"pages": ["documents", "recipients", "fields", "templates", "teams", "rate-limits", "versioning", "developer-mode"]
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ const TemplateEditor = ({ presignToken, templateId }) => {
|
||||
| `css` | `string` | No | Custom CSS string (Platform Plan) |
|
||||
| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) |
|
||||
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
|
||||
| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) |
|
||||
| `className` | `string` | No | CSS class for the iframe |
|
||||
| `features` | `object` | No | Feature toggles for the authoring experience |
|
||||
|
||||
|
||||
@@ -102,16 +102,18 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => {
|
||||
|
||||
### All V2 Authoring Components
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| ------------------ | --------- | -------- | -------------------------------------------------------- |
|
||||
| `presignToken` | `string` | Yes | Authentication token from your backend |
|
||||
| `externalId` | `string` | No | Your reference ID to link with the envelope |
|
||||
| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` |
|
||||
| `css` | `string` | No | Custom CSS string (Platform Plan) |
|
||||
| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) |
|
||||
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
|
||||
| `className` | `string` | No | CSS class for the iframe |
|
||||
| `features` | `object` | No | Feature toggles for the authoring experience |
|
||||
| Prop | Type | Required | Description |
|
||||
| ---------------- | --------- | -------- | -------------------------------------------------------- |
|
||||
| `presignToken` | `string` | Yes | Authentication token from your backend |
|
||||
| `externalId` | `string` | No | Your reference ID to link with the envelope |
|
||||
| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` |
|
||||
| `css` | `string` | No | Custom CSS string (Platform Plan) |
|
||||
| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) |
|
||||
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
|
||||
| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) |
|
||||
| `className` | `string` | No | CSS class for the iframe |
|
||||
| `user` | `object` | No | Current user info. When provided, enables the "Add Myself" button in the recipients list. Object with optional `email` and `name` fields |
|
||||
| `features` | `object` | No | Feature toggles for the authoring experience |
|
||||
|
||||
### Create Component Only
|
||||
|
||||
@@ -202,6 +204,7 @@ Controls how envelope items (individual files within the envelope) can be manage
|
||||
| `allowConfigureOrder` | `boolean` | `true` | Allow reordering items |
|
||||
| `allowUpload` | `boolean` | `true` | Allow uploading new items |
|
||||
| `allowDelete` | `boolean` | `true` | Allow deleting items |
|
||||
| `allowReplace` | `boolean` | `true` | Allow replacing an item's PDF |
|
||||
|
||||
### Recipients
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ If you prefer not to use any SDK, you can embed signing using [Direct Links](/do
|
||||
| `css` | `string` | Custom CSS string (Platform Plan). |
|
||||
| `cssVars` | `object` | CSS variable overrides for theming (Platform Plan). |
|
||||
| `darkModeDisabled` | `boolean` | Disable dark mode in the embed (Platform Plan). |
|
||||
| `language` | `string` | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts). |
|
||||
| `onDocumentReady` | `function` | Called when the document is loaded and ready. |
|
||||
| `onDocumentCompleted` | `function` | Called when signing is completed. |
|
||||
| `onDocumentError` | `function` | Called when an error occurs. |
|
||||
@@ -175,6 +176,7 @@ If you prefer not to use any SDK, you can embed signing using [Direct Links](/do
|
||||
| `host` | `string` | Documenso instance URL. Defaults to `https://app.documenso.com`. |
|
||||
| `name` | `string` | Pre-fill the signer's name. |
|
||||
| `lockName` | `boolean` | Prevent the signer from changing their name. |
|
||||
| `language` | `string` | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts). |
|
||||
| `onDocumentReady` | `function` | Called when the document is loaded and ready. |
|
||||
| `onDocumentCompleted` | `function` | Called when signing is completed. |
|
||||
| `onDocumentError` | `function` | Called when an error occurs. |
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
title: Background Jobs
|
||||
description: Configure how Documenso processes background tasks like email delivery, document processing, and webhook dispatch.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
## Overview
|
||||
|
||||
Documenso processes background jobs for email delivery, document sealing, webhook dispatch, and scheduled maintenance tasks. Three providers are available:
|
||||
|
||||
| Provider | Backend | Best For | Infrastructure |
|
||||
| -------- | ---------- | ----------------------------------------------- | -------------- |
|
||||
| Inngest | Managed | Production with zero ops overhead | None |
|
||||
| BullMQ | Redis | Self-hosted production with full control | Redis |
|
||||
| Local | PostgreSQL | Development and small self-hosted deployments | None |
|
||||
|
||||
Select a provider with the `NEXT_PRIVATE_JOBS_PROVIDER` environment variable:
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_JOBS_PROVIDER=inngest # or bullmq, local
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
The default provider is `local`. It requires no additional infrastructure and works well for development and small deployments, but is not recommended for production workloads.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Inngest (Recommended)
|
||||
|
||||
[Inngest](https://www.inngest.com/) is a managed background job service. It handles scheduling, retries, concurrency, and observability without any infrastructure to manage. This is the recommended provider for production deployments.
|
||||
|
||||
### Setup
|
||||
|
||||
{/* prettier-ignore */}
|
||||
1. Create an account at [inngest.com](https://www.inngest.com/)
|
||||
2. Create an app and obtain your event key and signing key
|
||||
3. Configure the environment variables:
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_JOBS_PROVIDER=inngest
|
||||
NEXT_PRIVATE_INNGEST_EVENT_KEY=your-event-key
|
||||
INNGEST_SIGNING_KEY=your-signing-key
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Required |
|
||||
| -------------------------------- | -------------------------------------------- | -------- |
|
||||
| `NEXT_PRIVATE_INNGEST_EVENT_KEY` | Inngest event key | Yes |
|
||||
| `INNGEST_EVENT_KEY` | Alternative Inngest event key | No |
|
||||
| `INNGEST_SIGNING_KEY` | Inngest signing key for webhook verification | Yes |
|
||||
| `NEXT_PRIVATE_INNGEST_APP_ID` | Custom Inngest app ID | No |
|
||||
|
||||
### Advantages
|
||||
|
||||
- No infrastructure to manage
|
||||
- Built-in monitoring dashboard
|
||||
- Automatic retries with backoff
|
||||
- Cron scheduling handled externally
|
||||
- Scales automatically
|
||||
|
||||
---
|
||||
|
||||
## BullMQ
|
||||
|
||||
[BullMQ](https://docs.bullmq.io/) is a Redis-backed job queue that runs inside the Documenso process. It provides higher throughput than the local provider, configurable concurrency, and a built-in dashboard for monitoring jobs.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **Redis 6.2+** - any Redis-compatible service works (Redis, KeyDB, Dragonfly, AWS ElastiCache, Upstash, etc.)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_JOBS_PROVIDER=bullmq
|
||||
NEXT_PRIVATE_REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------- | ----------- |
|
||||
| `NEXT_PRIVATE_REDIS_URL` | Redis connection URL | _(required)_ |
|
||||
| `NEXT_PRIVATE_REDIS_PREFIX` | Key prefix for Redis queues (useful when sharing an instance) | `documenso` |
|
||||
| `NEXT_PRIVATE_BULLMQ_CONCURRENCY` | Number of concurrent jobs to process | `10` |
|
||||
|
||||
### Dashboard
|
||||
|
||||
BullMQ includes a job monitoring dashboard at `/api/jobs/board`. In production, only admin users can access the dashboard. In development, it is open to all users.
|
||||
|
||||
The dashboard provides visibility into queued, active, completed, and failed jobs.
|
||||
|
||||
### Docker Compose with Redis
|
||||
|
||||
If you're using Docker Compose, add a Redis service:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
```
|
||||
|
||||
Then set `NEXT_PRIVATE_REDIS_URL=redis://redis:6379` in your Documenso environment.
|
||||
|
||||
### Advantages
|
||||
|
||||
- Self-hosted with no external service dependencies beyond Redis
|
||||
- Configurable concurrency
|
||||
- Built-in job monitoring dashboard
|
||||
- Reliable retries with exponential backoff
|
||||
- Queue namespacing for shared Redis instances
|
||||
|
||||
---
|
||||
|
||||
## Local
|
||||
|
||||
The local provider uses your PostgreSQL database as a job queue. Jobs are stored in the `BackgroundJob` table and processed via internal HTTP requests that Documenso sends to itself.
|
||||
|
||||
### Setup
|
||||
|
||||
No configuration required. The local provider is the default when `NEXT_PRIVATE_JOBS_PROVIDER` is unset or set to `local`.
|
||||
|
||||
```bash
|
||||
# Optional - this is the default
|
||||
NEXT_PRIVATE_JOBS_PROVIDER=local
|
||||
```
|
||||
|
||||
### Internal URL
|
||||
|
||||
Background jobs in the local provider work by Documenso sending HTTP requests to itself. If your reverse proxy or network setup causes issues with the app reaching its own public URL, set the internal URL:
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
This tells the job system to use the internal address instead of `NEXT_PUBLIC_WEBAPP_URL` for self-requests.
|
||||
|
||||
<Callout type="warn">
|
||||
The local provider is suitable for development and small deployments. For production workloads, use Inngest or BullMQ.
|
||||
</Callout>
|
||||
|
||||
### Limitations
|
||||
|
||||
- No concurrency control - jobs are processed one at a time per request cycle
|
||||
- No built-in monitoring
|
||||
- Depends on the application being able to reach itself over HTTP
|
||||
- Not suitable for high-throughput workloads
|
||||
|
||||
---
|
||||
|
||||
## Choosing a Provider
|
||||
|
||||
<Tabs items={['Managed hosting', 'Self-hosted production', 'Development']}>
|
||||
<Tab value="Managed hosting">
|
||||
|
||||
Use **Inngest**. Zero infrastructure, automatic scaling, and built-in observability. The simplest path to reliable background jobs in production.
|
||||
|
||||
</Tab>
|
||||
<Tab value="Self-hosted production">
|
||||
|
||||
Use **BullMQ**. Add a Redis instance to your infrastructure and get reliable job processing with a monitoring dashboard. Good fit if you already run Redis or want to keep everything self-hosted.
|
||||
|
||||
</Tab>
|
||||
<Tab value="Development">
|
||||
|
||||
Use **Local** (the default). No additional setup required. Works out of the box with just PostgreSQL.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
|
||||
- [Requirements](/docs/self-hosting/getting-started/requirements) - Infrastructure requirements
|
||||
- [Docker Compose](/docs/self-hosting/deployment/docker-compose) - Deploy with Docker Compose
|
||||
@@ -224,28 +224,41 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration entirely | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
|
||||
| Variable | Description | Default |
|
||||
| -------------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch. Disable all signup methods application-wide | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only. SSO signup is unaffected | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google. Existing Google-linked users can still sign in | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
|
||||
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
|
||||
|
||||
### Signup Restrictions
|
||||
|
||||
You can control who is allowed to create accounts on your instance using two environment variables:
|
||||
You can control who is allowed to create accounts on your instance with the following environment variables:
|
||||
|
||||
- **`NEXT_PUBLIC_DISABLE_SIGNUP`**: Set to `true` to block all new signups. Existing users can still sign in. This applies to both email/password and OAuth signups.
|
||||
- **`NEXT_PUBLIC_DISABLE_SIGNUP`** (master switch): Set to `true` to block all new signups across every method (email/password, Google, Microsoft, OIDC). When set, this also blocks new-account creation through the organisation OIDC authentication portal.
|
||||
- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP`**: Set to `true` to disable email/password signup only. SSO signup is still allowed.
|
||||
- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNUP`**: Set to `true` to block brand-new account creation through the matching SSO provider. Existing users with the provider already linked can still sign in, and existing users can still link the provider to their account. `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` also blocks new-account creation through the organisation authentication portal.
|
||||
- **`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`**: Restrict signups to specific email domains. When set, only users whose email address matches one of the listed domains can create an account. Leave empty to allow all domains.
|
||||
|
||||
Both restrictions apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
|
||||
Sign-in for existing users is never affected — only the creation of brand-new accounts.
|
||||
|
||||
When both variables are set, `NEXT_PUBLIC_DISABLE_SIGNUP` takes precedence. Signups are blocked regardless of the domain list.
|
||||
Both the master switch and the domain allowlist apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
|
||||
|
||||
When both the master switch and the domain allowlist are set, the master switch takes precedence. Signups are blocked regardless of the domain list.
|
||||
|
||||
```bash
|
||||
# Allow signups only from specific domains
|
||||
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
||||
|
||||
# Allow OIDC signup only; block email/password, Google, Microsoft
|
||||
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP="true"
|
||||
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP="true"
|
||||
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
||||
|
||||
# Or disable signups entirely
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
||||
```
|
||||
@@ -268,20 +281,40 @@ AI features must also be enabled in organisation/team settings after configurati
|
||||
|
||||
## Background Jobs
|
||||
|
||||
Documenso uses a PostgreSQL-based job queue by default. Jobs (email delivery, document processing, webhook dispatch) are stored in the `BackgroundJob` table and processed via internal HTTP requests. No external queue service like Redis is required.
|
||||
Documenso supports multiple background job providers for processing emails, documents, webhooks, and scheduled tasks.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------ | ------- |
|
||||
| `NEXT_PRIVATE_JOBS_PROVIDER` | Jobs provider: `local` (PostgreSQL-based queue) or `inngest` (managed service) | `local` |
|
||||
### Provider Selection
|
||||
|
||||
### Inngest Configuration
|
||||
| Variable | Description | Default |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PRIVATE_JOBS_PROVIDER` | Jobs provider: `local` (PostgreSQL), `bullmq` (Redis), or `inngest` (managed service) | `local` |
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------------- | -------------------------------------------- |
|
||||
| `NEXT_PRIVATE_INNGEST_EVENT_KEY` | Inngest event key |
|
||||
| `INNGEST_EVENT_KEY` | Alternative Inngest event key |
|
||||
| `INNGEST_SIGNING_KEY` | Inngest signing key for webhook verification |
|
||||
| `NEXT_PRIVATE_INNGEST_APP_ID` | Custom Inngest app ID |
|
||||
### Local (local)
|
||||
|
||||
No additional configuration required. Jobs are stored in PostgreSQL and processed via internal HTTP requests.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ---------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||
| `NEXT_PRIVATE_INTERNAL_WEBAPP_URL` | Internal URL for the app to send job requests to itself | Same as `NEXT_PUBLIC_WEBAPP_URL` |
|
||||
|
||||
### BullMQ (bullmq)
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
| ---------------------------------- | -------- | ------------------------------------------------------------- | ----------- |
|
||||
| `NEXT_PRIVATE_REDIS_URL` | Yes | Redis connection URL (e.g., `redis://localhost:6379`) | |
|
||||
| `NEXT_PRIVATE_REDIS_PREFIX` | No | Key prefix for Redis queues (useful when sharing an instance) | `documenso` |
|
||||
| `NEXT_PRIVATE_BULLMQ_CONCURRENCY` | No | Number of concurrent jobs to process | `10` |
|
||||
|
||||
### Inngest (inngest)
|
||||
|
||||
| Variable | Required | Description |
|
||||
| -------------------------------- | -------- | -------------------------------------------- |
|
||||
| `NEXT_PRIVATE_INNGEST_EVENT_KEY` | Yes | Inngest event key |
|
||||
| `INNGEST_EVENT_KEY` | No | Alternative Inngest event key |
|
||||
| `INNGEST_SIGNING_KEY` | Yes | Inngest signing key for webhook verification |
|
||||
| `NEXT_PRIVATE_INNGEST_APP_ID` | No | Custom Inngest app ID |
|
||||
|
||||
For setup guides and provider recommendations, see [Background Jobs](/docs/self-hosting/configuration/background-jobs).
|
||||
|
||||
---
|
||||
|
||||
@@ -351,6 +384,10 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
|
||||
|
||||
# Signup restrictions (optional)
|
||||
# NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
||||
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP="true"
|
||||
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP="true"
|
||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
|
||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
||||
```
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"database",
|
||||
"email",
|
||||
"storage",
|
||||
"background-jobs",
|
||||
"signing-certificate",
|
||||
"telemetry",
|
||||
"advanced"
|
||||
|
||||
@@ -155,7 +155,13 @@ PORT=3000
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
|
||||
|
||||
# Signup restrictions (optional)
|
||||
# Master switch — disables every signup method
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=false
|
||||
# Per-method switches (optional). Each disables brand-new account creation through that method.
|
||||
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=true
|
||||
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=true
|
||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
|
||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
|
||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
|
||||
```
|
||||
|
||||
@@ -252,7 +258,10 @@ Navigate to the signup page and create your account. Verify your email address
|
||||
<Callout type="info">
|
||||
All accounts created through signup are regular user accounts. Admin access must be granted
|
||||
directly in the database. Once your accounts are set up, consider disabling public signups by
|
||||
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`, or restrict signups to specific email domains with
|
||||
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`. For finer control, use the per-method switches
|
||||
`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP`, `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP`,
|
||||
`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`, `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP`, or restrict
|
||||
signups to specific email domains with
|
||||
`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`.
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -100,7 +100,11 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch — disable all signup methods | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google OAuth | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||
|
||||
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
|
||||
|
||||
@@ -153,7 +153,11 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
|
||||
| Variable | Description | Default |
|
||||
| --------------------------------- | ---------------------------------- | ------- |
|
||||
| `PORT` | Application port | `3000` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch — disable all signup methods | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google OAuth | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
|
||||
|
||||
@@ -85,9 +85,13 @@ See [Storage Configuration](/docs/self-hosting/configuration/storage) for setup
|
||||
|
||||
### Background Jobs
|
||||
|
||||
Documenso processes background jobs (email delivery, document processing) using a PostgreSQL-based queue. No additional services like Redis are required: the job queue is built into the application and uses your existing database.
|
||||
Documenso processes background jobs (email delivery, document processing) using a PostgreSQL-based queue by default. No additional services are required: the job queue is built into the application and uses your existing database.
|
||||
|
||||
For high-throughput deployments, Documenso optionally supports [Inngest](https://www.inngest.com/) as an alternative job provider. Set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and configure `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY`. Most self-hosted instances do not need this.
|
||||
For production deployments that need higher throughput or more reliable job processing, Documenso supports [BullMQ](https://docs.bullmq.io/) as an alternative provider. BullMQ requires a **Redis** instance (v6.2+). Set `NEXT_PRIVATE_JOBS_PROVIDER=bullmq` and configure `NEXT_PRIVATE_REDIS_URL`.
|
||||
|
||||
For managed/cloud deployments, [Inngest](https://www.inngest.com/) is also supported as a job provider. Set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and configure `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY`.
|
||||
|
||||
See [Background Jobs Configuration](/docs/self-hosting/configuration/background-jobs) for full details.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -144,19 +144,13 @@ See [Storage Configuration](/docs/self-hosting/configuration/storage) for full s
|
||||
|
||||
---
|
||||
|
||||
## Background Jobs Don't Need Redis
|
||||
## Background Jobs
|
||||
|
||||
Documenso uses a PostgreSQL-based job queue by default. No Redis, no external message broker. The job system uses your existing database to store and process background tasks like email delivery and document processing.
|
||||
Documenso uses a PostgreSQL-based job queue by default (`local` provider). No Redis or external message broker is required for basic deployments.
|
||||
|
||||
For high-throughput deployments, Documenso optionally supports [Inngest](https://www.inngest.com/) as an alternative job provider:
|
||||
For production workloads, consider switching to **Inngest** (managed) or **BullMQ** (self-hosted with Redis) for better reliability and throughput.
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_JOBS_PROVIDER=inngest
|
||||
INNGEST_EVENT_KEY=your-event-key
|
||||
INNGEST_SIGNING_KEY=your-signing-key
|
||||
```
|
||||
|
||||
Most self-hosted instances do not need Inngest.
|
||||
See [Background Jobs Configuration](/docs/self-hosting/configuration/background-jobs) for setup instructions and provider comparison.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,4 +24,14 @@ description: Advanced document features including PDF placeholders, AI detection
|
||||
description="Control who can see documents within a team."
|
||||
href="/docs/users/documents/advanced/document-visibility"
|
||||
/>
|
||||
<Card
|
||||
title="Recipient Expiration"
|
||||
description="Set a signing deadline so document links expire after a configurable period."
|
||||
href="/docs/users/documents/advanced/recipient-expiration"
|
||||
/>
|
||||
<Card
|
||||
title="Signing Reminders"
|
||||
description="Automatically email recipients who have not yet signed on a configurable schedule."
|
||||
href="/docs/users/documents/advanced/signing-reminders"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"pdf-placeholders",
|
||||
"ai-detection",
|
||||
"default-recipients",
|
||||
"document-visibility"
|
||||
"document-visibility",
|
||||
"recipient-expiration",
|
||||
"signing-reminders"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: Recipient Expiration
|
||||
description: Set a signing deadline for recipients so document links expire after a configurable period.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
## Overview
|
||||
|
||||
Recipient expiration lets you set a deadline for how long recipients have to sign a document after it is sent. Once the deadline passes, the recipient can no longer access the signing link and the document owner is notified.
|
||||
|
||||
This is useful when:
|
||||
|
||||
- A business deal is contingent on being signed within a specific time frame
|
||||
- A document is no longer relevant after a certain date
|
||||
- You want to ensure recipients act promptly rather than leaving documents unsigned indefinitely
|
||||
|
||||
Expiration is tracked **per recipient**, not per document. If one recipient's deadline passes, other recipients can still sign. The document stays in a pending state so the owner can decide whether to resend or cancel.
|
||||
|
||||
## Default Behaviour
|
||||
|
||||
Every organisation has a default expiration period of **3 months**. This means that when you send a document, each recipient has 3 months from the time the document is sent to complete their signing.
|
||||
|
||||
You can change this default at the organisation or team level, or override it per document.
|
||||
|
||||
## Settings Cascade
|
||||
|
||||
Expiration settings follow a three-level cascade: **Organisation → Team → Document**. Each level can override the one above it.
|
||||
|
||||
<Tabs items={['Organisation', 'Team', 'Document']}>
|
||||
<Tab value="Organisation">
|
||||
|
||||
Sets the default for all teams in the organisation. Options are a **custom duration** or **never expires**.
|
||||
|
||||
To configure, navigate to **Organisation Settings > Preferences > Document** and find **Default Envelope Expiration**.
|
||||
|
||||
</Tab>
|
||||
<Tab value="Team">
|
||||
|
||||
Overrides the organisation default for documents created within this team. Options are a **custom duration**, **never expires**, or **inherit from organisation**.
|
||||
|
||||
New teams default to **inherit from organisation**.
|
||||
|
||||
To configure, navigate to **Team Settings > Preferences > Document** and find **Default Envelope Expiration**.
|
||||
|
||||
</Tab>
|
||||
<Tab value="Document">
|
||||
|
||||
Overrides the team or organisation default for a single document. Options are a **custom duration** or **never expires**.
|
||||
|
||||
If you do not change the expiration when editing a document, the team or organisation default applies.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Set Expiration for a Document
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Open the document settings
|
||||
|
||||
In the document editor, open the **Settings** dialog and go to the **General** tab.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Configure the expiration
|
||||
|
||||
Find the **Expiration** field. Choose one of:
|
||||
|
||||
- **Custom duration** — enter a number and select a unit (days, weeks, months, or years)
|
||||
- **Never expires** — the recipient can sign at any time
|
||||
|
||||
If you leave it unchanged, the team or organisation default applies.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Send the document
|
||||
|
||||
When you send the document, the expiration deadline is calculated from that moment. For example, if you set a 7-day expiration and send the document on March 1st, the recipient has until March 8th to sign.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
You cannot change the expiration period after the document has been sent. To extend a recipient's
|
||||
deadline, resend the document to them — this resets the clock.
|
||||
</Callout>
|
||||
|
||||
## Set a Default Expiration Period
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Navigate to document preferences
|
||||
|
||||
Go to **Organisation Settings > Preferences > Document** (or **Team Settings > Preferences > Document** for team-level overrides).
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Configure the default
|
||||
|
||||
Find **Default Envelope Expiration** and choose:
|
||||
|
||||
- **Custom duration** — enter a number and unit
|
||||
- **Never expires** — no deadline for recipients
|
||||
- **Inherit from organisation** (team level only) — use whatever the organisation has configured
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Save
|
||||
|
||||
Click **Save** to apply. New documents created after this change use the updated default.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
Changing the default expiration does not affect documents that have already been sent. Only new
|
||||
documents use the updated setting.
|
||||
</Callout>
|
||||
|
||||
## What Happens When a Recipient Expires
|
||||
|
||||
When a recipient's signing deadline passes:
|
||||
|
||||
1. The recipient can no longer access the signing link. They see a message explaining that the signing deadline has expired and to contact the document owner.
|
||||
2. The document owner receives an email notification with a link to view the document.
|
||||
3. An audit log entry is created recording the expiration.
|
||||
4. The document remains in a **pending** state — other recipients who have not expired can still sign.
|
||||
|
||||

|
||||
|
||||
## Resending to Extend a Deadline
|
||||
|
||||
If a recipient's deadline has passed (or is about to), you can resend the document to them. Resending recalculates the expiration from the current time, effectively extending the deadline.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Open the document
|
||||
|
||||
Navigate to the document page and find the recipient whose deadline has expired. Expired recipients are marked with an **Expired** badge.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Resend
|
||||
|
||||
Click the resend option for the recipient. This sends a new signing link and resets the expiration clock based on the document's configured expiration period.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Expiration Options Reference
|
||||
|
||||
| Unit | Example | Description |
|
||||
| ------ | --------------- | ------------------------------------------- |
|
||||
| Days | 7 days | Recipient has 7 days from when the document is sent |
|
||||
| Weeks | 2 weeks | Recipient has 2 weeks from when the document is sent |
|
||||
| Months | 3 months | Recipient has 3 months from when the document is sent (default) |
|
||||
| Years | 1 year | Recipient has 1 year from when the document is sent |
|
||||
|
||||
You can also set expiration to **never expires**, which means the signing link remains valid indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Send Documents](/docs/users/documents/send) - Send documents for signing
|
||||
- [Document Preferences](/docs/users/organisations/preferences/document) - Configure default document settings
|
||||
- [Add Recipients](/docs/users/documents/add-recipients) - Add signers and other recipients to a document
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
title: Signing Reminders
|
||||
description: Automatically email recipients who have not yet signed on a configurable schedule.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
## Overview
|
||||
|
||||
Signing reminders automatically email recipients who have not completed their signing. You configure when the first reminder goes out and how often it repeats — Documenso handles the rest until the recipient signs or the document leaves a pending state.
|
||||
|
||||
This is useful when:
|
||||
|
||||
- You want recipients nudged without having to track them down manually
|
||||
- A deadline is approaching and you want to escalate gradually
|
||||
- You send a high volume of documents and cannot chase each one
|
||||
|
||||
Reminders are tracked **per recipient**, not per document. Each unsigned recipient is on their own schedule based on when they were emailed.
|
||||
|
||||
This is different from the **Resend** action covered in [Send Documents](/docs/users/documents/send), which is a one-off manual nudge. Reminders are scheduled and recurring.
|
||||
|
||||
## Default Behaviour
|
||||
|
||||
Every **newly created** organisation starts with reminders **enabled**:
|
||||
|
||||
- First reminder sent **5 days** after the recipient is emailed
|
||||
- Repeats **every 2 days** until the recipient signs
|
||||
|
||||
You can change this default at the organisation or team level, or override it per document.
|
||||
|
||||
<Callout type="info">
|
||||
Organisations created **before this feature shipped** have reminders left blank (no default) so
|
||||
recipients of in-flight or future documents are not unexpectedly sent reminders. To enable
|
||||
reminders for an existing organisation, configure **Default Signing Reminders** under
|
||||
**Organisation Settings > Preferences > Document**.
|
||||
</Callout>
|
||||
|
||||
## Settings Cascade
|
||||
|
||||
Reminder settings follow a three-level cascade: **Organisation → Team → Document**. Each level can override the one above it, or inherit from it.
|
||||
|
||||
<Tabs items={['Organisation', 'Team', 'Document']}>
|
||||
<Tab value="Organisation">
|
||||
|
||||
Sets the default for all teams in the organisation. Options are **Enabled** (set when the first reminder fires and how often it repeats) or **No reminders**.
|
||||
|
||||
To configure, navigate to **Organisation Settings > Preferences > Document** and find **Default Signing Reminders**.
|
||||
|
||||
</Tab>
|
||||
<Tab value="Team">
|
||||
|
||||
Overrides the organisation default for documents created within this team. Options are **Enabled**, **No reminders**, or **Inherit from organisation**.
|
||||
|
||||
New teams default to **Inherit from organisation**.
|
||||
|
||||
To configure, navigate to **Team Settings > Preferences > Document** and find **Default Signing Reminders**.
|
||||
|
||||
</Tab>
|
||||
<Tab value="Document">
|
||||
|
||||
Overrides the team or organisation default for a single document. Options are **Enabled**, **No reminders**, or **Inherit from organisation**.
|
||||
|
||||
If you do not change reminders when editing a document, the team or organisation default applies.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Set Reminders for a Document
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Open the document settings
|
||||
|
||||
In the document editor, open the **Settings** dialog and go to the **Reminders** tab.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Choose a mode
|
||||
|
||||
- **Enabled** — Documenso sends reminders on the schedule you configure below
|
||||
- **No reminders** — no automatic reminders for this document
|
||||
- **Inherit from organisation** — use the team or organisation default
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Configure the schedule
|
||||
|
||||
When **Enabled**, set:
|
||||
|
||||
- **Send first reminder after** — a number and unit (days, weeks, or months) measured from when the recipient is first emailed
|
||||
- **Then repeat every** — either **Custom interval** (a number and unit) or **Don't repeat** (only one reminder is ever sent)
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Send the document
|
||||
|
||||
The first reminder is scheduled when the recipient receives the initial signing email. Subsequent reminders are scheduled from the time the previous reminder was sent.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
Editing reminder settings on a document that is already pending recalculates the next reminder for
|
||||
every unsigned recipient immediately.
|
||||
</Callout>
|
||||
|
||||
## Set a Default Reminder Schedule
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Navigate to document preferences
|
||||
|
||||
Go to **Organisation Settings > Preferences > Document** (or **Team Settings > Preferences > Document** for team-level overrides).
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Configure the default
|
||||
|
||||
Find **Default Signing Reminders** and choose:
|
||||
|
||||
- **Enabled** — set the first-reminder delay and repeat interval
|
||||
- **No reminders** — disable reminders by default
|
||||
- **Inherit from organisation** (team level only) — use whatever the organisation has configured
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Save
|
||||
|
||||
Click **Save** to apply. New documents created after this change use the updated default. Documents already in flight are unaffected unless you edit their reminder settings directly.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## What Happens When a Reminder Fires
|
||||
|
||||
When a recipient's next reminder time arrives:
|
||||
|
||||
1. Documenso sends the reminder email — same template as the original signing request, but the subject and preview are prefixed with **"Reminder:"**.
|
||||
2. An audit log entry is created for the recipient (an `EMAIL_SENT` entry of type `REMINDER`).
|
||||
3. The `document.reminder.sent` webhook fires. See [webhook events](/docs/developers/webhooks/events).
|
||||
4. The next reminder is scheduled based on **Then repeat every**, or no further reminder is scheduled if you chose **Don't repeat**.
|
||||
|
||||
Reminders stop automatically when the recipient signs, declines, the recipient's signing deadline passes, or the document leaves the pending state (completed, rejected, or deleted).
|
||||
|
||||
## When Reminders Are Skipped
|
||||
|
||||
A configured reminder will not be sent in the following cases:
|
||||
|
||||
- The recipient is a **CC** — CCs are notified once and never reminded
|
||||
- The recipient's **signing deadline has passed** — see [Recipient Expiration](/docs/users/documents/advanced/recipient-expiration). Resending the document refreshes the deadline and resumes reminders.
|
||||
- The document uses **manual link distribution** — Documenso never emails recipients for these documents
|
||||
- The envelope's email settings have **signing request emails disabled**
|
||||
- The recipient has not yet been emailed (for example, an unreached step in a sequential workflow)
|
||||
|
||||
<Callout type="info">
|
||||
Reminders stop automatically **30 days after the recipient was first emailed**, regardless of the
|
||||
repeat interval. This hard cap prevents runaway reminder chains for recipients who never sign and
|
||||
have no expiration set. If you need a different stop condition, set a shorter repeat interval,
|
||||
use **Don't repeat**, or configure [recipient expiration](/docs/users/documents/advanced/recipient-expiration).
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Reminders are dispatched by a background sweep that runs every 15 minutes, so the actual send time
|
||||
may be up to ~15 minutes after the scheduled time.
|
||||
</Callout>
|
||||
|
||||
## Reminder Options Reference
|
||||
|
||||
| Setting | Options | Notes |
|
||||
| --------------------------- | ---------------------------------------- | -------------------------------------------------------------------- |
|
||||
| **Mode** | Enabled / No reminders / Inherit | Inherit is only available at the team and document levels |
|
||||
| **Send first reminder after** | 1+ days, weeks, or months | Measured from when the recipient receives the initial signing email. Reminders past 30 days from that moment are skipped. |
|
||||
| **Then repeat every** | Custom interval (1+ days/weeks/months) or Don't repeat | Custom interval keeps reminding until the recipient signs or the 30-day cap is reached |
|
||||
|
||||
The organisation default out of the box is **first reminder after 5 days, repeating every 2 days**, which falls well inside the 30-day cap.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Send Documents](/docs/users/documents/send) - Send documents and trigger a one-off resend
|
||||
- [Recipient Expiration](/docs/users/documents/advanced/recipient-expiration) - Set a hard signing deadline
|
||||
- [Document Preferences](/docs/users/organisations/preferences/document) - Configure default document settings
|
||||
- [Webhook Events](/docs/developers/webhooks/events) - Subscribe to `document.reminder.sent`
|
||||
@@ -1,13 +1,4 @@
|
||||
{
|
||||
"title": "Organisations",
|
||||
"pages": [
|
||||
"overview",
|
||||
"create-team",
|
||||
"members",
|
||||
"groups",
|
||||
"email-domains",
|
||||
"preferences",
|
||||
"single-sign-on",
|
||||
"billing"
|
||||
]
|
||||
"pages": ["overview", "create-team", "members", "groups", "email-domains", "preferences", "single-sign-on", "billing"]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ To access the preferences, navigate to either the organisation or teams settings
|
||||
| **Include the Signing Certificate** | Whether the signing certificate is embedded in signed PDFs. The certificate is always available separately from the logs page. |
|
||||
| **Include the Audit Logs** | Whether the audit logs are embedded in the document when downloaded. The audit logs are always available separately from the logs page. |
|
||||
| **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. |
|
||||
| **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). |
|
||||
| **Default Signing Reminders** | When and how often to email recipients who have not yet signed. See [signing reminders](/docs/users/documents/advanced/signing-reminders). |
|
||||
| **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. |
|
||||
| **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. |
|
||||
|
||||
|
||||
@@ -134,6 +134,13 @@ Leave empty to allow any domain authenticated by your identity provider.
|
||||
team.
|
||||
</Callout>
|
||||
|
||||
### Allow Personal Organisations
|
||||
|
||||
Controls whether users signing in via SSO for the first time also receive their own personal organisation in addition to joining your organisation.
|
||||
|
||||
- **Enabled**: New SSO users get a personal organisation where they can create and manage their own documents independently.
|
||||
- **Disabled**: New SSO users only join your organisation and do not receive a personal organisation.
|
||||
|
||||
## User Provisioning
|
||||
|
||||
When a user signs in through your SSO portal for the first time:
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"fumadocs-ui": "16.5.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"next": "16.1.6",
|
||||
"next": "16.2.4",
|
||||
"next-plausible": "^3.12.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 596 KiB After Width: | Height: | Size: 117 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
@@ -1,5 +1,5 @@
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { BookOpenIcon, CodeIcon, FileTextIcon, GithubIcon, ServerIcon, ShieldCheckIcon, UserIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
BookOpenIcon,
|
||||
CodeIcon,
|
||||
FileTextIcon,
|
||||
GithubIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Documenso Docs',
|
||||
description:
|
||||
@@ -22,21 +13,21 @@ export default function HomePage() {
|
||||
<main className="mx-auto max-w-4xl px-4 py-12">
|
||||
{/* Hero */}
|
||||
<div className="mb-16 pt-6 text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight">Documenso Documentation</h1>
|
||||
<p className="text-fd-muted-foreground mx-auto mb-8 max-w-2xl text-lg">
|
||||
The open-source document signing platform. Send documents for signatures, integrate with
|
||||
your apps, or self-host with full control.
|
||||
<h1 className="mb-4 font-bold text-4xl tracking-tight">Documenso Documentation</h1>
|
||||
<p className="mx-auto mb-8 max-w-2xl text-fd-muted-foreground text-lg">
|
||||
The open-source document signing platform. Send documents for signatures, integrate with your apps, or
|
||||
self-host with full control.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link
|
||||
href="/docs/users"
|
||||
className="bg-documenso text-fd-primary-foreground hover:bg-documenso-dark/90 inline-flex items-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-documenso px-5 py-2.5 font-medium text-fd-primary-foreground text-sm transition-colors hover:bg-documenso-dark/90"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/documenso/documenso"
|
||||
className="bg-fd-background hover:bg-fd-accent inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-medium transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg border bg-fd-background px-5 py-2.5 font-medium text-sm transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
<GithubIcon className="size-4" />
|
||||
View on GitHub
|
||||
@@ -48,64 +39,60 @@ export default function HomePage() {
|
||||
<div className="mb-16 grid gap-4 md:grid-cols-3">
|
||||
<Link
|
||||
href="/docs/users"
|
||||
className="group bg-fd-card hover:border-fd-primary/50 relative flex flex-col rounded-xl border p-6 transition-all hover:shadow-md"
|
||||
className="group relative flex flex-col rounded-xl border bg-fd-card p-6 transition-all hover:border-fd-primary/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<UserIcon className="size-6" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-lg font-semibold">User Guide</h2>
|
||||
<p className="text-fd-muted-foreground mb-4 flex-1 text-sm">
|
||||
<h2 className="mb-2 font-semibold text-lg">User Guide</h2>
|
||||
<p className="mb-4 flex-1 text-fd-muted-foreground text-sm">
|
||||
Send documents, create templates, and manage your team using the web application.
|
||||
</p>
|
||||
<span className="text-fd-primary text-sm font-medium">Get started →</span>
|
||||
<span className="font-medium text-fd-primary text-sm">Get started →</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/docs/developers"
|
||||
className="group bg-fd-card hover:border-fd-primary/50 relative flex flex-col rounded-xl border p-6 transition-all hover:shadow-md"
|
||||
className="group relative flex flex-col rounded-xl border bg-fd-card p-6 transition-all hover:border-fd-primary/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
<CodeIcon className="size-6" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-lg font-semibold">Developer Guide</h2>
|
||||
<p className="text-fd-muted-foreground mb-4 flex-1 text-sm">
|
||||
Integrate document signing into your applications with the REST API, webhooks, and
|
||||
embedding.
|
||||
<h2 className="mb-2 font-semibold text-lg">Developer Guide</h2>
|
||||
<p className="mb-4 flex-1 text-fd-muted-foreground text-sm">
|
||||
Integrate document signing into your applications with the REST API, webhooks, and embedding.
|
||||
</p>
|
||||
<span className="text-fd-primary text-sm font-medium">View API docs →</span>
|
||||
<span className="font-medium text-fd-primary text-sm">View API docs →</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/docs/self-hosting"
|
||||
className="group bg-fd-card hover:border-fd-primary/50 relative flex flex-col rounded-xl border p-6 transition-all hover:shadow-md"
|
||||
className="group relative flex flex-col rounded-xl border bg-fd-card p-6 transition-all hover:border-fd-primary/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-lg bg-purple-500/10 text-purple-600 dark:text-purple-400">
|
||||
<ServerIcon className="size-6" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-lg font-semibold">Self-Hosting Guide</h2>
|
||||
<p className="text-fd-muted-foreground mb-4 flex-1 text-sm">
|
||||
<h2 className="mb-2 font-semibold text-lg">Self-Hosting Guide</h2>
|
||||
<p className="mb-4 flex-1 text-fd-muted-foreground text-sm">
|
||||
Deploy your own Documenso instance with Docker, Kubernetes, or Railway.
|
||||
</p>
|
||||
<span className="text-fd-primary text-sm font-medium">Deploy now →</span>
|
||||
<span className="font-medium text-fd-primary text-sm">Deploy now →</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Quick Start & Core Concepts */}
|
||||
<div className="mb-16 grid gap-8 md:grid-cols-2">
|
||||
<div className="bg-fd-card/50 rounded-xl border p-6">
|
||||
<div className="rounded-xl border bg-fd-card/50 p-6">
|
||||
<h3 className="mb-4 flex items-center gap-2 font-semibold">
|
||||
<BookOpenIcon className="text-fd-muted-foreground size-5" />
|
||||
<BookOpenIcon className="size-5 text-fd-muted-foreground" />
|
||||
Quick Start
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium">Send your first document</h4>
|
||||
<ol className="text-fd-muted-foreground list-inside list-decimal space-y-1 text-sm">
|
||||
<h4 className="mb-2 font-medium text-sm">Send your first document</h4>
|
||||
<ol className="list-inside list-decimal space-y-1 text-fd-muted-foreground text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="/docs/users/getting-started/create-account"
|
||||
className="text-fd-primary hover:underline"
|
||||
>
|
||||
<Link href="/docs/users/getting-started/create-account" className="text-fd-primary hover:underline">
|
||||
Create an account
|
||||
</Link>
|
||||
</li>
|
||||
@@ -120,8 +107,8 @@ export default function HomePage() {
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium">Integrate with the API</h4>
|
||||
<ol className="text-fd-muted-foreground list-inside list-decimal space-y-1 text-sm">
|
||||
<h4 className="mb-2 font-medium text-sm">Integrate with the API</h4>
|
||||
<ol className="list-inside list-decimal space-y-1 text-fd-muted-foreground text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="/docs/developers/getting-started/authentication"
|
||||
@@ -141,8 +128,8 @@ export default function HomePage() {
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium">Deploy self-hosted</h4>
|
||||
<ol className="text-fd-muted-foreground list-inside list-decimal space-y-1 text-sm">
|
||||
<h4 className="mb-2 font-medium text-sm">Deploy self-hosted</h4>
|
||||
<ol className="list-inside list-decimal space-y-1 text-fd-muted-foreground text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="/docs/self-hosting/getting-started/requirements"
|
||||
@@ -164,36 +151,36 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-fd-card/50 rounded-xl border p-6">
|
||||
<div className="rounded-xl border bg-fd-card/50 p-6">
|
||||
<h3 className="mb-4 flex items-center gap-2 font-semibold">
|
||||
<BookOpenIcon className="text-fd-muted-foreground size-5" />
|
||||
<BookOpenIcon className="size-5 text-fd-muted-foreground" />
|
||||
Core Concepts
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Link
|
||||
href="/docs/concepts/document-lifecycle"
|
||||
className="bg-fd-background hover:border-fd-primary/50 rounded-lg border p-3 text-sm transition-colors"
|
||||
className="rounded-lg border bg-fd-background p-3 text-sm transition-colors hover:border-fd-primary/50"
|
||||
>
|
||||
<div className="mb-1 font-medium">Document Lifecycle</div>
|
||||
<div className="text-fd-muted-foreground text-xs">Draft to completed</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/concepts/recipient-roles"
|
||||
className="bg-fd-background hover:border-fd-primary/50 rounded-lg border p-3 text-sm transition-colors"
|
||||
className="rounded-lg border bg-fd-background p-3 text-sm transition-colors hover:border-fd-primary/50"
|
||||
>
|
||||
<div className="mb-1 font-medium">Recipient Roles</div>
|
||||
<div className="text-fd-muted-foreground text-xs">Signers and approvers</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/concepts/field-types"
|
||||
className="bg-fd-background hover:border-fd-primary/50 rounded-lg border p-3 text-sm transition-colors"
|
||||
className="rounded-lg border bg-fd-background p-3 text-sm transition-colors hover:border-fd-primary/50"
|
||||
>
|
||||
<div className="mb-1 font-medium">Field Types</div>
|
||||
<div className="text-fd-muted-foreground text-xs">Signatures and inputs</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/concepts/signing-certificates"
|
||||
className="bg-fd-background hover:border-fd-primary/50 rounded-lg border p-3 text-sm transition-colors"
|
||||
className="rounded-lg border bg-fd-background p-3 text-sm transition-colors hover:border-fd-primary/50"
|
||||
>
|
||||
<div className="mb-1 font-medium">Signing Certificates</div>
|
||||
<div className="text-fd-muted-foreground text-xs">Digital verification</div>
|
||||
@@ -206,7 +193,7 @@ export default function HomePage() {
|
||||
<div className="mb-16 grid gap-4 md:grid-cols-2">
|
||||
<Link
|
||||
href="/docs/compliance"
|
||||
className="bg-fd-card/50 hover:border-fd-primary/50 flex items-start gap-4 rounded-xl border p-5 transition-all"
|
||||
className="flex items-start gap-4 rounded-xl border bg-fd-card/50 p-5 transition-all hover:border-fd-primary/50"
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<ShieldCheckIcon className="size-5" />
|
||||
@@ -221,7 +208,7 @@ export default function HomePage() {
|
||||
|
||||
<Link
|
||||
href="/docs/policies"
|
||||
className="bg-fd-card/50 hover:border-fd-primary/50 flex items-start gap-4 rounded-xl border p-5 transition-all"
|
||||
className="flex items-start gap-4 rounded-xl border bg-fd-card/50 p-5 transition-all hover:border-fd-primary/50"
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-slate-500/10 text-slate-600 dark:text-slate-400">
|
||||
<FileTextIcon className="size-5" />
|
||||
@@ -236,22 +223,22 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
{/* Community CTA */}
|
||||
<div className="from-fd-primary/5 to-fd-primary/10 rounded-xl border bg-gradient-to-r p-8 text-center">
|
||||
<h3 className="mb-2 text-lg font-semibold">Join the Community</h3>
|
||||
<p className="text-fd-muted-foreground mb-6 text-sm">
|
||||
<div className="rounded-xl border bg-gradient-to-r from-fd-primary/5 to-fd-primary/10 p-8 text-center">
|
||||
<h3 className="mb-2 font-semibold text-lg">Join the Community</h3>
|
||||
<p className="mb-6 text-fd-muted-foreground text-sm">
|
||||
Documenso is open source. Contribute, ask questions, or share feedback.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<a
|
||||
href="https://github.com/documenso/documenso"
|
||||
className="bg-fd-background hover:bg-fd-accent inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg border bg-fd-background px-4 py-2 font-medium text-sm transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
<GithubIcon className="size-4" />
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://documen.so/discord"
|
||||
className="bg-fd-background hover:bg-fd-accent inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg border bg-fd-background px-4 py-2 font-medium text-sm transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
@@ -260,7 +247,7 @@ export default function HomePage() {
|
||||
</a>
|
||||
<a
|
||||
href="https://app.documenso.com/signup"
|
||||
className="bg-documenso text-fd-primary-foreground hover:bg-documenso/90 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-documenso px-4 py-2 font-medium text-fd-primary-foreground text-sm transition-colors hover:bg-documenso/90"
|
||||
>
|
||||
Try Documenso
|
||||
</a>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
import { source } from '@/lib/source';
|
||||
|
||||
export const { GET } = createFromSource(source, {
|
||||
// https://docs.orama.com/docs/orama-js/supported-languages
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';
|
||||
import { getPageImage, source } from '@/lib/source';
|
||||
import { getMDXComponents } from '@/mdx-components';
|
||||
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
|
||||
|
||||
const gitConfig = {
|
||||
user: 'documenso',
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { getFilteredPageTree, source } from '@/lib/source';
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import { CodeIcon, ServerIcon, UserIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { getFilteredPageTree, source } from '@/lib/source';
|
||||
|
||||
const ROOT_SECTIONS = [
|
||||
{
|
||||
@@ -44,7 +42,9 @@ function getFirstPageUrl(children: PageTree.Node[]): string | undefined {
|
||||
}
|
||||
if (child.type === 'folder' && child.children.length > 0) {
|
||||
const url = getFirstPageUrl(child.children);
|
||||
if (url) return url;
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -69,13 +69,8 @@ function SectionSwitcher({ activeSection }: { activeSection: string | null }) {
|
||||
>
|
||||
<Icon className={cn('mt-0.5 size-4 shrink-0', isActive ? 'text-fd-primary' : '')} />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{section.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isActive ? 'text-fd-muted-foreground' : 'text-fd-muted-foreground/70',
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-sm">{section.label}</span>
|
||||
<span className={cn('text-xs', isActive ? 'text-fd-muted-foreground' : 'text-fd-muted-foreground/70')}>
|
||||
{section.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'fumadocs-ui/css/shadcn.css';
|
||||
@import 'fumadocs-ui/css/preset.css';
|
||||
@import "tailwindcss";
|
||||
@import "fumadocs-ui/css/shadcn.css";
|
||||
@import "fumadocs-ui/css/preset.css";
|
||||
|
||||
@theme {
|
||||
/* Brand utility colors */
|
||||
@@ -43,13 +43,11 @@
|
||||
--sidebar-border: hsl(223.8136 0.0001% 89.8161%);
|
||||
--sidebar-ring: hsl(223.8136 0% 63.0163%);
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--radius: 0.5rem;
|
||||
--shadow-x: 0;
|
||||
--shadow-y: 1px;
|
||||
@@ -103,13 +101,11 @@
|
||||
--sidebar-border: hsl(223.8136 0% 15.5096%);
|
||||
--sidebar-ring: hsl(223.8136 0% 32.1993%);
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--radius: 0.5rem;
|
||||
--shadow-x: 0;
|
||||
--shadow-y: 1px;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||
import PlausibleProvider from 'next-plausible';
|
||||
|
||||
import './global.css';
|
||||
@@ -16,8 +15,7 @@ export const metadata: Metadata = {
|
||||
template: '%s | Documenso Docs',
|
||||
default: 'Documenso Docs',
|
||||
},
|
||||
description:
|
||||
'The official documentation for Documenso, the open-source document signing platform.',
|
||||
description: 'The official documentation for Documenso, the open-source document signing platform.',
|
||||
openGraph: {
|
||||
siteName: 'Documenso Docs',
|
||||
type: 'website',
|
||||
|
||||
@@ -3,20 +3,20 @@ import Link from 'next/link';
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="mx-auto flex max-w-xl flex-col items-center justify-center px-4 py-32 text-center">
|
||||
<h1 className="text-4xl font-bold tracking-tight">Page not found</h1>
|
||||
<p className="text-fd-muted-foreground mt-4 text-lg">
|
||||
<h1 className="font-bold text-4xl tracking-tight">Page not found</h1>
|
||||
<p className="mt-4 text-fd-muted-foreground text-lg">
|
||||
The page you are looking for may have moved. Our documentation was recently restructured.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<Link
|
||||
href="/docs/users"
|
||||
className="bg-documenso text-fd-primary-foreground hover:bg-documenso/90 inline-flex items-center rounded-lg px-5 py-2.5 text-sm font-medium transition-colors"
|
||||
className="inline-flex items-center rounded-lg bg-documenso px-5 py-2.5 font-medium text-fd-primary-foreground text-sm transition-colors hover:bg-documenso/90"
|
||||
>
|
||||
Browse documentation
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-fd-background hover:bg-fd-accent inline-flex items-center rounded-lg border px-5 py-2.5 text-sm font-medium transition-colors"
|
||||
className="inline-flex items-center rounded-lg border bg-fd-background px-5 py-2.5 font-medium text-sm transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
Go to homepage
|
||||
</Link>
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
import { getPageImage, source } from '@/lib/source';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPageImage, source } from '@/lib/source';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
const loadAssets = async () => {
|
||||
const [logoBuffer, interRegularData, interSemiBoldData, interBoldData] = await Promise.all([
|
||||
readFile(fileURLToPath(new URL('../../../../../public/logo.png', import.meta.url))),
|
||||
readFile(
|
||||
fileURLToPath(new URL('../../../../../public/fonts/inter-regular.ttf', import.meta.url)),
|
||||
),
|
||||
readFile(
|
||||
fileURLToPath(new URL('../../../../../public/fonts/inter-semibold.ttf', import.meta.url)),
|
||||
),
|
||||
readFile(fileURLToPath(new URL('../../../../../public/fonts/inter-regular.ttf', import.meta.url))),
|
||||
readFile(fileURLToPath(new URL('../../../../../public/fonts/inter-semibold.ttf', import.meta.url))),
|
||||
readFile(fileURLToPath(new URL('../../../../../public/fonts/inter-bold.ttf', import.meta.url))),
|
||||
]);
|
||||
|
||||
@@ -40,104 +35,100 @@ export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...
|
||||
const { logoSrc, fonts } = await loadAssets();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'white',
|
||||
padding: '60px 80px',
|
||||
fontFamily: 'Inter',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Green accent bar */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '6px',
|
||||
backgroundColor: '#6DC947',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top: Logo */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={logoSrc} alt="Documenso" height="28" />
|
||||
<span
|
||||
style={{
|
||||
color: '#D4D4D8',
|
||||
fontSize: '28px',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
|
|
||||
</span>
|
||||
<span style={{ color: '#71717A', fontSize: '20px', fontWeight: 400 }}>Docs</span>
|
||||
</div>
|
||||
|
||||
{/* Middle: Title + description */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'white',
|
||||
padding: '60px 80px',
|
||||
fontFamily: 'Inter',
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
{/* Green accent bar */}
|
||||
<div
|
||||
<h1
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '6px',
|
||||
backgroundColor: '#6DC947',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top: Logo */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
color: '#18181B',
|
||||
fontSize: page.data.title.length > 40 ? '48px' : '56px',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: '-0.025em',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={logoSrc} alt="Documenso" height="28" />
|
||||
<span
|
||||
{page.data.title}
|
||||
</h1>
|
||||
{page.data.description && (
|
||||
<p
|
||||
style={{
|
||||
color: '#D4D4D8',
|
||||
fontSize: '28px',
|
||||
color: '#71717A',
|
||||
fontSize: '22px',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
|
|
||||
</span>
|
||||
<span style={{ color: '#71717A', fontSize: '20px', fontWeight: 400 }}>Docs</span>
|
||||
</div>
|
||||
|
||||
{/* Middle: Title + description */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
color: '#18181B',
|
||||
fontSize: page.data.title.length > 40 ? '48px' : '56px',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: '-0.025em',
|
||||
lineHeight: 1.4,
|
||||
margin: 0,
|
||||
maxWidth: '900px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{page.data.title}
|
||||
</h1>
|
||||
{page.data.description && (
|
||||
<p
|
||||
style={{
|
||||
color: '#71717A',
|
||||
fontSize: '22px',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.4,
|
||||
margin: 0,
|
||||
maxWidth: '900px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{page.data.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: URL */}
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ color: '#A1A1AA', fontSize: '16px', fontWeight: 400 }}>
|
||||
docs.documenso.com{page.url}
|
||||
</span>
|
||||
</div>
|
||||
{page.data.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
{/* Bottom: URL */}
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ color: '#A1A1AA', fontSize: '16px', fontWeight: 400 }}>docs.documenso.com{page.url}</span>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover';
|
||||
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button';
|
||||
import { Check, ChevronDown, Copy, ExternalLinkIcon, MessageCircleIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
@@ -21,7 +20,9 @@ export function LLMCopyButton({
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [checked, onClick] = useCopyButton(async () => {
|
||||
const cached = cache.get(markdownUrl);
|
||||
if (cached) return navigator.clipboard.writeText(cached);
|
||||
if (cached) {
|
||||
return navigator.clipboard.writeText(cached);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
@@ -48,7 +49,7 @@ export function LLMCopyButton({
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'sm',
|
||||
className: '[&_svg]:text-fd-muted-foreground gap-2 [&_svg]:size-3.5',
|
||||
className: 'gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
onClick={onClick}
|
||||
@@ -74,8 +75,7 @@ export function ViewOptions({
|
||||
githubUrl: string;
|
||||
}) {
|
||||
const items = useMemo(() => {
|
||||
const fullMarkdownUrl =
|
||||
typeof window !== 'undefined' ? new URL(markdownUrl, window.location.origin) : 'loading';
|
||||
const fullMarkdownUrl = typeof window !== 'undefined' ? new URL(markdownUrl, window.location.origin) : 'loading';
|
||||
const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`;
|
||||
|
||||
return [
|
||||
@@ -96,12 +96,7 @@ export function ViewOptions({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
@@ -113,12 +108,7 @@ export function ViewOptions({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Anthropic</title>
|
||||
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
|
||||
</svg>
|
||||
@@ -146,7 +136,7 @@ export function ViewOptions({
|
||||
)}
|
||||
>
|
||||
Open
|
||||
<ChevronDown className="text-fd-muted-foreground size-3.5" />
|
||||
<ChevronDown className="size-3.5 text-fd-muted-foreground" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
@@ -155,11 +145,11 @@ export function ViewOptions({
|
||||
href={item.href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="hover:text-fd-accent-foreground hover:bg-fd-accent inline-flex items-center gap-2 rounded-lg p-2 text-sm [&_svg]:size-4"
|
||||
className="inline-flex items-center gap-2 rounded-lg p-2 text-sm hover:bg-fd-accent hover:text-fd-accent-foreground [&_svg]:size-4"
|
||||
>
|
||||
{item.icon}
|
||||
{item.title}
|
||||
<ExternalLinkIcon className="text-fd-muted-foreground ms-auto size-3.5" />
|
||||
<ExternalLinkIcon className="ms-auto size-3.5 text-fd-muted-foreground" />
|
||||
</a>
|
||||
))}
|
||||
</PopoverContent>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
|
||||
export const Mermaid = ({ chart }: { chart: string }) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { docs } from 'fumadocs-mdx:collections/server';
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
import { type InferPageType, loader } from 'fumadocs-core/source';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
import { docs } from 'fumadocs-mdx:collections/server';
|
||||
|
||||
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||
export const source = loader({
|
||||
@@ -30,9 +30,7 @@ export function getFilteredPageTree(rootName: string): PageTree.Root {
|
||||
// Find the main section folder
|
||||
const rootFolder = fullTree.children.find(
|
||||
(child): child is PageTree.Folder =>
|
||||
child.type === 'folder' &&
|
||||
typeof child.name === 'string' &&
|
||||
child.name.toLowerCase() === rootName.toLowerCase(),
|
||||
child.type === 'folder' && typeof child.name === 'string' && child.name.toLowerCase() === rootName.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!rootFolder) {
|
||||
@@ -42,9 +40,7 @@ export function getFilteredPageTree(rootName: string): PageTree.Root {
|
||||
// Find shared section folders
|
||||
const sharedFolders = fullTree.children.filter(
|
||||
(child): child is PageTree.Folder =>
|
||||
child.type === 'folder' &&
|
||||
typeof child.name === 'string' &&
|
||||
SHARED_SECTIONS.includes(child.name.toLowerCase()),
|
||||
child.type === 'folder' && typeof child.name === 'string' && SHARED_SECTIONS.includes(child.name.toLowerCase()),
|
||||
);
|
||||
|
||||
// Create separator for main section
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Mermaid } from '@/components/mdx/mermaid';
|
||||
import * as TabsComponents from 'fumadocs-ui/components/tabs';
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { Mermaid } from '@/components/mdx/mermaid';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function getMDXComponents(components?: MDXComponents): any {
|
||||
|
||||
+6
-22
@@ -2,11 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -20,12 +16,8 @@
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"fumadocs-mdx:collections/*": [
|
||||
".source/*"
|
||||
]
|
||||
"@/*": ["./src/*"],
|
||||
"fumadocs-mdx:collections/*": [".source/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@@ -33,14 +25,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -10,10 +10,7 @@ export type TransformedData = {
|
||||
|
||||
const FORMAT = 'MMM yyyy';
|
||||
|
||||
export const addZeroMonth = (
|
||||
transformedData: TransformedData,
|
||||
isCumulative = false,
|
||||
): TransformedData => {
|
||||
export const addZeroMonth = (transformedData: TransformedData, isCumulative = false): TransformedData => {
|
||||
const result: TransformedData = {
|
||||
labels: [...transformedData.labels],
|
||||
datasets: transformedData.datasets.map((dataset) => ({
|
||||
|
||||
@@ -60,7 +60,9 @@ async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginF
|
||||
const reqOrigin = req.headers.get('Origin') || undefined;
|
||||
const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin;
|
||||
|
||||
if (!value) return;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
return getOriginHeaders(reqOrigin, value);
|
||||
}
|
||||
|
||||
@@ -85,12 +87,17 @@ export default async function cors(req: Request, res: Response, options?: CorsOp
|
||||
const { headers } = res;
|
||||
const originHeaders = await originHeadersFromReq(req, opts.origin ?? false);
|
||||
const mergeHeaders = (v: string, k: string) => {
|
||||
if (k === 'Vary') headers.append(k, v);
|
||||
else headers.set(k, v);
|
||||
if (k === 'Vary') {
|
||||
headers.append(k, v);
|
||||
} else {
|
||||
headers.set(k, v);
|
||||
}
|
||||
};
|
||||
|
||||
// If there's no origin we won't touch the response
|
||||
if (!originHeaders) return res;
|
||||
if (!originHeaders) {
|
||||
return res;
|
||||
}
|
||||
|
||||
originHeaders.forEach(mergeHeaders);
|
||||
|
||||
@@ -98,9 +105,7 @@ export default async function cors(req: Request, res: Response, options?: CorsOp
|
||||
headers.set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
const exposed = Array.isArray(opts.exposedHeaders)
|
||||
? opts.exposedHeaders.join(',')
|
||||
: opts.exposedHeaders;
|
||||
const exposed = Array.isArray(opts.exposedHeaders) ? opts.exposedHeaders.join(',') : opts.exposedHeaders;
|
||||
|
||||
if (exposed) {
|
||||
headers.set('Access-Control-Expose-Headers', exposed);
|
||||
@@ -120,7 +125,9 @@ export default async function cors(req: Request, res: Response, options?: CorsOp
|
||||
headers.set('Access-Control-Max-Age', String(opts.maxAge));
|
||||
}
|
||||
|
||||
if (opts.preflightContinue) return res;
|
||||
if (opts.preflightContinue) {
|
||||
return res;
|
||||
}
|
||||
|
||||
headers.set('Content-Length', '0');
|
||||
return new Response(null, { status: opts.optionsSuccessStatus, headers });
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
import { addZeroMonth } from '../add-zero-month';
|
||||
|
||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
@@ -30,9 +29,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
|
||||
datasets: [
|
||||
{
|
||||
label: type === 'count' ? 'Completed Documents per Month' : 'Total Completed Documents',
|
||||
data: result
|
||||
.map((row) => (type === 'count' ? Number(row.count) : Number(row.cume_count)))
|
||||
.reverse(),
|
||||
data: result.map((row) => (type === 'count' ? Number(row.count) : Number(row.cume_count))).reverse(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -40,6 +37,4 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
|
||||
return addZeroMonth(transformedData, type === 'cumulative');
|
||||
};
|
||||
|
||||
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
||||
ReturnType<typeof getCompletedDocumentsMonthly>
|
||||
>;
|
||||
export type GetCompletedDocumentsMonthlyResult = Awaited<ReturnType<typeof getCompletedDocumentsMonthly>>;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { addZeroMonth } from '../add-zero-month';
|
||||
|
||||
@@ -29,9 +28,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
|
||||
datasets: [
|
||||
{
|
||||
label: type === 'count' ? 'Signers That Signed Up' : 'Total Signers That Signed Up',
|
||||
data: result
|
||||
.map((row) => (type === 'count' ? Number(row.count) : Number(row.cume_count)))
|
||||
.reverse(),
|
||||
data: result.map((row) => (type === 'count' ? Number(row.count) : Number(row.cume_count))).reverse(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -39,6 +36,4 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
|
||||
return addZeroMonth(transformedData, type === 'cumulative');
|
||||
};
|
||||
|
||||
export type GetSignerConversionMonthlyResult = Awaited<
|
||||
ReturnType<typeof getSignerConversionMonthly>
|
||||
>;
|
||||
export type GetSignerConversionMonthlyResult = Awaited<ReturnType<typeof getSignerConversionMonthly>>;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { addZeroMonth } from '../add-zero-month';
|
||||
|
||||
@@ -26,9 +25,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
|
||||
datasets: [
|
||||
{
|
||||
label: type === 'count' ? 'New Users' : 'Total Users',
|
||||
data: result
|
||||
.map((row) => (type === 'count' ? Number(row.count) : Number(row.cume_count)))
|
||||
.reverse(),
|
||||
data: result.map((row) => (type === 'count' ? Number(row.count) : Number(row.cume_count))).reverse(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { type TransformedData, addZeroMonth } from './add-zero-month';
|
||||
import { addZeroMonth, type TransformedData } from './add-zero-month';
|
||||
|
||||
type MetricKeys = {
|
||||
stars: number;
|
||||
@@ -24,13 +24,7 @@ const FRIENDLY_METRIC_NAMES: { [key in MetricKey]: string } = {
|
||||
earlyAdopters: 'Customers',
|
||||
};
|
||||
|
||||
export function transformData({
|
||||
data,
|
||||
metric,
|
||||
}: {
|
||||
data: DataEntry;
|
||||
metric: MetricKey;
|
||||
}): TransformedData {
|
||||
export function transformData({ data, metric }: { data: DataEntry; metric: MetricKey }): TransformedData {
|
||||
try {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "15.5.12"
|
||||
"next": "16.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@@ -22,6 +22,6 @@
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
+18
-18
@@ -1,9 +1,9 @@
|
||||
@import '@documenso/ui/styles/theme.css';
|
||||
@import "@documenso/ui/styles/theme.css";
|
||||
|
||||
/* Inter Variable Fonts */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/inter-variablefont_opsz,wght.ttf') format('truetype-variations');
|
||||
font-family: "Inter";
|
||||
src: url("/fonts/inter-variablefont_opsz,wght.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
/* Inter Italic Variable Fonts */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/inter-italic-variablefont_opsz,wght.ttf') format('truetype-variations');
|
||||
font-family: "Inter";
|
||||
src: url("/fonts/inter-italic-variablefont_opsz,wght.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
@@ -20,16 +20,16 @@
|
||||
|
||||
/* Caveat Variable Font */
|
||||
@font-face {
|
||||
font-family: 'Caveat';
|
||||
src: url('/fonts/caveat-variablefont_wght.ttf') format('truetype-variations');
|
||||
font-family: "Caveat";
|
||||
src: url("/fonts/caveat-variablefont_wght.ttf") format("truetype-variations");
|
||||
font-weight: 400 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/noto-sans.ttf') format('truetype-variations');
|
||||
font-family: "Noto Sans";
|
||||
src: url("/fonts/noto-sans.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -37,8 +37,8 @@
|
||||
|
||||
/* Korean noto sans */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Korean';
|
||||
src: url('/fonts/noto-sans-korean.ttf') format('truetype-variations');
|
||||
font-family: "Noto Sans Korean";
|
||||
src: url("/fonts/noto-sans-korean.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -46,8 +46,8 @@
|
||||
|
||||
/* Japanese noto sans */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Japanese';
|
||||
src: url('/fonts/noto-sans-japanese.ttf') format('truetype-variations');
|
||||
font-family: "Noto Sans Japanese";
|
||||
src: url("/fonts/noto-sans-japanese.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -55,8 +55,8 @@
|
||||
|
||||
/* Chinese noto sans */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Chinese';
|
||||
src: url('/fonts/noto-sans-chinese.ttf') format('truetype-variations');
|
||||
font-family: "Noto Sans Chinese";
|
||||
src: url("/fonts/noto-sans-chinese.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -64,8 +64,8 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--font-sans: 'Inter';
|
||||
--font-signature: 'Caveat';
|
||||
--font-noto: 'Noto Sans', 'Noto Sans Korean', 'Noto Sans Japanese', 'Noto Sans Chinese';
|
||||
--font-sans: "Inter";
|
||||
--font-signature: "Caveat";
|
||||
--font-noto: "Noto Sans", "Noto Sans Korean", "Noto Sans Japanese", "Noto Sans Chinese";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -21,6 +15,10 @@ import {
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type AccountDeleteDialogProps = {
|
||||
className?: string;
|
||||
@@ -36,8 +34,7 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
|
||||
|
||||
const [enteredEmail, setEnteredEmail] = useState<string>('');
|
||||
|
||||
const { mutateAsync: deleteAccount, isPending: isDeletingAccount } =
|
||||
trpc.profile.deleteAccount.useMutation();
|
||||
const { mutateAsync: deleteAccount, isPending: isDeletingAccount } = trpc.profile.deleteAccount.useMutation();
|
||||
|
||||
const onDeleteAccount = async () => {
|
||||
try {
|
||||
@@ -63,18 +60,15 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
|
||||
<div>
|
||||
<AlertTitle>
|
||||
<Trans>Delete Account</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Delete your account and all its contents, including completed documents. This action
|
||||
is irreversible and will cancel your subscription, so proceed with caution.
|
||||
Delete your account and all its contents, including completed documents. This action is irreversible and
|
||||
will cancel your subscription, so proceed with caution.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
@@ -109,10 +103,8 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Documenso will delete{' '}
|
||||
<span className="font-semibold">all of your documents</span>, along with all of
|
||||
your completed documents, signatures, and all other resources belonging to your
|
||||
Account.
|
||||
Documenso will delete <span className="font-semibold">all of your documents</span>, along with all
|
||||
of your completed documents, signatures, and all other resources belonging to your Account.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -121,9 +113,7 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
|
||||
<div>
|
||||
<Label>
|
||||
<Trans>
|
||||
Please type{' '}
|
||||
<span className="text-muted-foreground font-semibold">{user.email}</span> to
|
||||
confirm.
|
||||
Please type <span className="font-semibold text-muted-foreground">{user.email}</span> to confirm.
|
||||
</Trans>
|
||||
</Label>
|
||||
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -19,6 +12,11 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export type AdminDocumentDeleteDialogProps = {
|
||||
envelopeId: string;
|
||||
@@ -32,8 +30,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
||||
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } =
|
||||
trpc.admin.document.delete.useMutation();
|
||||
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } = trpc.admin.document.delete.useMutation();
|
||||
|
||||
const handleDeleteDocument = async () => {
|
||||
try {
|
||||
@@ -64,18 +61,13 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
|
||||
<div>
|
||||
<AlertTitle>
|
||||
<Trans>Delete Document</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Delete the document. This action is irreversible so proceed with caution.
|
||||
</Trans>
|
||||
<Trans>Delete the document. This action is irreversible so proceed with caution.</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
@@ -105,12 +97,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
||||
<Trans>To confirm, please enter the reason</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Input className="mt-2" type="text" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/create-admin-organisation.types';
|
||||
@@ -22,16 +12,16 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export type OrganisationCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
@@ -44,11 +34,7 @@ const ZCreateAdminOrganisationFormSchema = ZCreateAdminOrganisationRequestSchema
|
||||
|
||||
type TCreateOrganisationFormSchema = z.infer<typeof ZCreateAdminOrganisationFormSchema>;
|
||||
|
||||
export const AdminOrganisationCreateDialog = ({
|
||||
trigger,
|
||||
ownerUserId,
|
||||
...props
|
||||
}: OrganisationCreateDialogProps) => {
|
||||
export const AdminOrganisationCreateDialog = ({ trigger, ownerUserId, ...props }: OrganisationCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -101,11 +87,7 @@ export const AdminOrganisationCreateDialog = ({
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
@@ -127,10 +109,7 @@ export const AdminOrganisationCreateDialog = ({
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -149,10 +128,7 @@ export const AdminOrganisationCreateDialog = ({
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
You will need to configure any claims or subscription after creating this
|
||||
organisation
|
||||
</Trans>
|
||||
<Trans>You will need to configure any claims or subscription after creating this organisation</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export type AdminOrganisationMemberDeleteDialogProps = {
|
||||
organisationId: string;
|
||||
organisationName: string;
|
||||
organisationMemberId: string;
|
||||
organisationMemberName: string;
|
||||
organisationMemberEmail: string;
|
||||
};
|
||||
|
||||
export const AdminOrganisationMemberDeleteDialog = ({
|
||||
organisationId,
|
||||
organisationName,
|
||||
organisationMemberId,
|
||||
organisationMemberName,
|
||||
organisationMemberEmail,
|
||||
}: AdminOrganisationMemberDeleteDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: deleteOrganisationMember, isPending } = trpc.admin.organisationMember.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Member has been removed from the organisation.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
// Refresh the page to show updated data
|
||||
await navigate(0);
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(msg`We couldn't remove this member. Please try again later.`),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>
|
||||
<Trans>Remove Organisation Member</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are about to remove the following user from the organisation{' '}
|
||||
<span className="font-semibold">{organisationName}</span>:
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Alert className="mt-4" variant="neutral" padding="tight">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={organisationMemberName.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className="font-semibold">{organisationMemberName}</span>}
|
||||
secondaryText={organisationMemberEmail}
|
||||
/>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={isPending}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isPending}
|
||||
onClick={async () =>
|
||||
deleteOrganisationMember({
|
||||
organisationId,
|
||||
organisationMemberId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Remove member</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,3 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
||||
@@ -23,22 +11,18 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type AdminOrganisationMemberUpdateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
@@ -69,9 +53,7 @@ export const AdminOrganisationMemberUpdateDialog = ({
|
||||
// Determine the current role value for the form
|
||||
const currentRoleValue = isOwner
|
||||
? 'OWNER'
|
||||
: getHighestOrganisationRoleInGroup(
|
||||
organisationMember.organisationGroupMembers.map((ogm) => ogm.group),
|
||||
);
|
||||
: getHighestOrganisationRoleInGroup(organisationMember.organisationGroupMembers.map((ogm) => ogm.group));
|
||||
const organisationMemberName = organisationMember.user.name ?? organisationMember.user.email;
|
||||
|
||||
const form = useForm<ZUpdateOrganisationMemberSchema>({
|
||||
@@ -81,8 +63,7 @@ export const AdminOrganisationMemberUpdateDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisationMemberRole } =
|
||||
trpc.admin.organisationMember.updateRole.useMutation();
|
||||
const { mutateAsync: updateOrganisationMemberRole } = trpc.admin.organisationMember.updateRole.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
|
||||
try {
|
||||
@@ -135,11 +116,7 @@ export const AdminOrganisationMemberUpdateDialog = ({
|
||||
}, [open, currentRoleValue, form]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
@@ -156,8 +133,7 @@ export const AdminOrganisationMemberUpdateDialog = ({
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are currently updating <span className="font-bold">{organisationMemberName}</span>
|
||||
.
|
||||
You are currently updating <span className="font-bold">{organisationMemberName}</span>.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
@@ -15,14 +10,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export type AdminSwapSubscriptionDialogProps = {
|
||||
open: boolean;
|
||||
@@ -68,8 +59,7 @@ export const AdminSwapSubscriptionDialog = ({
|
||||
}
|
||||
|
||||
const hasActiveSubscription =
|
||||
org.subscription &&
|
||||
(org.subscription.status === 'ACTIVE' || org.subscription.status === 'PAST_DUE');
|
||||
org.subscription && (org.subscription.status === 'ACTIVE' || org.subscription.status === 'PAST_DUE');
|
||||
|
||||
return !hasActiveSubscription;
|
||||
});
|
||||
@@ -133,15 +123,14 @@ export const AdminSwapSubscriptionDialog = ({
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Move the subscription from "{sourceOrganisationName}" to another organisation owned by
|
||||
this user.
|
||||
Move the subscription from "{sourceOrganisationName}" to another organisation owned by this user.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset className="flex flex-col space-y-4" disabled={isSubmitting}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">
|
||||
<label className="font-medium text-sm">
|
||||
<Trans>Target Organisation</Trans>
|
||||
</label>
|
||||
|
||||
@@ -159,7 +148,7 @@ export const AdminSwapSubscriptionDialog = ({
|
||||
</Select>
|
||||
|
||||
{eligibleOrgs.length === 0 && orgsData && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>No eligible organisations found. The target must be on the free plan.</Trans>
|
||||
</p>
|
||||
)}
|
||||
@@ -169,8 +158,8 @@ export const AdminSwapSubscriptionDialog = ({
|
||||
<Alert variant="warning">
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
This will move the subscription from "{sourceOrganisationName}" to "
|
||||
{selectedOrg.name}". The source organisation will be reset to the free plan.
|
||||
This will move the subscription from "{sourceOrganisationName}" to "{selectedOrg.name}". The source
|
||||
organisation will be reset to the free plan.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -181,12 +170,7 @@ export const AdminSwapSubscriptionDialog = ({
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!selectedOrgId}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
<Button type="button" onClick={onSubmit} disabled={!selectedOrgId} loading={isSubmitting}>
|
||||
<Trans>Move Subscription</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export type AdminTeamMemberDeleteDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
memberEmail: string;
|
||||
};
|
||||
|
||||
export const AdminTeamMemberDeleteDialog = ({
|
||||
teamId,
|
||||
teamName,
|
||||
memberId,
|
||||
memberName,
|
||||
memberEmail,
|
||||
}: AdminTeamMemberDeleteDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: deleteTeamMember, isPending } = trpc.admin.teamMember.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Member has been removed from the team.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
// Refresh the page to show updated data
|
||||
await navigate(0);
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(msg`We couldn't remove this member. Please try again later.`),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>
|
||||
<Trans>Remove Team Member</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are about to remove the following user from the team <span className="font-semibold">{teamName}</span>
|
||||
:
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Alert className="mt-4" variant="neutral" padding="tight">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={memberName.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className="font-semibold">{memberName}</span>}
|
||||
secondaryText={memberEmail}
|
||||
/>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={isPending}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isPending}
|
||||
onClick={async () => deleteTeamMember({ teamId, memberId })}
|
||||
>
|
||||
<Trans>Remove member</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
@@ -22,6 +14,12 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type AdminUserDeleteDialogProps = {
|
||||
className?: string;
|
||||
@@ -34,8 +32,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
|
||||
trpc.admin.user.delete.useMutation();
|
||||
const { mutateAsync: deleteUser, isPending: isDeletingUser } = trpc.admin.user.delete.useMutation();
|
||||
|
||||
const onDeleteAccount = async () => {
|
||||
try {
|
||||
@@ -69,16 +66,13 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
|
||||
<div>
|
||||
<AlertTitle>Delete Account</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Delete the users account and all its contents. This action is irreversible and will
|
||||
cancel their subscription, so proceed with caution.
|
||||
Delete the users account and all its contents. This action is irreversible and will cancel their
|
||||
subscription, so proceed with caution.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
@@ -111,12 +105,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input className="mt-2" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
@@ -21,23 +14,24 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type AdminUserDisableDialogProps = {
|
||||
className?: string;
|
||||
userToDisable: TGetUserResponse;
|
||||
};
|
||||
|
||||
export const AdminUserDisableDialog = ({
|
||||
className,
|
||||
userToDisable,
|
||||
}: AdminUserDisableDialogProps) => {
|
||||
export const AdminUserDisableDialog = ({ className, userToDisable }: AdminUserDisableDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const { mutateAsync: disableUser, isPending: isDisablingUser } =
|
||||
trpc.admin.user.disable.useMutation();
|
||||
const { mutateAsync: disableUser, isPending: isDisablingUser } = trpc.admin.user.disable.useMutation();
|
||||
|
||||
const onDisableAccount = async () => {
|
||||
try {
|
||||
@@ -69,16 +63,13 @@ export const AdminUserDisableDialog = ({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
|
||||
<div>
|
||||
<AlertTitle>Disable Account</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Disabling the user results in the user not being able to use the account. It also
|
||||
disables all the related contents such as subscription, webhooks, teams, and API keys.
|
||||
Disabling the user results in the user not being able to use the account. It also disables all the related
|
||||
contents such as subscription, webhooks, teams, and API keys.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
@@ -100,9 +91,8 @@ export const AdminUserDisableDialog = ({
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>
|
||||
This action is reversible, but please be careful as the account may be
|
||||
affected permanently (e.g. their settings and contents not being restored
|
||||
properly).
|
||||
This action is reversible, but please be careful as the account may be affected permanently (e.g.
|
||||
their settings and contents not being restored properly).
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -116,12 +106,7 @@ export const AdminUserDisableDialog = ({
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input className="mt-2" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
@@ -21,6 +14,11 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type AdminUserEnableDialogProps = {
|
||||
className?: string;
|
||||
@@ -33,8 +31,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const { mutateAsync: enableUser, isPending: isEnablingUser } =
|
||||
trpc.admin.user.enable.useMutation();
|
||||
const { mutateAsync: enableUser, isPending: isEnablingUser } = trpc.admin.user.enable.useMutation();
|
||||
|
||||
const onEnableAccount = async () => {
|
||||
try {
|
||||
@@ -66,16 +63,13 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
|
||||
<div>
|
||||
<AlertTitle>Enable Account</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Enabling the account results in the user being able to use the account again, and all
|
||||
the related features such as webhooks, teams, and API keys for example.
|
||||
Enabling the account results in the user being able to use the account again, and all the related features
|
||||
such as webhooks, teams, and API keys for example.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
@@ -103,20 +97,11 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input className="mt-2" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={onEnableAccount}
|
||||
loading={isEnablingUser}
|
||||
disabled={email !== userToEnable.email}
|
||||
>
|
||||
<Button onClick={onEnableAccount} loading={isEnablingUser} disabled={email !== userToEnable.email}>
|
||||
<Trans>Enable account</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
@@ -22,24 +14,26 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type AdminUserResetTwoFactorDialogProps = {
|
||||
className?: string;
|
||||
user: TGetUserResponse;
|
||||
};
|
||||
|
||||
export const AdminUserResetTwoFactorDialog = ({
|
||||
className,
|
||||
user,
|
||||
}: AdminUserResetTwoFactorDialogProps) => {
|
||||
export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserResetTwoFactorDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const [email, setEmail] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } =
|
||||
trpc.admin.user.resetTwoFactor.useMutation();
|
||||
const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } = trpc.admin.user.resetTwoFactor.useMutation();
|
||||
|
||||
const onResetTwoFactor = async () => {
|
||||
try {
|
||||
@@ -64,9 +58,7 @@ export const AdminUserResetTwoFactorDialog = ({
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
() => msg`You are not authorized to reset two factor authentcation for this user.`,
|
||||
)
|
||||
.otherwise(
|
||||
() => msg`An error occurred while resetting two factor authentication for the user.`,
|
||||
);
|
||||
.otherwise(() => msg`An error occurred while resetting two factor authentication for the user.`);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
@@ -87,16 +79,13 @@ export const AdminUserResetTwoFactorDialog = ({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
|
||||
<div>
|
||||
<AlertTitle>Reset Two Factor Authentication</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Reset the users two factor authentication. This action is irreversible and will
|
||||
disable two factor authentication for the user.
|
||||
Reset the users two factor authentication. This action is irreversible and will disable two factor
|
||||
authentication for the user.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
@@ -119,8 +108,7 @@ export const AdminUserResetTwoFactorDialog = ({
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>
|
||||
This action is irreversible. Please ensure you have informed the user before
|
||||
proceeding.
|
||||
This action is irreversible. Please ensure you have informed the user before proceeding.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -132,12 +120,7 @@ export const AdminUserResetTwoFactorDialog = ({
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input className="mt-2" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@@ -24,11 +15,7 @@ type AiFeaturesEnableDialogProps = {
|
||||
onEnabled: () => void;
|
||||
};
|
||||
|
||||
export const AiFeaturesEnableDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onEnabled,
|
||||
}: AiFeaturesEnableDialogProps) => {
|
||||
export const AiFeaturesEnableDialog = ({ open, onOpenChange, onEnabled }: AiFeaturesEnableDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
@@ -71,11 +58,7 @@ export const AiFeaturesEnableDialog = ({
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to enable AI features', err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t`We couldn't enable AI features right now. Please try again.`,
|
||||
);
|
||||
setError(err instanceof Error ? err.message : t`We couldn't enable AI features right now. Please try again.`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,39 +72,38 @@ export const AiFeaturesEnableDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Turn on AI detection to automatically find recipients and fields in your documents. AI
|
||||
providers do not retain your data for training.
|
||||
Turn on AI detection to automatically find recipients and fields in your documents. AI providers do not
|
||||
retain your data for training.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Your document content will be sent securely to our AI provider solely for detection
|
||||
and will not be stored or used for training.
|
||||
Your document content will be sent securely to our AI provider solely for detection and will not be
|
||||
stored or used for training.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{canEnableAiFeatures ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You're an admin. You can enable AI features for this team right away. Everyone on
|
||||
the team will see AI detection once enabled.
|
||||
You're an admin. You can enable AI features for this team right away. Everyone on the team will see AI
|
||||
detection once enabled.
|
||||
</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
AI features are disabled for your team. Please ask your team owner or organisation
|
||||
owner to enable them.
|
||||
AI features are disabled for your team. Please ask your team owner or organisation owner to enable them.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
type DetectFieldsProgressEvent,
|
||||
detectFields,
|
||||
} from '../../../server/api/ai/detect-fields.client';
|
||||
import { AiApiError, type DetectFieldsProgressEvent, detectFields } from '../../../server/api/ai/detect-fields.client';
|
||||
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
|
||||
|
||||
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
|
||||
@@ -171,20 +159,17 @@ export const AiFieldDetectionDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
We'll scan your document to find form fields like signature lines, text inputs,
|
||||
checkboxes, and more. Detected fields will be suggested for you to review.
|
||||
We'll scan your document to find form fields like signature lines, text inputs, checkboxes, and more.
|
||||
Detected fields will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
<Trans>Your document is processed securely using AI services that don't retain your data.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -200,7 +185,7 @@ export const AiFieldDetectionDialog = ({
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Help the AI assign fields to the right recipients.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -231,7 +216,7 @@ export const AiFieldDetectionDialog = ({
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<p className="mt-2 text-muted-foreground/60 text-xs">
|
||||
<Plural
|
||||
value={progress.fieldsDetected}
|
||||
one={
|
||||
@@ -248,7 +233,7 @@ export const AiFieldDetectionDialog = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<p className="mt-2 max-w-[40ch] text-center text-muted-foreground/60 text-xs">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
@@ -278,16 +263,16 @@ export const AiFieldDetectionDialog = ({
|
||||
{detectedFields.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<FormInputIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<p className="mt-4 text-center text-muted-foreground text-sm">
|
||||
<Trans>No fields were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<p className="mt-1 text-center text-muted-foreground/70 text-xs">
|
||||
<Trans>You can add fields manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Plural
|
||||
value={detectedFields.length}
|
||||
one="We found # field in your document."
|
||||
@@ -299,7 +284,7 @@ export const AiFieldDetectionDialog = ({
|
||||
{fieldCountsByType.map(([type, count]) => (
|
||||
<li key={type} className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">{_(FIELD_TYPE_LABELS[type]) || type}</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">{count}</span>
|
||||
<span className="font-medium text-muted-foreground text-sm">{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -314,7 +299,7 @@ export const AiFieldDetectionDialog = ({
|
||||
|
||||
{detectedFields.length > 0 && (
|
||||
<Button type="button" onClick={onAddFields}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<CheckIcon className="mr-2 -ml-1 h-4 w-4" />
|
||||
<Trans>Add fields</Trans>
|
||||
</Button>
|
||||
)}
|
||||
@@ -331,11 +316,11 @@ export const AiFieldDetectionDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Something went wrong while detecting fields.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
{error && <p className="mt-2 text-destructive text-sm">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -358,10 +343,8 @@ export const AiFieldDetectionDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>You've made too many detection requests. Please wait a minute before trying again.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
@@ -146,20 +138,17 @@ export const AiRecipientDetectionDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
We'll scan your document to find signature fields and identify who needs to sign.
|
||||
Detected recipients will be suggested for you to review.
|
||||
We'll scan your document to find signature fields and identify who needs to sign. Detected recipients
|
||||
will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
<Trans>Your document is processed securely using AI services that don't retain your data.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -189,7 +178,7 @@ export const AiRecipientDetectionDialog = ({
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<p className="mt-2 text-muted-foreground/60 text-xs">
|
||||
<Plural
|
||||
value={progress.recipientsDetected}
|
||||
one={
|
||||
@@ -206,7 +195,7 @@ export const AiRecipientDetectionDialog = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<p className="mt-2 max-w-[40ch] text-center text-muted-foreground/60 text-xs">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
@@ -236,16 +225,16 @@ export const AiRecipientDetectionDialog = ({
|
||||
{detectedRecipients.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<UserIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<p className="mt-4 text-center text-muted-foreground text-sm">
|
||||
<Trans>No recipients were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<p className="mt-1 text-center text-muted-foreground/70 text-xs">
|
||||
<Trans>You can add recipients manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Plural
|
||||
value={detectedRecipients.length}
|
||||
one="We found # recipient in your document."
|
||||
@@ -265,13 +254,13 @@ export const AiRecipientDetectionDialog = ({
|
||||
: '?'
|
||||
}
|
||||
primaryText={
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<p className="font-medium text-foreground text-sm">
|
||||
{recipient.name || _(msg`Unknown name`)}
|
||||
</p>
|
||||
}
|
||||
secondaryText={
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="italic text-muted-foreground/70">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<p className="text-muted-foreground/70 italic">
|
||||
{recipient.email || _(msg`No email detected`)}
|
||||
</p>
|
||||
<p>{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
@@ -304,7 +293,7 @@ export const AiRecipientDetectionDialog = ({
|
||||
|
||||
{detectedRecipients.length > 0 && (
|
||||
<Button type="button" onClick={onAddRecipients}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<CheckIcon className="mr-2 -ml-1 h-4 w-4" />
|
||||
<Trans>Add recipients</Trans>
|
||||
</Button>
|
||||
)}
|
||||
@@ -321,11 +310,11 @@ export const AiRecipientDetectionDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Something went wrong while detecting recipients.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
{error && <p className="mt-2 text-destructive text-sm">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -349,10 +338,8 @@ export const AiRecipientDetectionDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>You've made too many detection requests. Please wait a minute before trying again.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -14,15 +8,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
||||
|
||||
@@ -43,7 +35,7 @@ type ConfirmationDialogProps = {
|
||||
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
email: zEmail('Invalid email address'),
|
||||
});
|
||||
|
||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||
@@ -103,9 +95,8 @@ export function AssistantConfirmationDialog({
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Are you sure you want to complete the document? This action cannot be undone.
|
||||
Please ensure that you have completed prefilling all relevant fields before
|
||||
proceeding.
|
||||
Are you sure you want to complete the document? This action cannot be undone. Please ensure that you
|
||||
have completed prefilling all relevant fields before proceeding.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -146,11 +137,7 @@ export function AssistantConfirmationDialog({
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="mt-2"
|
||||
placeholder={t`Enter the next signer's name`}
|
||||
/>
|
||||
<Input {...field} className="mt-2" placeholder={t`Enter the next signer's name`} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -18,6 +13,9 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
||||
|
||||
@@ -75,12 +73,7 @@ export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
|
||||
licenseFlags={licenseFlags}
|
||||
formSubmitTrigger={
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -15,6 +11,8 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type ClaimDeleteDialogProps = {
|
||||
claimId: string;
|
||||
@@ -23,12 +21,7 @@ export type ClaimDeleteDialogProps = {
|
||||
trigger: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ClaimDeleteDialog = ({
|
||||
claimId,
|
||||
claimName,
|
||||
claimLocked,
|
||||
trigger,
|
||||
}: ClaimDeleteDialogProps) => {
|
||||
export const ClaimDeleteDialog = ({ claimId, claimName, claimLocked, trigger }: ClaimDeleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
|
||||
@@ -16,6 +12,8 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
||||
|
||||
@@ -74,12 +72,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
|
||||
licenseFlags={licenseFlags}
|
||||
formSubmitTrigger={
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DocumentDeleteDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
onDelete?: () => Promise<void> | void;
|
||||
status: DocumentStatus;
|
||||
documentTitle: string;
|
||||
canManageDocument: boolean;
|
||||
};
|
||||
|
||||
export const DocumentDeleteDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
onDelete,
|
||||
status,
|
||||
documentTitle,
|
||||
canManageDocument,
|
||||
}: DocumentDeleteDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { refreshLimits } = useLimits();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const deleteMessage = msg`delete`;
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||
|
||||
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
void refreshLimits();
|
||||
|
||||
toast({
|
||||
title: _(msg`Document deleted`),
|
||||
description: _(msg`"${documentTitle}" has been successfully deleted`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await onDelete?.();
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`This document could not be deleted at this time. Please try again.`),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInputValue('');
|
||||
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
||||
}
|
||||
}, [open, status]);
|
||||
|
||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{canManageDocument ? (
|
||||
<Trans>
|
||||
You are about to delete <strong>"{documentTitle}"</strong>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You are about to hide <strong>"{documentTitle}"</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{canManageDocument ? (
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
{match(status)
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||
this document will be permanently deleted.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(DocumentStatus.PENDING, () => (
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1">
|
||||
<Trans>Once confirmed, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>Document will be permanently deleted</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Document signing process will be cancelled</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>All inserted signatures will be voided</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>All recipients will be notified</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>By deleting this document, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>The document will be hidden from your account</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Recipients will still retain their copy of the document</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
<AlertDescription>
|
||||
<Trans>Please contact support if you would like to revert this action.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status !== DocumentStatus.DRAFT && canManageDocument && (
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
placeholder={_(msg`Please type ${`'${_(deleteMessage)}'`} to confirm`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isPending}
|
||||
onClick={() => void deleteDocument({ documentId: id })}
|
||||
disabled={!isDeleteEnabled && canManageDocument}
|
||||
variant="destructive"
|
||||
>
|
||||
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type DocumentDuplicateDialogProps = {
|
||||
id: string;
|
||||
token?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DocumentDuplicateDialog = ({
|
||||
id,
|
||||
token,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DocumentDuplicateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
trpcReact.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: _(msg`Document Duplicated`),
|
||||
description: _(msg`Your document has been successfully duplicated.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(`${documentsPath}/${id}/edit`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateEnvelope({ envelopeId: id });
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`This document could not be duplicated at this time. Please try again.`),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isDuplicating}
|
||||
loading={isDuplicating}
|
||||
onClick={onDuplicate}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trans>Duplicate</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,3 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
@@ -23,16 +11,19 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@@ -165,7 +156,7 @@ export const DocumentMoveToFolderDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
|
||||
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={_(msg`Search folders...`)}
|
||||
value={searchTerm}
|
||||
@@ -219,7 +210,7 @@ export const DocumentMoveToFolderDialog = ({
|
||||
))}
|
||||
|
||||
{searchTerm && filteredFolders?.length === 0 && (
|
||||
<div className="text-muted-foreground px-2 py-2 text-center text-sm">
|
||||
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
|
||||
<Trans>No folders found</Trans>
|
||||
</div>
|
||||
)}
|
||||
@@ -239,9 +230,7 @@ export const DocumentMoveToFolderDialog = ({
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isFoldersLoading || form.formState.isSubmitting || currentFolderId === null
|
||||
}
|
||||
disabled={isFoldersLoading || form.formState.isSubmitting || currentFolderId === null}
|
||||
>
|
||||
<Trans>Move</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
@@ -27,14 +17,17 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { SigningStatus, type Team, type User } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@@ -45,10 +38,10 @@ const FORM_ID = 'resend-email';
|
||||
export type DocumentResendDialogProps = {
|
||||
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
|
||||
export const ZResendDocumentFormSchema = z.object({
|
||||
@@ -141,10 +134,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
{recipients.map((recipient) => (
|
||||
<FormItem
|
||||
key={recipient.id}
|
||||
className="flex flex-row items-center justify-between gap-x-3"
|
||||
>
|
||||
<FormItem key={recipient.id} className="flex flex-row items-center justify-between gap-x-3">
|
||||
<FormLabel
|
||||
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||
'opacity-50': !value.includes(recipient.id),
|
||||
@@ -183,7 +173,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
@@ -22,6 +14,11 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
type EnvelopeDeleteDialogProps = {
|
||||
id: string;
|
||||
@@ -52,13 +49,23 @@ export const EnvelopeDeleteDialog = ({
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||
|
||||
const isDocument = type === EnvelopeType.DOCUMENT;
|
||||
|
||||
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
void refreshLimits();
|
||||
|
||||
toast({
|
||||
title: t`Document deleted`,
|
||||
description: t`"${title}" has been successfully deleted`,
|
||||
title: canManageDocument
|
||||
? isDocument
|
||||
? t`Document deleted`
|
||||
: t`Template deleted`
|
||||
: isDocument
|
||||
? t`Document hidden`
|
||||
: t`Template hidden`,
|
||||
description: canManageDocument
|
||||
? t`"${title}" has been successfully deleted`
|
||||
: t`"${title}" has been successfully hidden`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -69,7 +76,9 @@ export const EnvelopeDeleteDialog = ({
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be deleted at this time. Please try again.`,
|
||||
description: isDocument
|
||||
? t`This document could not be deleted at this time. Please try again.`
|
||||
: t`This template could not be deleted at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
@@ -118,13 +127,13 @@ export const EnvelopeDeleteDialog = ({
|
||||
<AlertDescription>
|
||||
{type === EnvelopeType.DOCUMENT ? (
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||
this document will be permanently deleted.
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed, this document will
|
||||
be permanently deleted.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||
this template will be permanently deleted.
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed, this template will
|
||||
be permanently deleted.
|
||||
</Trans>
|
||||
)}
|
||||
</AlertDescription>
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -31,27 +19,24 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
|
||||
export type EnvelopeDistributeDialogProps = {
|
||||
onDistribute?: () => Promise<void>;
|
||||
@@ -62,16 +47,10 @@ export type EnvelopeDistributeDialogProps = {
|
||||
export const ZEnvelopeDistributeFormSchema = z.object({
|
||||
meta: z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
z.string().email().optional(),
|
||||
),
|
||||
emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()),
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
distributionMethod: z
|
||||
.nativeEnum(DocumentDistributionMethod)
|
||||
.optional()
|
||||
.default(DocumentDistributionMethod.EMAIL),
|
||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional().default(DocumentDistributionMethod.EMAIL),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -102,8 +81,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
emailReplyTo: envelope.documentMeta?.emailReplyTo || undefined,
|
||||
subject: envelope.documentMeta?.subject ?? '',
|
||||
message: envelope.documentMeta?.message ?? '',
|
||||
distributionMethod:
|
||||
envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
|
||||
distributionMethod: envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
|
||||
},
|
||||
},
|
||||
resolver: zodResolver(ZEnvelopeDistributeFormSchema),
|
||||
@@ -116,16 +94,15 @@ export const EnvelopeDistributeDialog = ({
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const { data: emailData, isLoading: isLoadingEmails } =
|
||||
trpc.enterprise.organisation.email.find.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
},
|
||||
{
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
const { data: emailData, isLoading: isLoadingEmails } = trpc.enterprise.organisation.email.find.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
},
|
||||
{
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
const emails = emailData?.data || [];
|
||||
|
||||
@@ -155,9 +132,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
return (
|
||||
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email
|
||||
);
|
||||
return (auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email;
|
||||
});
|
||||
}, [recipientsWithIndex, envelope.authOptions]);
|
||||
|
||||
@@ -312,14 +287,9 @@ export const EnvelopeDistributeDialog = ({
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === '-1' ? null : value)
|
||||
}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
loading={isLoadingEmails}
|
||||
className="bg-background"
|
||||
>
|
||||
<SelectTrigger loading={isLoadingEmails} className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -348,8 +318,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Reply To Email{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
Reply To Email <span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
|
||||
@@ -369,8 +338,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Subject{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
Subject <span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
|
||||
@@ -389,8 +357,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>
|
||||
Message{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
Message <span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
@@ -424,15 +391,15 @@ export const EnvelopeDistributeDialog = ({
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
className="min-h-60 rounded-lg border"
|
||||
>
|
||||
<div className="py-24 text-center text-sm text-muted-foreground">
|
||||
<div className="py-24 text-center text-muted-foreground text-sm">
|
||||
<p>
|
||||
<Trans>We won't send anything to notify recipients.</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-2">
|
||||
<Trans>
|
||||
We will generate signing links for you, which you can send to the
|
||||
recipients through your method of choice.
|
||||
We will generate signing links for you, which you can send to the recipients through your
|
||||
method of choice.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -472,7 +439,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
<AlertDescription>
|
||||
<Trans>The following signers are missing signature fields:</Trans>
|
||||
|
||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||
<ul className="mt-1 ml-2 list-inside list-disc">
|
||||
{recipientsMissingSignatureFields.map((recipient) => (
|
||||
<li key={recipient.id}>
|
||||
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
|
||||
@@ -485,7 +452,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
<AlertDescription>
|
||||
<Trans>The following recipients require an email address:</Trans>
|
||||
|
||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||
<ul className="mt-1 ml-2 list-inside list-disc">
|
||||
{recipientsMissingRequiredEmail.map((recipient) => (
|
||||
<li key={recipient.id}>
|
||||
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -18,12 +11,29 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
|
||||
|
||||
type EnvelopeDownloadDialogProps = {
|
||||
envelopeId: string;
|
||||
envelopeStatus: DocumentStatus;
|
||||
|
||||
/**
|
||||
* Whether the envelope is a legacy (v1) envelope. Only consulted to gate the
|
||||
* partial-download variant: legacy envelopes use a different field-rendering
|
||||
* pipeline that the partial PDF helper does not implement, so the Partial
|
||||
* button is hidden for them.
|
||||
*
|
||||
* Optional: omit it on call sites where the status can never be PENDING (DRAFT,
|
||||
* COMPLETED, REJECTED) or when a recipient token is set, since the Partial button
|
||||
* is also gated on those. Pass it from team-side call sites that can render the
|
||||
* dialog for a PENDING envelope.
|
||||
*/
|
||||
isLegacy?: boolean;
|
||||
envelopeItems?: EnvelopeItemToDownload[];
|
||||
|
||||
/**
|
||||
@@ -38,6 +48,7 @@ type EnvelopeDownloadDialogProps = {
|
||||
export const EnvelopeDownloadDialog = ({
|
||||
envelopeId,
|
||||
envelopeStatus,
|
||||
isLegacy,
|
||||
envelopeItems: initialEnvelopeItems,
|
||||
token,
|
||||
trigger,
|
||||
@@ -51,27 +62,49 @@ export const EnvelopeDownloadDialog = ({
|
||||
[envelopeItemIdAndVersion: string]: boolean;
|
||||
}>({});
|
||||
|
||||
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
|
||||
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed' | 'pending') =>
|
||||
`${envelopeItemId}-${version}`;
|
||||
|
||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||
trpc.envelope.item.getManyByToken.useQuery(
|
||||
{
|
||||
envelopeId,
|
||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||
},
|
||||
{
|
||||
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
// The dialog shows the original document alongside one of:
|
||||
// - "Signed" (when the envelope is COMPLETED)
|
||||
// - "Partial" (when the envelope is PENDING, not legacy, and we are on the
|
||||
// team/owner side; recipients are intentionally not offered this since the
|
||||
// partial PDF carries no PKI signature and would create a leak vector for
|
||||
// half-executed contracts; legacy envelopes use a different rendering
|
||||
// pipeline that the partial-download helper does not implement)
|
||||
// - nothing (DRAFT, REJECTED, PENDING with recipient token, or legacy PENDING)
|
||||
const secondaryDownload = useMemo<{ version: 'signed' | 'pending'; label: string } | null>(() => {
|
||||
if (envelopeStatus === DocumentStatus.COMPLETED) {
|
||||
return {
|
||||
version: 'signed',
|
||||
label: t({ message: 'Signed', context: 'Signed document (adjective)' }),
|
||||
};
|
||||
}
|
||||
|
||||
if (envelopeStatus === DocumentStatus.PENDING && !token && !isLegacy) {
|
||||
return {
|
||||
version: 'pending',
|
||||
label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [envelopeStatus, isLegacy, token, t]);
|
||||
|
||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getManyByToken.useQuery(
|
||||
{
|
||||
envelopeId,
|
||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||
},
|
||||
{
|
||||
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||
|
||||
const onDownload = async (
|
||||
envelopeItem: EnvelopeItemToDownload,
|
||||
version: 'original' | 'signed',
|
||||
) => {
|
||||
const onDownload = async (envelopeItem: EnvelopeItemToDownload, version: 'original' | 'signed' | 'pending') => {
|
||||
const { id: envelopeItemId } = envelopeItem;
|
||||
|
||||
if (isDownloadingState[generateDownloadKey(envelopeItemId, version)]) {
|
||||
@@ -127,13 +160,9 @@ export const EnvelopeDownloadDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex w-full flex-col gap-4 overflow-hidden">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<>
|
||||
{Array.from({ length: 1 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
||||
>
|
||||
{isLoadingEnvelopeItems
|
||||
? Array.from({ length: 1 }).map((_, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded-lg border border-border bg-card p-4">
|
||||
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
|
||||
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
@@ -143,62 +172,59 @@ export const EnvelopeDownloadDialog = ({
|
||||
|
||||
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
envelopeItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<FileTextIcon className="text-primary h-5 w-5" />
|
||||
))
|
||||
: envelopeItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileTextIcon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Todo: Envelopes - Fix overflow */}
|
||||
<h4 className="text-foreground truncate text-sm font-medium" title={item.title}>
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
<Trans>PDF Document</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Todo: Envelopes - Fix overflow */}
|
||||
<h4 className="truncate font-medium text-foreground text-sm" title={item.title}>
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
<Trans>PDF Document</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={async () => onDownload(item, 'original')}
|
||||
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
|
||||
>
|
||||
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans context="Original document (adjective)">Original</Trans>
|
||||
</Button>
|
||||
|
||||
{envelopeStatus === DocumentStatus.COMPLETED && (
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={async () => onDownload(item, 'signed')}
|
||||
loading={isDownloadingState[generateDownloadKey(item.id, 'signed')]}
|
||||
onClick={async () => onDownload(item, 'original')}
|
||||
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
|
||||
>
|
||||
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
|
||||
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans context="Signed document (adjective)">Signed</Trans>
|
||||
<Trans context="Original document (adjective)">Original</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{secondaryDownload && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={async () => onDownload(item, secondaryDownload.version)}
|
||||
loading={isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)]}
|
||||
>
|
||||
{!isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)] && (
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{secondaryDownload.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -18,6 +12,10 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@@ -27,11 +25,7 @@ type EnvelopeDuplicateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeDuplicateDialog = ({
|
||||
envelopeId,
|
||||
envelopeType,
|
||||
trigger,
|
||||
}: EnvelopeDuplicateDialogProps) => {
|
||||
export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: EnvelopeDuplicateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -41,24 +35,24 @@ export const EnvelopeDuplicateDialog = ({
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
trpc.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: t`Envelope Duplicated`,
|
||||
description: t`Your envelope has been successfully duplicated.`,
|
||||
duration: 5000,
|
||||
});
|
||||
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
|
||||
|
||||
const path =
|
||||
envelopeType === EnvelopeType.DOCUMENT
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = trpc.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: isDocument ? t`Document Duplicated` : t`Template Duplicated`,
|
||||
description: isDocument
|
||||
? t`Your document has been successfully duplicated.`
|
||||
: t`Your template has been successfully duplicated.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(`${path}/${id}/edit`);
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
const path = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${path}/${id}/edit`);
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
@@ -66,7 +60,9 @@ export const EnvelopeDuplicateDialog = ({
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be duplicated at this time. Please try again.`,
|
||||
description: isDocument
|
||||
? t`This document could not be duplicated at this time. Please try again.`
|
||||
: t`This template could not be duplicated at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
@@ -78,30 +74,25 @@ export const EnvelopeDuplicateDialog = ({
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
|
||||
<DialogContent>
|
||||
{envelopeType === EnvelopeType.DOCUMENT ? (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate Document</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isDocument ? (
|
||||
<Trans>This document will be duplicated.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
) : (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate Template</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
) : (
|
||||
<Trans>This template will be duplicated.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" disabled={isDuplicating}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isDuplicating}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" loading={isDuplicating} onClick={onDuplicate}>
|
||||
<Trans>Duplicate</Trans>
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -16,6 +11,8 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type EnvelopeItemDeleteDialogProps = {
|
||||
canItemBeDeleted: boolean;
|
||||
@@ -39,28 +36,27 @@ export const EnvelopeItemDeleteDialog = ({
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteEnvelopeItem, isPending: isDeleting } =
|
||||
trpc.envelope.item.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`You have successfully removed this envelope item.`,
|
||||
duration: 5000,
|
||||
});
|
||||
const { mutateAsync: deleteEnvelopeItem, isPending: isDeleting } = trpc.envelope.item.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`You have successfully removed this envelope item.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onDelete?.(envelopeItemId);
|
||||
onDelete?.(envelopeItemId);
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to remove this envelope item. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to remove this envelope item. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||
@@ -74,16 +70,12 @@ export const EnvelopeItemDeleteDialog = ({
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are about to remove the following document and all associated fields
|
||||
</Trans>
|
||||
<Trans>You are about to remove the following document and all associated fields</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription className="text-center font-semibold">
|
||||
{envelopeItemTitle}
|
||||
</AlertDescription>
|
||||
<AlertDescription className="text-center font-semibold">{envelopeItemTitle}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<fieldset disabled={isDeleting}>
|
||||
@@ -116,9 +108,7 @@ export const EnvelopeItemDeleteDialog = ({
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You cannot delete this item because the document has been sent to recipients.
|
||||
</Trans>
|
||||
<Trans>You cannot delete this item because the document has been sent to recipients.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
|
||||
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
|
||||
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { AlertTriangleIcon, FileIcon, UploadIcon, XIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZEditEnvelopeItemFormSchema = z.object({
|
||||
title: ZDocumentTitleSchema,
|
||||
});
|
||||
|
||||
type TEditEnvelopeItemFormSchema = z.infer<typeof ZEditEnvelopeItemFormSchema>;
|
||||
|
||||
/**
|
||||
* Note: This should only be visible if the envelope item is editable.
|
||||
*/
|
||||
export type EnvelopeItemEditDialogProps = {
|
||||
envelopeItem: { id: string; title: string };
|
||||
allowConfigureTitle: boolean;
|
||||
trigger: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopeItemEditDialog = ({
|
||||
envelopeItem,
|
||||
allowConfigureTitle,
|
||||
trigger,
|
||||
...props
|
||||
}: EnvelopeItemEditDialogProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { envelope, editorFields, setLocalEnvelope, isEmbedded } = useCurrentEnvelopeEditor();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [replacementFile, setReplacementFile] = useState<{ file: File; pageCount: number } | null>(null);
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
|
||||
const form = useForm<TEditEnvelopeItemFormSchema>({
|
||||
resolver: zodResolver(ZEditEnvelopeItemFormSchema),
|
||||
defaultValues: {
|
||||
title: envelopeItem.title,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
|
||||
onSuccess: ({ data, fields }) => {
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
item.id === data.id ? { ...item, documentDataId: data.documentDataId, title: data.title } : item,
|
||||
),
|
||||
});
|
||||
|
||||
if (fields) {
|
||||
setLocalEnvelope({ fields });
|
||||
editorFields.resetForm(fields);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const fieldsOnExcessPages =
|
||||
replacementFile !== null
|
||||
? envelope.fields.filter(
|
||||
(field) => field.envelopeItemId === envelopeItem.id && field.page > replacementFile.pageCount,
|
||||
)
|
||||
: [];
|
||||
|
||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||
toast({
|
||||
title: t`Upload failed`,
|
||||
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
|
||||
if (!file || isDropping) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDropping(true);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const fileData = new Uint8Array(arrayBuffer.slice(0));
|
||||
const { PDF } = await import('@libpdf/core');
|
||||
const pdfDoc = await PDF.load(fileData);
|
||||
|
||||
setReplacementFile({
|
||||
file,
|
||||
pageCount: pdfDoc.getPageCount(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: t`Failed to read file`,
|
||||
description: t`The file is not a valid PDF.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setIsDropping(false);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: { 'application/pdf': ['.pdf'] },
|
||||
maxFiles: 1,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
disabled: form.formState.isSubmitting,
|
||||
onDrop: (files) => void onFileDrop(files),
|
||||
onDropRejected: onFileDropRejected,
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TEditEnvelopeItemFormSchema) => {
|
||||
if (isDropping || !replacementFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { file, pageCount } = replacementFile;
|
||||
|
||||
if (isEmbedded) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const fileData = new Uint8Array(arrayBuffer.slice(0));
|
||||
|
||||
const remainingFields = envelope.fields.filter(
|
||||
(field) => field.envelopeItemId !== envelopeItem.id || field.page <= pageCount,
|
||||
);
|
||||
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
item.id === envelopeItem.id ? { ...item, title: data.title, data: fileData } : item,
|
||||
),
|
||||
fields: remainingFields,
|
||||
});
|
||||
|
||||
editorFields.resetForm(remainingFields);
|
||||
} else {
|
||||
const payload = {
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
title: data.title,
|
||||
} satisfies TReplaceEnvelopeItemPdfPayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('file', file);
|
||||
|
||||
await replaceEnvelopeItemPdf(formData);
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Failed to update item`,
|
||||
description: t`Something went wrong while updating the envelope item.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset({ title: envelopeItem.title });
|
||||
setReplacementFile(null);
|
||||
setIsDropping(false);
|
||||
}
|
||||
}, [isOpen, form, envelopeItem.title]);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={isOpen} onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Edit Item</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Update the title or replace the PDF file.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Document Title</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-testid="envelope-item-edit-title-input"
|
||||
placeholder={t`Document Title`}
|
||||
disabled={!allowConfigureTitle}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
<Trans>Replace PDF</Trans>
|
||||
</FormLabel>
|
||||
|
||||
{replacementFile ? (
|
||||
<div className="mt-1.5 space-y-2">
|
||||
<div
|
||||
data-testid="envelope-item-edit-selected-file"
|
||||
className="flex items-center justify-between rounded-md border border-border bg-muted/50 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center space-x-2">
|
||||
<FileIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-sm">{replacementFile.file.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatFileSize(replacementFile.file.size)}
|
||||
{isDropping ? ' · …' : ' · '}
|
||||
{!isDropping && replacementFile.pageCount !== null && (
|
||||
<Plural one="1 page" other="# pages" value={replacementFile.pageCount} />
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="envelope-item-edit-clear-file"
|
||||
onClick={() => {
|
||||
setReplacementFile(null);
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fieldsOnExcessPages.length > 0 && (
|
||||
<Alert variant="warning" padding="tight">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertDescription data-testid="envelope-item-edit-field-warning">
|
||||
<Plural
|
||||
one="1 field will be deleted because the new PDF has fewer pages than the current one."
|
||||
other="# fields will be deleted because the new PDF has fewer pages than the current one."
|
||||
value={fieldsOnExcessPages.length}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="envelope-item-edit-dropzone"
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'mt-1.5 flex cursor-pointer items-center justify-center rounded-md border border-border border-dashed px-4 py-4 transition-colors',
|
||||
isDragActive
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'hover:border-muted-foreground/50 hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex items-center space-x-2 text-muted-foreground text-sm">
|
||||
<UploadIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Drop PDF here or click to select</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={isDropping || !replacementFile}
|
||||
data-testid="envelope-item-edit-update-button"
|
||||
>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType, type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -25,20 +16,21 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { StackAvatar } from '../general/stack-avatar';
|
||||
|
||||
export type EnvelopeRedistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
recipients: TEnvelopeRecipientLite[];
|
||||
};
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
@@ -51,10 +43,7 @@ export const ZEnvelopeRedistributeFormSchema = z.object({
|
||||
|
||||
export type TEnvelopeRedistributeFormSchema = z.infer<typeof ZEnvelopeRedistributeFormSchema>;
|
||||
|
||||
export const EnvelopeRedistributeDialog = ({
|
||||
envelope,
|
||||
trigger,
|
||||
}: EnvelopeRedistributeDialogProps) => {
|
||||
export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedistributeDialogProps) => {
|
||||
const recipients = envelope.recipients;
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type EnvelopeRenameDialogProps = {
|
||||
id: string;
|
||||
initialTitle: string;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
onSuccess?: () => Promise<void>;
|
||||
envelopeType?: 'document' | 'template';
|
||||
};
|
||||
|
||||
export const EnvelopeRenameDialog = ({
|
||||
id,
|
||||
initialTitle,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
envelopeType = 'document',
|
||||
}: EnvelopeRenameDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle(initialTitle);
|
||||
}
|
||||
}, [open, initialTitle]);
|
||||
|
||||
const isTemplate = envelopeType === 'template';
|
||||
|
||||
const { mutate: updateEnvelope, isPending } = trpcReact.envelope.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await onSuccess?.();
|
||||
|
||||
toast({
|
||||
title: isTemplate ? t`Template Renamed` : t`Document Renamed`,
|
||||
description: isTemplate
|
||||
? t`Your template has been successfully renamed.`
|
||||
: t`Your document has been successfully renamed.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
description: t`Something went wrong. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
|
||||
const onRename = () => {
|
||||
if (!trimmedTitle || trimmedTitle === initialTitle) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
updateEnvelope({
|
||||
envelopeId: id,
|
||||
data: {
|
||||
title: trimmedTitle,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isTemplate ? <Trans>Rename Template</Trans> : <Trans>Rename Document</Trans>}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2">
|
||||
<Label htmlFor="title" className="sr-only">
|
||||
<Trans>Title</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
placeholder={t`Enter a new title`}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={isPending}
|
||||
maxLength={DOCUMENT_TITLE_MAX_LENGTH}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isPending || !trimmedTitle || trimmedTitle === initialTitle}
|
||||
loading={isPending}
|
||||
onClick={() => void onRename()}
|
||||
>
|
||||
<Trans>Rename</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type EnvelopeSaveAsTemplateDialogProps = {
|
||||
envelopeId: string;
|
||||
trigger?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnvelopeSaveAsTemplateDialog = ({
|
||||
envelopeId,
|
||||
trigger,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: EnvelopeSaveAsTemplateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const templatesPath = formatTemplatesPath(team.url);
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
includeRecipients: true,
|
||||
includeFields: true,
|
||||
},
|
||||
});
|
||||
|
||||
const includeRecipients = form.watch('includeRecipients');
|
||||
|
||||
const { mutateAsync: saveAsTemplate, isPending } = trpc.envelope.saveAsTemplate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: t`Template Created`,
|
||||
description: t`Your document has been saved as a template.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(`${templatesPath}/${id}/edit`);
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { includeRecipients, includeFields } = form.getValues();
|
||||
|
||||
try {
|
||||
await saveAsTemplate({
|
||||
envelopeId,
|
||||
includeRecipients,
|
||||
includeFields: includeRecipients && includeFields,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be saved as a template at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(value);
|
||||
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Save as Template</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Create a template from this document.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="includeRecipients"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="envelopeIncludeRecipients"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked === true);
|
||||
|
||||
if (!checked) {
|
||||
form.setValue('includeFields', false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="envelopeIncludeRecipients">
|
||||
<Trans>Include Recipients</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="includeFields"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="envelopeIncludeFields"
|
||||
checked={field.value}
|
||||
disabled={!includeRecipients}
|
||||
onCheckedChange={(checked) => field.onChange(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="envelopeIncludeFields" className={!includeRecipients ? 'opacity-50' : ''}>
|
||||
<Trans>Include Fields</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" loading={isPending} onClick={onSubmit}>
|
||||
<Trans>Save as Template</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,3 @@
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Plural, useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -16,6 +10,10 @@ import {
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
export type EnvelopesBulkDeleteDialogProps = {
|
||||
envelopeIds: string[];
|
||||
@@ -88,9 +86,7 @@ export const EnvelopesBulkDeleteDialog = ({
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isDocument ? <Trans>Delete Documents</Trans> : <Trans>Delete Templates</Trans>}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{isDocument ? <Trans>Delete Documents</Trans> : <Trans>Delete Templates</Trans>}</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{isDocument ? (
|
||||
@@ -149,12 +145,7 @@ export const EnvelopesBulkDeleteDialog = ({
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Plural, useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -21,16 +9,18 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type EnvelopesBulkMoveDialogProps = {
|
||||
envelopeIds: string[];
|
||||
@@ -119,10 +109,7 @@ export const EnvelopesBulkMoveDialog = ({
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with(
|
||||
AppErrorCode.NOT_FOUND,
|
||||
() => t`The folder you are trying to move the items to does not exist.`,
|
||||
)
|
||||
.with(AppErrorCode.NOT_FOUND, () => t`The folder you are trying to move the items to does not exist.`)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => t`You are not allowed to move these items.`)
|
||||
.with(AppErrorCode.INVALID_BODY, () => t`All items must be of the same type.`)
|
||||
.otherwise(() => t`An error occurred while moving the items.`);
|
||||
@@ -143,11 +130,7 @@ export const EnvelopesBulkMoveDialog = ({
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isDocument ? (
|
||||
<Trans>Move Documents to Folder</Trans>
|
||||
) : (
|
||||
<Trans>Move Templates to Folder</Trans>
|
||||
)}
|
||||
{isDocument ? <Trans>Move Documents to Folder</Trans> : <Trans>Move Templates to Folder</Trans>}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
@@ -168,7 +151,7 @@ export const EnvelopesBulkMoveDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t`Search folders...`}
|
||||
value={searchTerm}
|
||||
@@ -222,7 +205,7 @@ export const EnvelopesBulkMoveDialog = ({
|
||||
))}
|
||||
|
||||
{searchTerm && filteredFolders?.length === 0 && (
|
||||
<div className="px-2 py-2 text-center text-sm text-muted-foreground">
|
||||
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
|
||||
<Trans>No folders found</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { FolderType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderPlusIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -20,16 +9,18 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { FolderType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderPlusIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZCreateFolderFormSchema = z.object({
|
||||
name: z.string().min(1, { message: 'Folder name is required' }),
|
||||
@@ -40,13 +31,16 @@ type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||
export type FolderCreateDialogProps = {
|
||||
type: FolderType;
|
||||
trigger?: React.ReactNode;
|
||||
parentFolderId?: string | null;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
|
||||
export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }: FolderCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const parentId = parentFolderId ?? folderId;
|
||||
|
||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
||||
@@ -62,7 +56,7 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
|
||||
try {
|
||||
await createFolder({
|
||||
name: data.name,
|
||||
parentId: folderId,
|
||||
parentId,
|
||||
type,
|
||||
});
|
||||
|
||||
@@ -90,11 +84,7 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
|
||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center"
|
||||
data-testid="folder-create-button"
|
||||
>
|
||||
<Button variant="outline" className="flex items-center" data-testid="folder-create-button">
|
||||
<FolderPlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Create Folder</Trans>
|
||||
</Button>
|
||||
@@ -131,11 +121,7 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setIsCreateFolderOpen(false)}
|
||||
>
|
||||
<Button type="button" variant="secondary" onClick={() => setIsCreateFolderOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
@@ -20,16 +11,15 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type FolderDeleteDialogProps = {
|
||||
folder: TFolderWithSubfolders;
|
||||
@@ -110,14 +100,12 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{(folder._count.documents > 0 ||
|
||||
folder._count.templates > 0 ||
|
||||
folder._count.subfolders > 0) && (
|
||||
{(folder._count.documents > 0 || folder._count.templates > 0 || folder._count.subfolders > 0) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
This folder contains multiple items. Deleting it will remove all subfolders and move
|
||||
all nested documents and templates to the root folder.
|
||||
This folder contains multiple items. Deleting it will remove all subfolders and move all nested
|
||||
documents and templates to the root folder.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -134,9 +122,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing:{' '}
|
||||
<span className="font-sm text-destructive font-semibold">
|
||||
{deleteMessage}
|
||||
</span>
|
||||
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user