mirror of
https://github.com/documenso/documenso.git
synced 2026-07-10 21:15:15 +10:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6b797a6ce | |||
| 1b1e3d197b | |||
| a276e18e1f | |||
| 50f272be87 | |||
| a55e6d9484 | |||
| d35d13db23 | |||
| 337f85f021 | |||
| 2332b0316b | |||
| 393b51d484 | |||
| 5a8335e0eb |
@@ -1,382 +0,0 @@
|
||||
---
|
||||
date: 2026-06-29
|
||||
title: Document Template Tags
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Users currently organise documents and templates using **folders** (a hierarchical, single-parent structure). There is no way to apply **flat, cross-cutting labels** (tags) to envelopes — e.g. "urgent", "invoice", "contract", "HR". This makes it hard to filter, group, and find documents/templates across folder boundaries.
|
||||
|
||||
Tags complement folders: a folder answers "where is this?", a tag answers "what kind is this?". An envelope can be in exactly one folder but should be able to carry many tags.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Allow users to create, update, and delete tags scoped to their team.
|
||||
2. Allow users to assign one or more tags to a document or template.
|
||||
3. Allow users to filter the documents and templates list pages by tag(s).
|
||||
4. Display tags on document/template table rows and detail pages.
|
||||
5. Expose tag management and filtering through tRPC routes (and optionally the V1 public API).
|
||||
6. Follow the existing **folder** feature as the architectural blueprint so the codebase stays consistent.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
These decisions are informed by the existing `Folder` feature and codebase conventions:
|
||||
|
||||
1. **Tags are team-scoped** — Just like `Folder`, every `Tag` belongs to a `teamId` and a `userId` (creator). This matches `buildTeamWhereQuery` access patterns used throughout the lib layer.
|
||||
|
||||
2. **Tags are type-specific (DOCUMENT / TEMPLATE)** — Following the `FolderType` enum pattern (`DOCUMENT` / `TEMPLATE`). A `TagType` enum mirrors this so document tags and template tags are managed separately, consistent with how folders are split (`documents.folders._index.tsx` vs `templates.folders._index.tsx`). *Alternative considered: shared tags with no type — rejected for consistency with folders and to keep the UI clean.*
|
||||
|
||||
3. **Many-to-many via a join table (`EnvelopeTag`)** — Unlike folders (single `folderId` on `Envelope`), an envelope can have many tags and a tag can be on many envelopes. A join table is required.
|
||||
|
||||
4. **Optional `color` field** — A nullable hex color string (`#RRGGBB`) for visual distinction in the UI. Optional so it's not a breaking concern if omitted.
|
||||
|
||||
5. **Unique constraint `(teamId, name, type)`** — Prevents duplicate tag names within a team for a given type. Names are case-insensitive-normalised on write.
|
||||
|
||||
6. **Filtering semantics: OR (any of)** — When filtering by multiple tags, return envelopes that have **any** of the selected tags. This is the most common tag-filter UX. *Alternative considered: AND (must have all) — noted as a future toggle if needed.*
|
||||
|
||||
7. **Assigning tags replaces the full set** — The `setEnvelopeTags` operation takes an array of tag IDs and sets the envelope's tags to exactly that set (add/remove diff). This is simpler and less error-prone than individual add/remove operations and matches how form state typically works.
|
||||
|
||||
8. **Inline tag creation during assignment** — The assignment UI allows creating a new tag on-the-fly (autocomplete + create), similar to common tag-input UX. The `setEnvelopeTags` route will accept either an existing `tagId` or a `{ name, color }` to create-and-assign in one step.
|
||||
|
||||
9. **`deletedAt` is not needed for tags** — Unlike envelopes, tags are lightweight metadata. Deleting a tag cascades to remove the join-table rows (`onDelete: Cascade`). Envelopes are unaffected.
|
||||
|
||||
10. **V1 public API is a separate, optional phase** — Tag CRUD + filtering is added to tRPC first. V1 API endpoints can follow the same pattern used for folders (`packages/api/v1/`) but are deferred to a later phase to keep this change focused.
|
||||
|
||||
## Scope
|
||||
|
||||
This plan touches four layers: Prisma schema, lib (server-only), tRPC, and the Remix UI. New files are created for the tag lib functions and tag-router; existing files are extended for filtering and display.
|
||||
|
||||
| Layer | New files | Modified files |
|
||||
| ----- | --------- | -------------- |
|
||||
| Prisma | schema migration | `packages/prisma/schema.prisma` |
|
||||
| Lib | `packages/lib/server-only/tag/*` (6 files) | `find-documents.ts`, `find-templates.ts` |
|
||||
| tRPC | `packages/trpc/server/tag-router/*` (2 files), root router registration | `document-router/find-documents.types.ts`, `template-router` find types, root `trpc.ts` |
|
||||
| UI | tag primitives + filter component | documents/templates list pages, tables, edit pages |
|
||||
| V1 API (deferred) | — | — |
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Database Schema — `packages/prisma/schema.prisma`
|
||||
|
||||
Add a `TagType` enum and `Tag` model, plus an `EnvelopeTag` join table:
|
||||
|
||||
```prisma
|
||||
enum TagType {
|
||||
DOCUMENT
|
||||
TEMPLATE
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
color String?
|
||||
type TagType
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
teamId Int
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
envelopes EnvelopeTag[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@unique([teamId, name, type])
|
||||
@@index([teamId])
|
||||
@@index([teamId, type])
|
||||
}
|
||||
|
||||
model EnvelopeTag {
|
||||
envelopeId String
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
tagId String
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
assignedBy Int
|
||||
assignedByUser User @relation(fields: [assignedBy], references: [id], onDelete: Cascade)
|
||||
assignedAt DateTime @default(now())
|
||||
|
||||
@@id([envelopeId, tagId])
|
||||
@@index([tagId])
|
||||
}
|
||||
```
|
||||
|
||||
Add the back-relations to `Envelope` and `User` and `Team`:
|
||||
|
||||
```prisma
|
||||
// On Envelope model, add:
|
||||
tags EnvelopeTag[]
|
||||
|
||||
// On User model, add:
|
||||
tags Tag[]
|
||||
envelopeTags EnvelopeTag[]
|
||||
|
||||
// On Team model, add:
|
||||
tags Tag[]
|
||||
```
|
||||
|
||||
**Migration:** Run `npx prisma migrate dev --name add_document_template_tags` (or the project's equivalent migration command). The migration creates both tables, indexes, and the unique constraint.
|
||||
|
||||
### 2. Lib Layer — `packages/lib/server-only/tag/`
|
||||
|
||||
Create a new `tag/` directory mirroring the `folder/` directory structure. Each file follows the existing pattern: import `prisma`, use `AppError`/`AppErrorCode`, use `buildTeamWhereQuery` for team access, and `getTeamById` / `getMemberRoles` for role-based visibility.
|
||||
|
||||
#### `create-tag.ts`
|
||||
```ts
|
||||
export interface CreateTagOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
name: string;
|
||||
color?: string;
|
||||
type: TTagType; // DOCUMENT | TEMPLATE
|
||||
}
|
||||
```
|
||||
- Validates team access via `getTeamSettings`.
|
||||
- Normalises `name` (trim, collapse whitespace).
|
||||
- Throws `AppError(AppErrorCode.CONFLICT)` on duplicate `(teamId, name, type)` (caught from Prisma unique violation or pre-checked).
|
||||
- Returns the created `Tag`.
|
||||
|
||||
#### `find-tags.ts`
|
||||
```ts
|
||||
export interface FindTagsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
type?: TTagType;
|
||||
query?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
```
|
||||
- Paginated list of tags for a team, optionally filtered by type and a name search query.
|
||||
- Uses `buildTeamWhereQuery` for access control.
|
||||
- Returns `FindResultResponse<Tag>`.
|
||||
|
||||
#### `update-tag.ts`
|
||||
```ts
|
||||
export interface UpdateTagOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
tagId: string;
|
||||
data: { name?: string; color?: string | null };
|
||||
}
|
||||
```
|
||||
- Verifies tag belongs to the user's team.
|
||||
- Updates name and/or color.
|
||||
- Re-validates uniqueness if name changes.
|
||||
|
||||
#### `delete-tag.ts`
|
||||
```ts
|
||||
export interface DeleteTagOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
tagId: string;
|
||||
}
|
||||
```
|
||||
- Verifies ownership/team access.
|
||||
- `prisma.tag.delete` — cascades to `EnvelopeTag` rows automatically.
|
||||
|
||||
#### `set-envelope-tags.ts`
|
||||
```ts
|
||||
export interface SetEnvelopeTagsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
envelopeId: string;
|
||||
tagIds: string[];
|
||||
}
|
||||
```
|
||||
- Fetches the envelope, verifies access (team + visibility, same checks as folder operations).
|
||||
- Verifies all `tagIds` belong to the same team and match the envelope's type (DOCUMENT tags for documents, TEMPLATE tags for templates).
|
||||
- Uses a Prisma transaction to diff: delete `EnvelopeTag` rows not in the new set, insert missing ones.
|
||||
- Returns the updated list of tags on the envelope.
|
||||
|
||||
#### `get-envelope-tags.ts`
|
||||
```ts
|
||||
export interface GetEnvelopeTagsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
envelopeId: string;
|
||||
}
|
||||
```
|
||||
- Returns all tags assigned to an envelope (with access check).
|
||||
|
||||
#### `types/tag-type.ts` — `packages/lib/types/tag-type.ts`
|
||||
```ts
|
||||
import { z } from 'zod';
|
||||
export const ZTagTypeSchema = z.enum(['DOCUMENT', 'TEMPLATE']);
|
||||
export type TTagType = z.infer<typeof ZTagTypeSchema>;
|
||||
```
|
||||
(Mirrors `packages/lib/types/folder-type.ts`.)
|
||||
|
||||
### 3. Filtering — Modify `find-documents.ts` and `find-templates.ts`
|
||||
|
||||
#### `find-documents.ts` (Kysely-based)
|
||||
- Add `tagIds?: string[]` to `FindDocumentsOptions`.
|
||||
- When `tagIds` is non-empty, add an `EXISTS` subquery to the WHERE clause:
|
||||
```ts
|
||||
eb.exists(
|
||||
eb.selectFrom('EnvelopeTag')
|
||||
.whereRef('EnvelopeTag.envelopeId', '=', 'Envelope.id')
|
||||
.where('EnvelopeTag.tagId', 'in', sql.join(tagIds.map(sql.lit)))
|
||||
.select(sql.lit(1).as('one'))
|
||||
)
|
||||
```
|
||||
This implements **OR (any of)** semantics — the envelope matches if it has at least one of the selected tags.
|
||||
- Hydration step (`prisma.envelope.findMany`) should `include: { tags: { include: { tag: true } } }` so tags are returned with each document.
|
||||
|
||||
#### `find-templates.ts` (Prisma-based)
|
||||
- Add `tagIds?: string[]` to `FindTemplatesOptions`.
|
||||
- Add to the `where` clause:
|
||||
```ts
|
||||
tagIds?.length ? { tags: { some: { tagId: { in: tagIds } } } } : undefined
|
||||
```
|
||||
- Add `tags: { include: { tag: true } }` to `templateInclude`.
|
||||
|
||||
### 4. tRPC Layer — `packages/trpc/server/tag-router/`
|
||||
|
||||
Create `tag-router/router.ts` and `tag-router/schema.ts`, mirroring `folder-router/`.
|
||||
|
||||
#### `tag-router/schema.ts`
|
||||
```ts
|
||||
import TagSchema from '@documenso/prisma/generated/zod/modelSchema/TagSchema';
|
||||
import { ZTagTypeSchema } from '@documenso/lib/types/tag-type';
|
||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
|
||||
export const ZTagSchema = TagSchema.pick({
|
||||
id: true, name: true, color: true, type: true,
|
||||
teamId: true, userId: true, createdAt: true, updatedAt: true,
|
||||
});
|
||||
|
||||
export const ZCreateTagRequestSchema = z.object({
|
||||
name: z.string().min(1).max(50),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
||||
type: ZTagTypeSchema,
|
||||
});
|
||||
|
||||
export const ZCreateTagResponseSchema = ZTagSchema;
|
||||
|
||||
export const ZUpdateTagRequestSchema = z.object({
|
||||
tagId: z.string(),
|
||||
data: z.object({
|
||||
name: z.string().min(1).max(50).optional(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDeleteTagRequestSchema = z.object({ tagId: z.string() });
|
||||
|
||||
export const ZFindTagsRequestSchema = ZFindSearchParamsSchema.extend({
|
||||
type: ZTagTypeSchema.optional(),
|
||||
query: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZFindTagsResponseSchema = ZFindResultResponse.extend({
|
||||
data: z.array(ZTagSchema),
|
||||
});
|
||||
|
||||
export const ZSetEnvelopeTagsRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
tagIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const ZSetEnvelopeTagsResponseSchema = z.array(ZTagSchema);
|
||||
```
|
||||
|
||||
#### `tag-router/router.ts`
|
||||
Routes following the folder-router conventions (`authenticatedProcedure`, OpenAPI meta with `tags: ['Tag']`, GET/POST only):
|
||||
|
||||
| Route name | Method | Path | Description |
|
||||
| ---------- | ------ | ---- | ----------- |
|
||||
| `findTags` | GET | `/tag` | Find tags for the current team |
|
||||
| `createTag` | POST | `/tag/create` | Create a new tag |
|
||||
| `updateTag` | POST | `/tag/update` | Update a tag's name/color |
|
||||
| `deleteTag` | POST | `/tag/delete` | Delete a tag (cascades to assignments) |
|
||||
| `setEnvelopeTags` | POST | `/tag/assign` | Set the full tag set on an envelope |
|
||||
|
||||
Each route deconstructs `input` on its own line, uses `ctx.teamId` and `ctx.user.id`, and logs via `ctx.logger.info`.
|
||||
|
||||
#### Register the router
|
||||
In the root tRPC router file (where `folderRouter` is registered), add:
|
||||
```ts
|
||||
import { tagRouter } from './tag-router/router';
|
||||
// ...
|
||||
tag: tagRouter,
|
||||
```
|
||||
|
||||
#### Update find-documents / find-templates request schemas
|
||||
- `document-router/find-documents.types.ts`: add `tagIds: z.array(z.string()).optional()` to `ZFindDocumentsRequestSchema`.
|
||||
- `template-router` find types: add `tagIds: z.array(z.string()).optional()`.
|
||||
- `document-router/find-documents-internal.types.ts`: add `tagIds` so the UI can filter.
|
||||
|
||||
### 5. UI Layer — Remix app
|
||||
|
||||
#### New primitives (`packages/ui/primitives/tag/`)
|
||||
- `tag-badge.tsx` — a small coloured pill displaying a tag name (uses `color` if present, falls back to a default Tailwind colour).
|
||||
- `tag-input.tsx` — an autocomplete multi-select tag input with inline creation (used in edit pages and dialogs). Renders existing tags as removable badges; typing filters suggestions; pressing Enter or selecting creates/assigns. Uses the existing `Command` (cmdk) primitive if available, otherwise a Popover + input pattern.
|
||||
- `tag-filter.tsx` — a multi-select dropdown for the list pages that adds `tagIds` to the URL search params.
|
||||
|
||||
#### Documents list page — `t.$teamUrl+/documents._index.tsx`
|
||||
- Add `tagIds` to `ZSearchParamsSchema` (parse from comma-separated query string).
|
||||
- Pass `tagIds` into the `findDocuments` / `findDocumentsInternal` tRPC query.
|
||||
- Render `<TagFilter type="DOCUMENT" />` in the filter toolbar alongside the period selector and search.
|
||||
- Fetch tags via `trpc.tag.findTags.useQuery({ type: 'DOCUMENT' })` for the filter options.
|
||||
|
||||
#### Templates list page — `t.$teamUrl+/templates._index.tsx`
|
||||
- Same changes as documents, with `type: 'TEMPLATE'`.
|
||||
|
||||
#### Tables — `documents-table.tsx` and `templates-table.tsx`
|
||||
- Add a "Tags" column that renders `<TagBadge>` for each tag on the row.
|
||||
- The row data already includes `tags` (from the hydration `include` added in step 3).
|
||||
|
||||
#### Document/template detail pages
|
||||
- `documents.$id._index.tsx` / `templates.$id._index.tsx`: display assigned tags as badges.
|
||||
- `documents.$id.edit.tsx` / `templates.$id.edit.tsx`: add a `<TagInput>` field that calls `trpc.tag.setEnvelopeTags.mutate` on change.
|
||||
|
||||
#### Tag management
|
||||
- A lightweight management UI (rename/delete tags) can be added to team settings or as a small popover from the `<TagFilter>`. This can be a minimal first iteration (create via the tag-input inline, delete/rename via a settings page later).
|
||||
|
||||
### 6. V1 Public API (Deferred — Phase 2)
|
||||
|
||||
Once the tRPC + UI layer is stable, add to `packages/api/v1/`:
|
||||
- `GET /api/v1/tags` — list tags (with `type` query param).
|
||||
- `POST /api/v1/tags` — create tag.
|
||||
- `POST /api/v1/tags/assign` — assign tags to an envelope.
|
||||
- Add `tags` array to the document/template response schemas.
|
||||
- Add `tagIds` filter to `GET /api/v1/documents`.
|
||||
|
||||
This mirrors exactly how folder support was added to the V1 API (see the `wild-teal-wind-add-folder-support-to-v1-api` plan).
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Prisma schema + migration** — add models, run migration, regenerate client (`zod-prisma-types` will generate `TagSchema`).
|
||||
2. **Lib layer** — create `tag/` functions; extend `find-documents.ts` and `find-templates.ts` with `tagIds` filter + hydration.
|
||||
3. **tRPC layer** — create `tag-router`, register it, extend find request schemas.
|
||||
4. **UI primitives** — `TagBadge`, `TagInput`, `TagFilter`.
|
||||
5. **List pages** — wire up `TagFilter` + tags column on documents and templates.
|
||||
6. **Detail/edit pages** — display + assign tags.
|
||||
7. **E2E tests** — tag CRUD, assign, filter.
|
||||
8. *(Deferred)* V1 API endpoints.
|
||||
|
||||
## Testing
|
||||
|
||||
E2E tests in `packages/app-tests` following existing Playwright patterns:
|
||||
|
||||
1. Create a tag from the documents tag filter — verify it appears in the list.
|
||||
2. Assign tags to a document from the edit page — verify badges appear on the table row.
|
||||
3. Filter documents by a tag — verify only tagged documents show.
|
||||
4. Filter by multiple tags (OR) — verify union of results.
|
||||
5. Delete a tag — verify it's removed from all assigned documents.
|
||||
6. Repeat 1–5 for templates.
|
||||
7. Verify team scoping — tags from team A are not visible in team B.
|
||||
8. Verify a DOCUMENT tag cannot be assigned to a TEMPLATE and vice versa.
|
||||
|
||||
## Migration & Compatibility Notes
|
||||
|
||||
- The new `Tag` and `EnvelopeTag` tables are purely additive — no existing columns are modified or removed.
|
||||
- The `tags` relation added to `Envelope` is additive; existing queries that don't `include` it are unaffected.
|
||||
- The `tagIds` filter is optional in all find functions; when omitted, behaviour is identical to today.
|
||||
- No breaking changes to existing tRPC routes or V1 API responses (tags are added as new optional fields).
|
||||
- The `zod-prisma-types` generator (already configured in `schema.prisma`) will auto-generate `TagSchema` for use in `tag-router/schema.ts`, same as `FolderSchema` is used in `folder-router/schema.ts`.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Tag name uniqueness scope** — Should uniqueness be `(teamId, name, type)` (case-insensitive) or should we allow duplicates and dedupe in the UI? **Recommendation:** enforce in DB (decision #5).
|
||||
2. **Max tags per envelope** — Should there be a limit (e.g. 10)? **Recommendation:** no hard limit initially; can add a limit later.
|
||||
3. **Bulk tag assignment** — Should the bulk action bar on the documents page support "add tag to all selected"? **Recommendation:** yes, as a follow-up; the `setEnvelopeTags` lib function can be extended to accept multiple envelope IDs, or a separate `bulkAssignTags` function can be added (mirroring `bulk-move-envelopes`).
|
||||
4. **Tag colors** — Should we ship a fixed palette picker or allow free-form hex? **Recommendation:** fixed palette of ~8 colours for v1, stored as hex in the `color` column.
|
||||
@@ -74,5 +74,3 @@ tmp/
|
||||
|
||||
# opencode
|
||||
.opencode/package-lock.json
|
||||
|
||||
SUPPORT_KNOWLEDGE_BASE.md
|
||||
|
||||
@@ -11,9 +11,14 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
|
||||
## HTTP Rate Limits
|
||||
|
||||
**Limit:** 100 requests per minute per IP address
|
||||
**Limit:** 1000 requests per minute per IP address
|
||||
**Response:** 429 Too Many Requests
|
||||
|
||||
<Callout type="info">
|
||||
This is the global per-IP ceiling. Your organisation may have its own rate limits configured below
|
||||
this value, in which case you can be rate-limited before reaching the global limit.
|
||||
</Callout>
|
||||
|
||||
### Rate Limit Response
|
||||
|
||||
```json
|
||||
|
||||
@@ -472,7 +472,7 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
|
||||
<code>distributeDocument: true</code>
|
||||
</Step>
|
||||
<Step>
|
||||
Process in batches with a short delay to respect rate limits (e.g. 100 requests/minute)
|
||||
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -638,8 +638,8 @@ done
|
||||
</Tabs>
|
||||
|
||||
<Callout type="info">
|
||||
The API allows 100 requests per minute. For large batches, implement rate limiting with delays
|
||||
between requests to avoid hitting limits.
|
||||
The API allows 1000 requests per minute (your organisation may have its own lower limit). For large
|
||||
batches, implement rate limiting with delays between requests to avoid hitting limits.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
@@ -483,7 +483,7 @@ The API returns standard HTTP status codes and JSON error responses:
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
The API allows 100 requests per minute per IP address. When rate limited, wait at least 60 seconds before retrying:
|
||||
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
|
||||
|
||||
```javascript
|
||||
async function fetchWithRetry(url, options, maxRetries = 3) {
|
||||
|
||||
@@ -76,6 +76,8 @@ The Enterprise Edition is required when you:
|
||||
4. Restart your Documenso instance
|
||||
5. Verify the license is active in the **Admin Panel** under the **Stats** section
|
||||
|
||||
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
|
||||
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
@@ -197,7 +199,7 @@ See [Support](/docs/policies/support) for complete support options.
|
||||
1. Sign the Enterprise license agreement
|
||||
2. Receive license key and access credentials
|
||||
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
|
||||
4. Configure Enterprise features with support assistance
|
||||
4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
@@ -238,6 +240,7 @@ See [Support](/docs/policies/support) for complete support options.
|
||||
|
||||
## Related
|
||||
|
||||
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
|
||||
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
|
||||
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
|
||||
- [Support](/docs/policies/support) - Support channels and response times
|
||||
|
||||
@@ -41,12 +41,17 @@ When a limit is reached, requests return a `429 Too Many Requests` response with
|
||||
|
||||
| Action | Limit | Window |
|
||||
| --- | --- | --- |
|
||||
| API requests (v1 and v2) | 100 requests | 1 minute |
|
||||
| API requests (v1 and v2) | 1000 requests | 1 minute |
|
||||
| File uploads | 20 requests | 1 minute |
|
||||
| AI features | 3 requests | 1 minute |
|
||||
|
||||
Authentication endpoints (login, signup, password reset, etc.) are also rate-limited to protect against abuse.
|
||||
|
||||
<Callout type="info">
|
||||
The API request limit above is the global per-IP ceiling. Individual organisations also have their
|
||||
own rate limits, which may be configured below this value.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Rate limits may vary by plan. Enterprise plans can include higher or custom limits. Contact
|
||||
[sales](https://documen.so/sales) for details.
|
||||
|
||||
@@ -443,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
|
||||
|
||||
## Enterprise Features
|
||||
|
||||
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
|
||||
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------ | ------------------------------------------------ |
|
||||
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
|
||||
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it |
|
||||
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
|
||||
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
|
||||
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: Apply Your License Key
|
||||
description: Activate your Enterprise license key to unlock enterprise features on your self-hosted instance.
|
||||
---
|
||||
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
A license key activates the Enterprise features available to your self-hosted instance, such as CSC signing, SSO, embed white-labelling, and 21 CFR Part 11 compliance.
|
||||
|
||||
<Callout type="info">
|
||||
The license key applies to your **whole instance**, not an individual user account. There's one
|
||||
key per deployment.
|
||||
</Callout>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An active Enterprise license key — contact [sales](https://documen.so/enterprise) to set up an
|
||||
Enterprise subscription, then copy your key from [license.documenso.com](https://license.documenso.com).
|
||||
See [Enterprise Edition](/docs/policies/enterprise-edition) for details.
|
||||
- A running self-hosted Documenso instance that you're able to restart
|
||||
|
||||
## Step 1: Set the environment variable
|
||||
|
||||
Set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` to your license key.
|
||||
|
||||
<Tabs items={['Docker Compose', 'docker run', '.env']}>
|
||||
<Tab value="Docker Compose">
|
||||
|
||||
Add the variable to your `.env` file (or directly under `environment:` in `compose.yml`):
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
|
||||
```
|
||||
|
||||
Then apply it:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab value="docker run">
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name documenso \
|
||||
-e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \
|
||||
documenso/documenso:latest
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab value=".env">
|
||||
|
||||
If you're running Documenso directly (not in a container), add the variable to your `.env` file:
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Step 2: Restart the instance
|
||||
|
||||
The license key is only read once, at process startup. Setting the variable in a running container or shell has no effect until the process restarts.
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose restart documenso
|
||||
|
||||
# Docker
|
||||
docker restart documenso
|
||||
```
|
||||
|
||||
On startup, Documenso validates the key against the Documenso license server and caches the result locally for future startups, so a brief license-server outage won't lock you out.
|
||||
|
||||
## What the license enables
|
||||
|
||||
A valid license doesn't turn every enterprise feature on everywhere — activation depends on the feature:
|
||||
|
||||
- **CSC signing** activates instance-wide automatically once the license is active and CSC transport is configured. See [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup.
|
||||
- **SSO, embed white-labelling, 21 CFR Part 11, and similar** are provisioned per organisation. Follow each feature's own guide to configure it once the license is active.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<Accordions type="multiple">
|
||||
<Accordion title="Enterprise features are still unavailable after applying the key">
|
||||
- Confirm the key is present in the environment the running process actually reads — `docker
|
||||
exec` into the container and check `env | grep LICENSE` if unsure.
|
||||
- Confirm the instance was fully restarted after the variable was set, not just reloaded.
|
||||
- Re-copy the key to rule out truncation or accidental whitespace.
|
||||
</Accordion>
|
||||
<Accordion title="A specific feature still isn't working">
|
||||
Instance-wide features (like CSC signing) also need their own configuration — an active license
|
||||
alone isn't enough. Check that feature's guide to confirm the required settings are in place.
|
||||
Per-organisation features additionally need to be provisioned for the organisation that's using
|
||||
them.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
## See Also
|
||||
|
||||
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
|
||||
- [Enterprise Edition](/docs/policies/enterprise-edition) - What's included and how to purchase a license
|
||||
- [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) - Enable CSC-based signing
|
||||
@@ -2,6 +2,7 @@
|
||||
"title": "Configuration",
|
||||
"pages": [
|
||||
"environment",
|
||||
"license",
|
||||
"database",
|
||||
"email",
|
||||
"storage",
|
||||
|
||||
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
|
||||
|
||||
### Enterprise Edition license
|
||||
|
||||
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
|
||||
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
|
||||
|
||||
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
|
||||
|
||||
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
|
||||
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -23,7 +24,7 @@ import { useParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZCreateFolderFormSchema = z.object({
|
||||
name: z.string().min(1, { message: 'Folder name is required' }),
|
||||
name: ZNameSchema,
|
||||
});
|
||||
|
||||
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||
@@ -65,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
|
||||
toast({
|
||||
description: t`Folder created successfully`,
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: t`Failed to create folder`,
|
||||
description: t`An unknown error occurred while creating the folder.`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -23,8 +24,6 @@ import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type FolderUpdateDialogProps = {
|
||||
folder: TFolderWithSubfolders | null;
|
||||
isOpen: boolean;
|
||||
@@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = {
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const ZUpdateFolderFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
name: ZNameSchema,
|
||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
||||
});
|
||||
|
||||
@@ -40,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
||||
|
||||
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -25,14 +26,13 @@ import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type PasskeyCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
onSuccess?: () => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZCreatePasskeyFormSchema = z.object({
|
||||
passkeyName: z.string().min(3),
|
||||
passkeyName: ZNameSchema,
|
||||
});
|
||||
|
||||
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,16 +20,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export type TeamEmailUpdateDialogProps = {
|
||||
teamEmail: TeamEmail;
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZUpdateTeamEmailFormSchema = z.object({
|
||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||
});
|
||||
const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
|
||||
data: true,
|
||||
}).shape.data;
|
||||
|
||||
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
||||
|
||||
@@ -44,6 +45,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
|
||||
defaultValues: {
|
||||
name: teamEmail.name,
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
BRANDING_LOGO_ALLOWED_TYPES,
|
||||
BRANDING_LOGO_MAX_SIZE_BYTES,
|
||||
BRANDING_LOGO_MAX_SIZE_MB,
|
||||
} from '@documenso/lib/constants/branding';
|
||||
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -21,15 +26,17 @@ import { z } from 'zod';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||
|
||||
const ZBrandingPreferencesFormSchema = z.object({
|
||||
brandingEnabled: z.boolean().nullable(),
|
||||
brandingLogo: z
|
||||
.instanceof(File)
|
||||
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
|
||||
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
|
||||
.refine(
|
||||
(file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES,
|
||||
`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
|
||||
)
|
||||
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
|
||||
.nullish(),
|
||||
brandingUrl: z.string().url().optional().or(z.literal('')),
|
||||
brandingCompanyDetails: z.string().max(500).optional(),
|
||||
@@ -71,38 +78,82 @@ export function BrandingPreferencesForm({
|
||||
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
|
||||
const initialColors = parsedColors.success ? parsedColors.data : {};
|
||||
|
||||
// The saved state the form maps to. Used both as the reactive `values` source and as
|
||||
// the explicit target for a Reset (see handleReset).
|
||||
const savedValues: TBrandingPreferencesFormSchema = {
|
||||
brandingEnabled: settings.brandingEnabled ?? null,
|
||||
brandingUrl: settings.brandingUrl ?? '',
|
||||
brandingLogo: undefined,
|
||||
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
|
||||
brandingColors: initialColors,
|
||||
brandingCss: settings.brandingCss ?? '',
|
||||
};
|
||||
|
||||
const form = useForm<TBrandingPreferencesFormSchema>({
|
||||
values: {
|
||||
brandingEnabled: settings.brandingEnabled ?? null,
|
||||
brandingUrl: settings.brandingUrl ?? '',
|
||||
brandingLogo: undefined,
|
||||
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
|
||||
brandingColors: initialColors,
|
||||
brandingCss: settings.brandingCss ?? '',
|
||||
},
|
||||
values: savedValues,
|
||||
resolver: zodResolver(ZBrandingPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const isBrandingEnabled = form.watch('brandingEnabled');
|
||||
|
||||
const getSavedLogoPreviewUrl = () => {
|
||||
if (!settings.brandingLogo) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const file = JSON.parse(settings.brandingLogo);
|
||||
|
||||
if (!('type' in file) || !('data' in file)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const logoUrl =
|
||||
context === 'Team'
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
||||
|
||||
return `${logoUrl}?v=${Date.now()}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.brandingLogo) {
|
||||
const file = JSON.parse(settings.brandingLogo);
|
||||
const savedLogoPreviewUrl = getSavedLogoPreviewUrl();
|
||||
|
||||
if ('type' in file && 'data' in file) {
|
||||
const logoUrl =
|
||||
context === 'Team'
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
||||
|
||||
setPreviewUrl(logoUrl + '?v=' + Date.now());
|
||||
setHasLoadedPreview(true);
|
||||
}
|
||||
if (savedLogoPreviewUrl) {
|
||||
setPreviewUrl(savedLogoPreviewUrl);
|
||||
}
|
||||
|
||||
setHasLoadedPreview(true);
|
||||
}, [settings.brandingLogo]);
|
||||
|
||||
// Reset the form to the saved values. The form is driven by the `values` prop (no
|
||||
// `defaultValues`), so `reset()` with no argument doesn't re-baseline the dirty check;
|
||||
// passing the saved values clears the per-field dirty tracking (dirtyFields).
|
||||
const handleReset = () => {
|
||||
setPreviewUrl(getSavedLogoPreviewUrl());
|
||||
form.reset(savedValues);
|
||||
};
|
||||
|
||||
// `formState.isDirty` is unreliable for a `values`-driven form: after a reset (or a
|
||||
// save + refetch) it can stay true even though every field already matches its saved
|
||||
// value and `dirtyFields` is empty. Derive the flag from `dirtyFields` instead so the
|
||||
// sticky save bar reliably disappears.
|
||||
const hasUnsavedChanges = Object.keys(form.formState.dirtyFields).length > 0;
|
||||
|
||||
// Re-baseline the form to the just-saved state after a successful submit. The `values`
|
||||
// prop re-syncs most fields once the route refetches, but write-only fields (the logo
|
||||
// is a File that isn't reflected back into `values`) would otherwise stay dirty and
|
||||
// keep the save bar visible. Relies on the page handler rethrowing on error so we only
|
||||
// re-baseline on success.
|
||||
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
await onFormSubmit(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(form.getValues());
|
||||
});
|
||||
|
||||
// Cleanup ObjectURL on unmount or when previewUrl changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -114,7 +165,7 @@ export function BrandingPreferencesForm({
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
<fieldset className="flex h-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -167,7 +218,7 @@ export function BrandingPreferencesForm({
|
||||
/>
|
||||
|
||||
<div className="relative flex w-full flex-col gap-y-4">
|
||||
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
|
||||
{!isBrandingEnabled && <div className="absolute inset-0 z-30 bg-background/60" />}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -199,7 +250,7 @@ export function BrandingPreferencesForm({
|
||||
<FormControl className="relative">
|
||||
<Input
|
||||
type="file"
|
||||
accept={ACCEPTED_FILE_TYPES.join(',')}
|
||||
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
|
||||
disabled={!isBrandingEnabled}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -321,7 +372,7 @@ export function BrandingPreferencesForm({
|
||||
|
||||
{hasAdvancedBranding && (
|
||||
<div className="relative flex w-full flex-col gap-y-6">
|
||||
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
|
||||
{!isBrandingEnabled && <div className="absolute inset-0 z-30 bg-background/60" />}
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
@@ -538,11 +589,11 @@ export function BrandingPreferencesForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<FormStickySaveBar
|
||||
isDirty={hasUnsavedChanges}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { ReminderSettingsPicker } from '@documenso/ui/components/document/remind
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import {
|
||||
Form,
|
||||
@@ -46,6 +45,7 @@ import { z } from 'zod';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||
|
||||
/**
|
||||
* Can't infer this from the schema since we need to keep the schema inside the component to allow
|
||||
@@ -147,9 +147,21 @@ export const DocumentPreferencesForm = ({
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
await onFormSubmit(data);
|
||||
} catch {
|
||||
// The page handler surfaces its own error toast. Keep the form dirty so
|
||||
// the save bar stays visible and the user can retry.
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(data);
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||
{!isPersonalLayoutMode && (
|
||||
<FormField
|
||||
@@ -756,11 +768,11 @@ export const DocumentPreferencesForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<FormStickySaveBar
|
||||
isDirty={form.formState.isDirty}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={() => form.reset()}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -22,6 +21,8 @@ import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||
|
||||
const ZEmailPreferencesFormSchema = z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: zEmail().nullable(),
|
||||
@@ -59,9 +60,21 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
||||
|
||||
const emails = emailData?.data || [];
|
||||
|
||||
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
await onFormSubmit(data);
|
||||
} catch {
|
||||
// The page handler surfaces its own error toast. Keep the form dirty so
|
||||
// the save bar stays visible and the user can retry.
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(data);
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||
{organisation.organisationClaim.flags.emailDomains && (
|
||||
<FormField
|
||||
@@ -203,11 +216,11 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<FormStickySaveBar
|
||||
isDirty={form.formState.isDirty}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={() => form.reset()}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZEmailTransportFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
fromName: z.string().min(1),
|
||||
name: ZNameSchema,
|
||||
fromName: ZNameSchema,
|
||||
fromAddress: z.string().email(),
|
||||
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
|
||||
host: z.string().optional(),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AlertTriangleIcon } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type FormStickySaveBarProps = {
|
||||
isDirty: boolean;
|
||||
isSubmitting: boolean;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single `position: sticky` bar rendered at the bottom of the form.
|
||||
*
|
||||
* - When the form's end is on screen it settles into place as a plain footer (just the
|
||||
* Reset / Save buttons).
|
||||
* - When the form's end is scrolled off, it sticks to the bottom of the viewport and
|
||||
* shows the "unsaved changes" pill chrome.
|
||||
*
|
||||
* Because it's the same element in the form's flow, it auto-aligns to the form and the
|
||||
* float <-> dock hand-off is a native, scroll-linked transition (no measurement, no
|
||||
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
|
||||
* the pill chrome.
|
||||
*/
|
||||
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
|
||||
if (!sentinel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The sentinel sits at the bar's resting position (the end of the form). While the
|
||||
// bar is stuck to the bottom of the viewport the sentinel is scrolled past (out of
|
||||
// view); once you reach the form's end it comes into view and the bar settles.
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsStuck(!entry.isIntersecting);
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '0px 0px -24px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Show the floating pill chrome only when there are unsaved changes AND the form's
|
||||
// end is off screen.
|
||||
const isFloating = isDirty && isStuck;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="form-sticky-save-bar"
|
||||
className={cn(
|
||||
'z-40 flex min-h-9 min-w-0 items-center gap-x-2 rounded-lg py-4 transition-[margin,padding,background-color,border-color,box-shadow] duration-200 md:gap-x-4',
|
||||
isDirty ? 'sticky bottom-6' : '',
|
||||
// On mobile the docked and floating states are geometrically identical (only
|
||||
// paint changes): a horizontal bleed there overflows the narrow viewport and
|
||||
// fights the IntersectionObserver (oscillation + partial hiding). From `sm` up
|
||||
// there's room, so we restore the original chrome — the island bleeds 8px past
|
||||
// the form when floating, and the buttons sit flush with the fields when docked.
|
||||
isFloating
|
||||
? 'border border-border bg-background px-4 shadow-2xl sm:-mx-2'
|
||||
: 'border border-transparent bg-transparent px-4 shadow-none sm:px-0',
|
||||
)}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{isFloating && (
|
||||
<motion.div
|
||||
key="notice"
|
||||
role="region"
|
||||
aria-label={t`Unsaved changes`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex min-h-9 min-w-0 items-center gap-x-2 text-sm"
|
||||
>
|
||||
<AlertTriangleIcon className="h-5 w-5 flex-shrink-0 text-destructive" />
|
||||
<span className="font-medium text-xs md:text-sm">
|
||||
<Trans>You have unsaved changes</Trans>
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
|
||||
{isDirty && (
|
||||
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
|
||||
<Trans>Undo</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="shrink-0" size="sm" loading={isSubmitting} disabled={!isDirty}>
|
||||
<Trans>Save changes</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sentinel: detects when the sticky bar is floating (stuck) vs settled (docked). */}
|
||||
<div ref={sentinelRef} aria-hidden className="pointer-events-none h-px w-full" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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';
|
||||
@@ -12,11 +11,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||
|
||||
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
|
||||
name: true,
|
||||
url: true,
|
||||
@@ -137,36 +137,11 @@ export const OrganisationUpdateForm = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<AnimatePresence>
|
||||
{form.formState.isDirty && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<Button type="button" variant="secondary" onClick={() => form.reset()}>
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="transition-opacity"
|
||||
disabled={!form.formState.isDirty}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Update organisation</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<FormStickySaveBar
|
||||
isDirty={form.formState.isDirty}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={() => form.reset()}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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';
|
||||
@@ -10,11 +9,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||
|
||||
export type UpdateTeamDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
@@ -135,36 +135,11 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<AnimatePresence>
|
||||
{form.formState.isDirty && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<Button type="button" variant="secondary" onClick={() => form.reset()}>
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="transition-opacity"
|
||||
disabled={!form.formState.isDirty}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Update team</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<FormStickySaveBar
|
||||
isDirty={form.formState.isDirty}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={() => form.reset()}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
|
||||
@@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
|
||||
type AdminGlobalSettingsSectionProps = {
|
||||
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
|
||||
isTeam?: boolean;
|
||||
/** When viewing a team, the parent organisation settings the team inherits from. */
|
||||
inheritedSettings?: OrganisationGlobalSettings | null;
|
||||
};
|
||||
|
||||
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => {
|
||||
export const AdminGlobalSettingsSection = ({
|
||||
settings,
|
||||
isTeam = false,
|
||||
inheritedSettings,
|
||||
}: AdminGlobalSettingsSectionProps) => {
|
||||
const { _ } = useLingui();
|
||||
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
|
||||
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textValue = (value: string | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
return notSetLabel;
|
||||
const notSet = <Trans>Not set</Trans>;
|
||||
|
||||
const inheritedValue = (value: ReactNode) => {
|
||||
if (!isTeam || value === null) {
|
||||
return notSet;
|
||||
}
|
||||
|
||||
return value;
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Inherited</Trans>:
|
||||
</span>
|
||||
<span>{value}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const brandingTextValue = (value: string | null | undefined) => {
|
||||
if (value === null || value === undefined || value.trim() === '') {
|
||||
return notSetLabel;
|
||||
const textValue = (value: string | null | undefined, inherited?: string | null) => {
|
||||
if (value && value.trim() !== '') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value;
|
||||
if (inherited && inherited.trim() !== '') {
|
||||
return inheritedValue(inherited);
|
||||
}
|
||||
|
||||
return notSet;
|
||||
};
|
||||
|
||||
const booleanValue = (value: boolean | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
return notSetLabel;
|
||||
const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
|
||||
|
||||
const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
return booleanLabel(value);
|
||||
}
|
||||
|
||||
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
|
||||
return inherited !== null && inherited !== undefined ? inheritedValue(booleanLabel(inherited)) : notSet;
|
||||
};
|
||||
|
||||
const visibilityLabel = (value: string | null | undefined) => {
|
||||
return value && DOCUMENT_VISIBILITY[value] ? _(DOCUMENT_VISIBILITY[value].value) : null;
|
||||
};
|
||||
|
||||
const visibilityValue = (value: string | null | undefined, inherited?: string | null) => {
|
||||
const label = visibilityLabel(value);
|
||||
|
||||
if (label !== null) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return inheritedValue(visibilityLabel(inherited));
|
||||
};
|
||||
|
||||
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
|
||||
@@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DetailsCard label={<Trans>Document visibility</Trans>}>
|
||||
<DetailsValue>
|
||||
{settings.documentVisibility != null
|
||||
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
|
||||
: notSetLabel}
|
||||
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Document language</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Document timezone</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Date format</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include sender details</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include audit log</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Typed signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Upload signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Draw signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding logo</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding URL</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding company details</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Email reply-to</Trans>}>
|
||||
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
{isTeam && parsedEmailSettings.success && (
|
||||
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
|
||||
)}
|
||||
|
||||
<DetailsCard label={<Trans>AI features</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
@@ -19,7 +20,6 @@ import { useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
|
||||
|
||||
export type ClaimAccountProps = {
|
||||
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
|
||||
|
||||
export const ZClaimAccountFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }),
|
||||
name: ZNameSchema,
|
||||
email: zEmail().min(1),
|
||||
password: ZPasswordSchema,
|
||||
})
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -13,6 +6,13 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
|
||||
|
||||
import { RateLimitArrayInput } from './rate-limit-array-input';
|
||||
|
||||
/**
|
||||
* The rate-limit editor renders its own per-row inline errors, but a submit
|
||||
* attempt can still surface array-level Zod issues (e.g. a committed duplicate
|
||||
* window). Rendering the field's message here guarantees the form never fails
|
||||
* silently when those errors are not tied to a row the editor is showing.
|
||||
*/
|
||||
|
||||
type ClaimLimitFieldsProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
|
||||
@@ -20,6 +20,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type LimitGroup = {
|
||||
title: ReactNode;
|
||||
quotaKey: string;
|
||||
rateLimitKey: string;
|
||||
};
|
||||
|
||||
export const ClaimLimitFields = <T extends FieldValues>({
|
||||
control,
|
||||
prefix = '',
|
||||
@@ -30,13 +36,33 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const name = (key: string) => `${prefix}${key}` as Path<T>;
|
||||
|
||||
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
|
||||
const limitGroups: LimitGroup[] = [
|
||||
{
|
||||
title: <Trans>Documents</Trans>,
|
||||
quotaKey: 'documentQuota',
|
||||
rateLimitKey: 'documentRateLimits',
|
||||
},
|
||||
{
|
||||
title: <Trans>Emails</Trans>,
|
||||
quotaKey: 'emailQuota',
|
||||
rateLimitKey: 'emailRateLimits',
|
||||
},
|
||||
{
|
||||
title: <Trans>API</Trans>,
|
||||
quotaKey: 'apiQuota',
|
||||
rateLimitKey: 'apiRateLimits',
|
||||
},
|
||||
];
|
||||
|
||||
const renderQuotaField = (group: LimitGroup) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name(key)}
|
||||
name={name(group.quotaKey)}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormLabel className="text-muted-foreground text-xs">
|
||||
<Trans>Monthly quota</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -47,20 +73,18 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{description}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderRateLimitField = (key: string, label: ReactNode) => (
|
||||
const renderRateLimitField = (group: LimitGroup) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name(key)}
|
||||
name={name(group.rateLimitKey)}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
||||
</FormControl>
|
||||
@@ -71,27 +95,30 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<FormLabel>
|
||||
<Trans>Limits</Trans>
|
||||
</FormLabel>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">
|
||||
<Trans>Limits</Trans>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderQuotaField(
|
||||
'documentQuota',
|
||||
<Trans>Monthly document quota</Trans>,
|
||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
||||
)}
|
||||
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
|
||||
{limitGroups.map((group) => (
|
||||
<div key={group.quotaKey} className="space-y-4 p-4">
|
||||
<h4 className="font-semibold text-sm">{group.title}</h4>
|
||||
|
||||
{renderQuotaField(
|
||||
'emailQuota',
|
||||
<Trans>Monthly email quota</Trans>,
|
||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
||||
)}
|
||||
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
|
||||
|
||||
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
|
||||
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
|
||||
{renderQuotaField(group)}
|
||||
{renderRateLimitField(group)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { TagType } from '@documenso/lib/types/tag-type';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { TagInput } from '@documenso/ui/primitives/tag/tag-input';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { TagsIcon } from 'lucide-react';
|
||||
|
||||
export type EnvelopeTagsSectionProps = {
|
||||
envelopeId: string;
|
||||
type: (typeof TagType)[keyof typeof TagType];
|
||||
};
|
||||
|
||||
export const EnvelopeTagsSection = ({ envelopeId, type }: EnvelopeTagsSectionProps) => {
|
||||
const { data: assignedTags } = trpc.tag.getEnvelopeTags.useQuery({ envelopeId });
|
||||
|
||||
return (
|
||||
<section className="flex flex-col rounded-xl border border-border bg-widget text-foreground dark:bg-background">
|
||||
<h1 className="flex items-center gap-2 px-4 py-3 font-medium">
|
||||
<TagsIcon className="h-4 w-4" />
|
||||
<Trans>Tags</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
<TagInput type={type} envelopeId={envelopeId} assignedTags={assignedTags ?? []} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,38 @@
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import {
|
||||
getQuotaUsagePercent,
|
||||
isQuotaExceeded,
|
||||
isQuotaNearing,
|
||||
normalizeCapacityLimit,
|
||||
} from '@documenso/lib/universal/quota-usage';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import type { BadgeProps } from '@documenso/ui/primitives/badge';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Progress } from '@documenso/ui/primitives/progress';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
||||
import { useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
|
||||
|
||||
type CapacityUsage = {
|
||||
members: number;
|
||||
teams: number;
|
||||
};
|
||||
|
||||
type UsageRow = {
|
||||
counter: 'document' | 'email' | 'api';
|
||||
label: ReactNode;
|
||||
icon: LucideIcon;
|
||||
used: number;
|
||||
effectiveLimit: number | null;
|
||||
};
|
||||
|
||||
type OrganisationUsagePanelProps = {
|
||||
organisationId: string;
|
||||
monthlyStats: Pick<
|
||||
@@ -15,13 +40,151 @@ type OrganisationUsagePanelProps = {
|
||||
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
|
||||
>[];
|
||||
organisationClaim: OrganisationClaim;
|
||||
capacityUsage?: CapacityUsage;
|
||||
};
|
||||
|
||||
type UsageCardState = {
|
||||
status: {
|
||||
label: ReactNode;
|
||||
variant: NonNullable<BadgeProps['variant']>;
|
||||
};
|
||||
percent: number;
|
||||
hasFiniteLimit: boolean;
|
||||
progressClassName: string;
|
||||
subtext: ReactNode;
|
||||
};
|
||||
|
||||
type UsageCardStateOptions = {
|
||||
used: number;
|
||||
limit: number | null | undefined;
|
||||
footnote?: ReactNode;
|
||||
};
|
||||
|
||||
const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => {
|
||||
const percent = getQuotaUsagePercent(used, limit ?? null);
|
||||
const hasFiniteLimit = Boolean(limit && limit > 0);
|
||||
|
||||
if (limit === null || limit === undefined) {
|
||||
return {
|
||||
status: { label: <Trans>Unlimited</Trans>, variant: 'neutral' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (limit === 0) {
|
||||
return {
|
||||
status: { label: <Trans>Blocked</Trans>, variant: 'destructive' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '',
|
||||
subtext: footnote ?? <Trans>Resource blocked</Trans>,
|
||||
};
|
||||
}
|
||||
|
||||
if (used > limit) {
|
||||
return {
|
||||
status: { label: <Trans>Exceeded</Trans>, variant: 'destructive' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '[&>div]:bg-destructive',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isQuotaExceeded(limit, used)) {
|
||||
return {
|
||||
status: { label: <Trans>Limit reached</Trans>, variant: 'orange' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isQuotaNearing(limit, used)) {
|
||||
return {
|
||||
status: { label: <Trans>Near limit</Trans>, variant: 'warning' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: { label: <Trans>Within limit</Trans>, variant: 'default' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
type UsageStatCardProps = {
|
||||
label: ReactNode;
|
||||
icon: LucideIcon;
|
||||
used: number;
|
||||
limit: number | null | undefined;
|
||||
/** When true the card is a plain counter with no limit, status or progress. */
|
||||
countOnly?: boolean;
|
||||
footnote?: ReactNode;
|
||||
action?: ReactNode;
|
||||
};
|
||||
|
||||
const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => {
|
||||
const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-lg border bg-background p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 font-medium text-foreground text-sm">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
|
||||
{!countOnly && (
|
||||
<Badge variant={status.variant} size="small">
|
||||
{status.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-1 flex-col">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="font-semibold text-3xl text-foreground tabular-nums tracking-tight">
|
||||
{used.toLocaleString()}
|
||||
</span>
|
||||
{hasFiniteLimit ? (
|
||||
<span className="text-base text-muted-foreground tabular-nums">/ {limit?.toLocaleString()}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasFiniteLimit ? (
|
||||
<span className="font-medium text-muted-foreground text-sm tabular-nums">{percent}%</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasFiniteLimit ? <Progress className={cn('mt-3 h-2', progressClassName)} value={percent} /> : null}
|
||||
|
||||
{subtext ? <p className="mt-2 text-muted-foreground text-xs">{subtext}</p> : null}
|
||||
</div>
|
||||
|
||||
{action ? <div className="mt-4 flex justify-end border-t pt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganisationUsagePanel = ({
|
||||
organisationId,
|
||||
monthlyStats,
|
||||
organisationClaim,
|
||||
capacityUsage,
|
||||
}: OrganisationUsagePanelProps) => {
|
||||
const monthlyUsagePeriodId = useId();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
|
||||
|
||||
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
|
||||
@@ -30,86 +193,105 @@ export const OrganisationUsagePanel = ({
|
||||
// current period), so only offer the reset action when viewing the current month.
|
||||
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
|
||||
|
||||
const rows = [
|
||||
const capacityRows = capacityUsage
|
||||
? [
|
||||
{
|
||||
key: 'members',
|
||||
label: <Trans>Members</Trans>,
|
||||
icon: UsersIcon,
|
||||
used: capacityUsage.members,
|
||||
limit: normalizeCapacityLimit(organisationClaim.memberCount),
|
||||
},
|
||||
{
|
||||
key: 'teams',
|
||||
label: <Trans>Teams</Trans>,
|
||||
icon: UsersRoundIcon,
|
||||
used: capacityUsage.teams,
|
||||
limit: normalizeCapacityLimit(organisationClaim.teamCount),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const monthlyRows: UsageRow[] = [
|
||||
{
|
||||
counter: 'document' as const,
|
||||
counter: 'document',
|
||||
label: <Trans>Documents</Trans>,
|
||||
icon: FileIcon,
|
||||
used: selectedStat?.documentCount ?? 0,
|
||||
effectiveLimit: organisationClaim.documentQuota,
|
||||
},
|
||||
{
|
||||
counter: 'email' as const,
|
||||
counter: 'email',
|
||||
label: <Trans>Emails</Trans>,
|
||||
icon: MailIcon,
|
||||
used: selectedStat?.emailCount ?? 0,
|
||||
effectiveLimit: organisationClaim.emailQuota,
|
||||
},
|
||||
{
|
||||
counter: 'api' as const,
|
||||
counter: 'api',
|
||||
label: <Trans>API requests</Trans>,
|
||||
icon: PlugIcon,
|
||||
used: selectedStat?.apiCount ?? 0,
|
||||
effectiveLimit: organisationClaim.apiQuota,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-sm">
|
||||
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
|
||||
</h3>
|
||||
<div className="mt-4 space-y-6">
|
||||
{capacityRows.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{capacityRows.map((row) => (
|
||||
<UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{monthlyStats.length > 0 && (
|
||||
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthlyStats.map((stat) => (
|
||||
<SelectItem key={stat.period} value={stat.period}>
|
||||
{stat.period}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
|
||||
<Trans>Monthly usage</Trans>
|
||||
</h3>
|
||||
|
||||
{rows.map((row) => {
|
||||
const percent =
|
||||
row.effectiveLimit && row.effectiveLimit > 0
|
||||
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
|
||||
: 0;
|
||||
{monthlyStats.length > 0 ? (
|
||||
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
|
||||
<SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthlyStats.map((stat) => (
|
||||
<SelectItem key={stat.period} value={stat.period}>
|
||||
{stat.period}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div key={row.counter} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{row.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{row.used} /{' '}
|
||||
{match(row.effectiveLimit)
|
||||
.with(null, () => <Trans>Unlimited</Trans>)
|
||||
.with(0, () => <Trans>Blocked</Trans>)
|
||||
.otherwise(String)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{monthlyRows.map((row) => (
|
||||
<UsageStatCard
|
||||
key={row.counter}
|
||||
label={row.label}
|
||||
icon={row.icon}
|
||||
used={row.used}
|
||||
limit={row.effectiveLimit}
|
||||
action={
|
||||
selectedStat && isCurrentPeriod ? (
|
||||
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
|
||||
|
||||
{selectedStat && isCurrentPeriod && (
|
||||
<div className="flex w-full justify-end pt-1">
|
||||
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>
|
||||
<Trans>Reports</Trans>
|
||||
</span>
|
||||
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
|
||||
<UsageStatCard
|
||||
label={<Trans>Reports</Trans>}
|
||||
icon={MailOpenIcon}
|
||||
used={selectedStat?.emailReports ?? 0}
|
||||
limit={null}
|
||||
countOnly
|
||||
footnote={<Trans>Sent this period</Trans>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { RotateCcwIcon } from 'lucide-react';
|
||||
import { useRevalidator } from 'react-router';
|
||||
|
||||
type OrganisationUsageResetButtonProps = {
|
||||
@@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
|
||||
loading={isPending}
|
||||
onClick={() => reset({ organisationId, counter })}
|
||||
>
|
||||
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type RateLimitEntryValue = { window: string; max: number };
|
||||
|
||||
@@ -11,50 +13,153 @@ type RateLimitArrayInputProps = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 };
|
||||
|
||||
/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */
|
||||
const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.max > 0;
|
||||
|
||||
/** Keep in-progress rows; drop rows that are completely empty. */
|
||||
const persistEntries = (entries: RateLimitEntryValue[]) => {
|
||||
return entries.map((entry) => ({ ...entry, window: entry.window.trim() })).filter(hasEntryInput);
|
||||
};
|
||||
|
||||
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
|
||||
const entries = value ?? [];
|
||||
const { t } = useLingui();
|
||||
const [draftEntry, setDraftEntry] = useState<RateLimitEntryValue | null>(null);
|
||||
|
||||
const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY];
|
||||
|
||||
const getWindowError = (entry: RateLimitEntryValue, index: number) => {
|
||||
const window = entry.window.trim();
|
||||
|
||||
if (!hasEntryInput(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (window === '') {
|
||||
return t`Enter a window, e.g. 5m`;
|
||||
}
|
||||
|
||||
if (!RATE_LIMIT_WINDOW_REGEX.test(window)) {
|
||||
return t`Use a duration with a unit, e.g. 5m, 1h, or 24h`;
|
||||
}
|
||||
|
||||
const isDuplicateWindow = entries.some((otherEntry, otherIndex) => {
|
||||
return otherIndex !== index && otherEntry.window.trim() === window;
|
||||
});
|
||||
|
||||
return isDuplicateWindow ? t`Use a unique window for each rate limit` : null;
|
||||
};
|
||||
|
||||
const getMaxError = (entry: RateLimitEntryValue) => {
|
||||
if (!hasEntryInput(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.max > 0 ? null : t`Enter a max request count greater than 0`;
|
||||
};
|
||||
|
||||
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
|
||||
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
|
||||
onChange(next);
|
||||
if (index >= value.length) {
|
||||
const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch };
|
||||
|
||||
if (hasEntryInput(nextDraftEntry)) {
|
||||
onChange(persistEntries([...value, nextDraftEntry]));
|
||||
setDraftEntry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftEntry(nextDraftEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
|
||||
onChange(persistEntries(next));
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
onChange(entries.filter((_, i) => i !== index));
|
||||
if (index >= value.length) {
|
||||
setDraftEntry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = value.filter((_, i) => i !== index);
|
||||
onChange(persistEntries(next));
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
onChange([...entries, { window: '5m', max: 100 }]);
|
||||
setDraftEntry(EMPTY_ENTRY);
|
||||
};
|
||||
|
||||
const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry));
|
||||
const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-24"
|
||||
placeholder="5m"
|
||||
value={entry.window}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateEntry(index, { window: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="w-32"
|
||||
type="number"
|
||||
min={1}
|
||||
value={entry.max}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<span className="w-20 shrink-0">
|
||||
<Trans>Window</Trans>
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<Trans>Max requests</Trans>
|
||||
</span>
|
||||
<span className="w-9 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
|
||||
{entries.map((entry, index) => {
|
||||
const windowError = getWindowError(entry, index);
|
||||
const maxError = getMaxError(entry);
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-20 shrink-0"
|
||||
placeholder="5m"
|
||||
value={entry.window}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(windowError)}
|
||||
onChange={(e) => updateEntry(index, { window: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="100"
|
||||
value={entry.max || ''}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(maxError)}
|
||||
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
|
||||
disabled={disabled}
|
||||
aria-label={t`Remove rate limit`}
|
||||
onClick={() => removeEntry(index)}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{windowError ? <p className="text-destructive text-xs">{windowError}</p> : null}
|
||||
{maxError ? <p className="text-destructive text-xs">{maxError}</p> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-dashed"
|
||||
disabled={isAddDisabled}
|
||||
onClick={addEntry}
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Add rate limit</Trans>
|
||||
<Trans>Add rate limit window</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
import { TagList } from '@documenso/ui/primitives/tag/tag-list';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Loader } from 'lucide-react';
|
||||
@@ -50,8 +49,6 @@ export const DocumentsTable = ({
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url ?? '');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
@@ -99,11 +96,6 @@ export const DocumentsTable = ({
|
||||
header: _(msg`Sender`),
|
||||
cell: ({ row }) => row.original.user.name ?? row.original.user.email,
|
||||
},
|
||||
{
|
||||
header: _(msg`Tags`),
|
||||
cell: ({ row }) => <TagList tags={row.original.tags} getTagHref={(tag) => `${documentsPath}/tag/${tag.id}`} />,
|
||||
size: 160,
|
||||
},
|
||||
{
|
||||
header: _(msg`Recipient`),
|
||||
accessorKey: 'recipient',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
|
||||
};
|
||||
|
||||
const ZUpdatePasskeySchema = z.object({
|
||||
name: z.string(),
|
||||
name: ZNameSchema,
|
||||
});
|
||||
|
||||
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
import { TagList } from '@documenso/ui/primitives/tag/tag-list';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -33,7 +32,6 @@ type TemplatesTableProps = {
|
||||
documentRootPath: string;
|
||||
templateRootPath: string;
|
||||
enableSelection?: boolean;
|
||||
enableTagLinks?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||||
};
|
||||
@@ -47,7 +45,6 @@ export const TemplatesTable = ({
|
||||
documentRootPath,
|
||||
templateRootPath,
|
||||
enableSelection,
|
||||
enableTagLinks = true,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
}: TemplatesTableProps) => {
|
||||
@@ -112,16 +109,6 @@ export const TemplatesTable = ({
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: _(msg`Tags`),
|
||||
cell: ({ row }) => (
|
||||
<TagList
|
||||
tags={row.original.tags}
|
||||
getTagHref={enableTagLinks ? (tag) => `${templateRootPath}/tag/${tag.id}` : undefined}
|
||||
/>
|
||||
),
|
||||
size: 160,
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div className="flex flex-row items-center">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
||||
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
@@ -30,7 +31,7 @@ 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 { OrganisationMemberRole } from '@prisma/client';
|
||||
import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
|
||||
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -42,7 +43,6 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
|
||||
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
|
||||
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
|
||||
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
@@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
<GenericOrganisationAdminForm organisation={organisation} />
|
||||
|
||||
<div className="mt-6 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>Organisation usage</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Current usage against organisation limits.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsHeader
|
||||
title={t`Organisation usage`}
|
||||
subtitle={t`Current usage against organisation limits.`}
|
||||
className="mt-6"
|
||||
hideDivider
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<DetailsCard label={<Trans>Members</Trans>}>
|
||||
<DetailsValue>
|
||||
{organisation.members.length} /{' '}
|
||||
{organisation.organisationClaim.memberCount === 0
|
||||
? t`Unlimited`
|
||||
: organisation.organisationClaim.memberCount}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Teams</Trans>}>
|
||||
<DetailsValue>
|
||||
{organisation.teams.length} /{' '}
|
||||
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<OrganisationUsagePanel
|
||||
organisationId={organisation.id}
|
||||
monthlyStats={organisation.monthlyStats}
|
||||
organisationClaim={organisation.organisationClaim}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<OrganisationUsagePanel
|
||||
organisationId={organisation.id}
|
||||
monthlyStats={organisation.monthlyStats}
|
||||
organisationClaim={organisation.organisationClaim}
|
||||
capacityUsage={{
|
||||
members: organisation.members.length,
|
||||
teams: organisation.teams.length,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-6 rounded-lg border p-4">
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="global-settings" className="border-b-0">
|
||||
<AccordionTrigger className="py-0">
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-sm">
|
||||
<p className="font-semibold text-base">
|
||||
<Trans>Global Settings</Trans>
|
||||
</p>
|
||||
<p className="mt-1 font-normal text-muted-foreground text-sm">
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Default settings applied to this organisation.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
className="mt-16"
|
||||
/>
|
||||
|
||||
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<Alert
|
||||
className={cn(
|
||||
'my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center',
|
||||
organisation.subscription?.status === SubscriptionStatus.ACTIVE &&
|
||||
'border border-green-600/20 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10',
|
||||
organisation.subscription?.status === SubscriptionStatus.INACTIVE && 'opacity-60',
|
||||
)}
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Subscription</Trans>
|
||||
@@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
{organisation.subscription ? (
|
||||
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{organisation.subscription.status === SubscriptionStatus.ACTIVE && (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
|
||||
)}
|
||||
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<Trans>No subscription found</Trans>
|
||||
@@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background"
|
||||
loading={isCreatingStripeCustomer}
|
||||
onClick={async () => createStripeCustomer({ organisationId })}
|
||||
>
|
||||
@@ -366,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
{organisation.customerId && !organisation.subscription && (
|
||||
<div>
|
||||
<Button variant="outline" asChild>
|
||||
<Button variant="outline" className="bg-background" asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
|
||||
@@ -383,13 +375,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
<AdminOrganisationSyncSubscriptionDialog
|
||||
organisationId={organisationId}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Button variant="outline" className="bg-background">
|
||||
<Trans>Sync Stripe subscription</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Button variant="outline" className="bg-background" asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
|
||||
@@ -406,21 +398,27 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
<div className="mt-16 space-y-10">
|
||||
<div>
|
||||
<label className="font-medium text-sm leading-none">
|
||||
<h3 className="font-semibold text-base">
|
||||
<Trans>Organisation Members</Trans>
|
||||
</label>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>People with access to this organisation.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="my-2">
|
||||
<div className="mt-3">
|
||||
<DataTable columns={organisationMembersColumns} data={organisation.members} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium text-sm leading-none">
|
||||
<h3 className="font-semibold text-base">
|
||||
<Trans>Organisation Teams</Trans>
|
||||
</label>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Teams that belong to this organisation.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="my-2">
|
||||
<div className="mt-3">
|
||||
<DataTable columns={teamsColumns} data={organisation.teams} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -648,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
<FormLabel className="flex items-center">
|
||||
<Trans>Inherited subscription claim</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -681,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input disabled {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
|
||||
{field.value ? (
|
||||
<span className="font-mono text-foreground">{field.value}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>No inherited claim</Trans>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -715,108 +718,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.teamCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Team Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.teamCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Team Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.memberCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Member Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.memberCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Member Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.envelopeItemCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Envelope Item Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.envelopeItemCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Envelope Item Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.recipientCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.recipientCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
<h3 className="font-semibold text-base">
|
||||
<Trans>Feature Flags</Trans>
|
||||
</FormLabel>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Capabilities enabled for this organisation.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-2 space-y-2 rounded-md border p-4">
|
||||
<div className="mt-3 space-y-2 rounded-md border p-4">
|
||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
|
||||
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
||||
|
||||
|
||||
@@ -287,7 +287,11 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mt-4">
|
||||
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
|
||||
<AdminGlobalSettingsSection
|
||||
settings={team.teamGlobalSettings}
|
||||
inheritedSettings={team.organisation.organisationGlobalSettings}
|
||||
isTeam
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -49,26 +48,29 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
|
||||
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
|
||||
|
||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
||||
|
||||
let uploadedBrandingLogo: string | undefined;
|
||||
// Upload (or clear) the logo through the dedicated, server-validated route.
|
||||
if (brandingLogo instanceof File || brandingLogo === null) {
|
||||
const formData = new FormData();
|
||||
|
||||
if (brandingLogo) {
|
||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||
}
|
||||
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
|
||||
|
||||
// Empty the branding logo if the user unsets it.
|
||||
if (brandingLogo === null) {
|
||||
uploadedBrandingLogo = '';
|
||||
if (brandingLogo instanceof File) {
|
||||
formData.append('brandingLogo', brandingLogo);
|
||||
}
|
||||
|
||||
await updateOrganisationBrandingLogo(formData);
|
||||
}
|
||||
|
||||
const result = await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
brandingEnabled: brandingEnabled ?? undefined,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors,
|
||||
@@ -104,6 +106,9 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
description: t`We were unable to update your branding preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
// Rethrow so the form knows the save failed and keeps the unsaved changes.
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
description: t`We were unable to update your document preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ export default function OrganisationSettingsGeneral() {
|
||||
description: t`We were unable to update your email preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/org
|
||||
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -28,7 +29,6 @@ import { useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import {
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
OrganisationMembersMultiSelectCombobox,
|
||||
} from '~/components/general/organisation-members-multiselect-combobox';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
||||
|
||||
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
|
||||
@@ -113,7 +112,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
}
|
||||
|
||||
const ZUpdateOrganisationGroupFormSchema = z.object({
|
||||
name: z.string().min(1, msg`Name is required`.id),
|
||||
name: ZNameSchema,
|
||||
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
||||
memberIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/env
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { TagType } from '@documenso/lib/types/tag-type';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
} from '~/components/general/document/document-status';
|
||||
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
|
||||
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||
import { EnvelopeTagsSection } from '~/components/general/envelope-tags-section';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
@@ -254,9 +252,6 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
{/* Document information section. */}
|
||||
<DocumentPageViewInformation envelope={envelope} userId={user.id} />
|
||||
|
||||
{/* Tags section. */}
|
||||
<EnvelopeTagsSection envelopeId={envelope.id} type={TagType.DOCUMENT} />
|
||||
|
||||
{/* Recipients section. */}
|
||||
<DocumentPageViewRecipients envelope={envelope} documentRootPath={documentRootPath} />
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||
import { TagType } from '@documenso/lib/types/tag-type';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
@@ -13,11 +12,9 @@ import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/docu
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { TagFilter } from '@documenso/ui/primitives/tag/tag-filter';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
@@ -49,18 +46,13 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
query: true,
|
||||
}).extend({
|
||||
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
||||
tagIds: z
|
||||
.string()
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.transform((val) => val?.split(',').filter(Boolean)),
|
||||
});
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { folderId, tagId } = useParams();
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -93,15 +85,10 @@ export default function DocumentsPage() {
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
// Route param tagId takes priority over URL search param tagIds.
|
||||
const effectiveTagIds = tagId ? [tagId] : findDocumentSearchParams.tagIds;
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
|
||||
{
|
||||
...findDocumentSearchParams,
|
||||
folderId,
|
||||
includeAllFolders: tagId !== undefined,
|
||||
tagIds: effectiveTagIds,
|
||||
},
|
||||
{
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
@@ -127,9 +114,7 @@ export default function DocumentsPage() {
|
||||
|
||||
let path = formatDocumentsPath(team.url);
|
||||
|
||||
if (tagId) {
|
||||
path += `/tag/${tagId}`;
|
||||
} else if (folderId) {
|
||||
if (folderId) {
|
||||
path += `/f/${folderId}`;
|
||||
}
|
||||
|
||||
@@ -149,14 +134,7 @@ export default function DocumentsPage() {
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
{tagId && (
|
||||
<Link to={documentsPath} className="mb-4 flex items-center text-documenso-700 hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>All documents</Trans>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!tagId && <FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />}
|
||||
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
@@ -206,8 +184,6 @@ export default function DocumentsPage() {
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
{!tagId && <TagFilter type={TagType.DOCUMENT} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import DocumentPage, { meta } from './documents._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default DocumentPage;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -38,6 +37,7 @@ export default function TeamsSettingsPage() {
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
||||
const { mutateAsync: updateTeamBrandingLogo } = trpc.team.settings.updateBrandingLogo.useMutation();
|
||||
|
||||
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||
|
||||
@@ -48,22 +48,23 @@ export default function TeamsSettingsPage() {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
||||
|
||||
let uploadedBrandingLogo: string | undefined;
|
||||
// Upload (or clear) the logo through the dedicated, server-validated route.
|
||||
if (brandingLogo instanceof File || brandingLogo === null) {
|
||||
const formData = new FormData();
|
||||
|
||||
if (brandingLogo) {
|
||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||
}
|
||||
formData.append('payload', JSON.stringify({ teamId: team.id }));
|
||||
|
||||
// Empty the branding logo if the user unsets it.
|
||||
if (brandingLogo === null) {
|
||||
uploadedBrandingLogo = '';
|
||||
if (brandingLogo instanceof File) {
|
||||
formData.append('brandingLogo', brandingLogo);
|
||||
}
|
||||
|
||||
await updateTeamBrandingLogo(formData);
|
||||
}
|
||||
|
||||
const result = await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: {
|
||||
brandingEnabled,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl: brandingUrl || null,
|
||||
brandingCompanyDetails: brandingCompanyDetails || null,
|
||||
brandingColors,
|
||||
@@ -99,6 +100,9 @@ export default function TeamsSettingsPage() {
|
||||
description: t`We were unable to update your branding preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
// Rethrow so the form knows the save failed and keeps the unsaved changes.
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ export default function TeamsSettingsPage() {
|
||||
description: t`We were unable to update your document preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ export default function TeamEmailSettingsGeneral() {
|
||||
description: t`We were unable to update your email preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { TagType } from '@documenso/lib/types/tag-type';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
@@ -22,7 +21,6 @@ import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-l
|
||||
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
||||
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
|
||||
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||
import { EnvelopeTagsSection } from '~/components/general/envelope-tags-section';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
@@ -273,9 +271,6 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
{/* Template information section. */}
|
||||
<TemplatePageViewInformation template={envelope} userId={user.id} />
|
||||
|
||||
{/* Tags section. */}
|
||||
<EnvelopeTagsSection envelopeId={envelope.id} type={TagType.TEMPLATE} />
|
||||
|
||||
{/* Recipients section. */}
|
||||
<TemplatePageViewRecipients
|
||||
recipients={envelope.recipients}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { TagType } from '@documenso/lib/types/tag-type';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { TagFilter } from '@documenso/ui/primitives/tag/tag-filter';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, OrganisationType } from '@prisma/client';
|
||||
import { Bird, ChevronLeft } from 'lucide-react';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useParams, useSearchParams } from 'react-router';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
@@ -38,20 +36,16 @@ export default function TemplatesPage() {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { folderId, tagId } = useParams();
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
|
||||
const urlTagIds = searchParams.get('tagIds')?.split(',').filter(Boolean) ?? [];
|
||||
// Route param tagId takes priority over URL search param tagIds.
|
||||
const effectiveTagIds = tagId ? [tagId] : urlTagIds;
|
||||
|
||||
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
|
||||
|
||||
const isOrgView = !tagId && view === 'organisation';
|
||||
const showOrgTab = !tagId && organisation.type !== OrganisationType.PERSONAL;
|
||||
const isOrgView = view === 'organisation';
|
||||
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
||||
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
@@ -69,8 +63,6 @@ export default function TemplatesPage() {
|
||||
page,
|
||||
perPage,
|
||||
folderId,
|
||||
includeAllFolders: tagId !== undefined,
|
||||
tagIds: effectiveTagIds.length > 0 ? effectiveTagIds : undefined,
|
||||
},
|
||||
{
|
||||
enabled: !isOrgView,
|
||||
@@ -100,14 +92,7 @@ export default function TemplatesPage() {
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
{tagId && (
|
||||
<Link to={templateRootPath} className="mb-4 flex items-center text-documenso-700 hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>All templates</Trans>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isOrgView && !tagId && <FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />}
|
||||
{!isOrgView && <FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />}
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="flex flex-row items-center">
|
||||
@@ -144,12 +129,6 @@ export default function TemplatesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOrgView && !tagId && (
|
||||
<div className="mt-6">
|
||||
<TagFilter type={TagType.TEMPLATE} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
{activeQuery.data && activeQuery.data.count === 0 ? (
|
||||
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
@@ -177,7 +156,6 @@ export default function TemplatesPage() {
|
||||
documentRootPath={documentRootPath}
|
||||
templateRootPath={templateRootPath}
|
||||
enableSelection={!isOrgView}
|
||||
enableTagLinks={!isOrgView}
|
||||
rowSelection={isOrgView ? {} : rowSelection}
|
||||
onRowSelectionChange={isOrgView ? undefined : setRowSelection}
|
||||
/>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import TemplatePage, { meta } from './templates._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default TemplatePage;
|
||||
@@ -36,8 +36,8 @@
|
||||
"@lingui/react": "^5.6.0",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@react-router/node": "^7.12.0",
|
||||
"@react-router/serve": "^7.12.0",
|
||||
"@react-router/node": "^7.18.1",
|
||||
"@react-router/serve": "^7.18.1",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
@@ -81,8 +81,8 @@
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
|
||||
"@lingui/vite-plugin": "^5.6.0",
|
||||
"@react-router/dev": "^7.12.0",
|
||||
"@react-router/remix-routes-option-adapter": "^7.12.0",
|
||||
"@react-router/dev": "^7.18.1",
|
||||
"@react-router/remix-routes-option-adapter": "^7.18.1",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@rollup/plugin-commonjs": "^28.0.9",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
@@ -12,14 +11,11 @@ import { Hono } from 'hono';
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
|
||||
import {
|
||||
isAllowedUploadContentType,
|
||||
type TGetPresignedPostUrlResponse,
|
||||
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
|
||||
ZGetEnvelopeItemFileRequestParamsSchema,
|
||||
ZGetEnvelopeItemFileRequestQuerySchema,
|
||||
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
|
||||
ZGetEnvelopeItemFileTokenRequestParamsSchema,
|
||||
ZGetPresignedPostUrlRequestSchema,
|
||||
ZUploadPdfRequestSchema,
|
||||
} from './files.types';
|
||||
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
|
||||
@@ -61,29 +57,6 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
})
|
||||
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
|
||||
const userId = await resolveFileUploadUserId(c);
|
||||
|
||||
if (!userId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const { fileName, contentType } = c.req.valid('json');
|
||||
|
||||
if (!isAllowedUploadContentType(contentType)) {
|
||||
return c.json({ error: 'Unsupported content type' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
|
||||
|
||||
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
|
||||
}
|
||||
})
|
||||
.get(
|
||||
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
|
||||
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
|
||||
|
||||
@@ -13,27 +13,6 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
|
||||
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
|
||||
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
|
||||
|
||||
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
|
||||
|
||||
export const isAllowedUploadContentType = (contentType: string): boolean => {
|
||||
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
|
||||
|
||||
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
|
||||
};
|
||||
|
||||
export const ZGetPresignedPostUrlRequestSchema = z.object({
|
||||
fileName: z.string().min(1),
|
||||
contentType: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ZGetPresignedPostUrlResponseSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
url: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
|
||||
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
|
||||
|
||||
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
|
||||
envelopeId: z.string().min(1),
|
||||
envelopeItemId: z.string().min(1),
|
||||
|
||||
@@ -105,7 +105,6 @@ app.route('/api/auth', auth);
|
||||
|
||||
// Files route.
|
||||
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
|
||||
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
|
||||
app.route('/api/files', filesRoute);
|
||||
|
||||
// AI route.
|
||||
|
||||
Generated
+2052
-1268
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -87,7 +87,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.4.0",
|
||||
"@libpdf/core": "^0.4.1",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
|
||||
@@ -526,7 +526,7 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
||||
// Should be able to access organisation settings
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
|
||||
|
||||
// Should have delete permissions
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
|
||||
@@ -50,7 +50,7 @@ import type { Organisation, Team, User } from '@prisma/client';
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
|
||||
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||
* apiV1RateLimit = 1000 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
@@ -62,7 +62,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v1 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
@@ -125,7 +125,7 @@ const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
||||
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
|
||||
*
|
||||
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 1000/min
|
||||
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
|
||||
* Across the suite (and especially across repeated local runs within the same
|
||||
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||
|
||||
@@ -37,7 +37,7 @@ import type { Organisation, Team, User } from '@prisma/client';
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
|
||||
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
|
||||
* apiV2RateLimit = 1000 requests / 1 minute (see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
@@ -49,7 +49,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v2 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
|
||||
@@ -44,46 +44,6 @@ test.describe('File upload endpoint authorization', () => {
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer not-a-real-token',
|
||||
},
|
||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${presignToken}`,
|
||||
},
|
||||
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
|
||||
});
|
||||
|
||||
// Authenticated, but the content type is not on the allow-list.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { optimiseBrandingLogo } from '@documenso/lib/utils/images/logo';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const makePng = async (width = 1200, height = 1200) =>
|
||||
sharp({
|
||||
create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
test.describe('optimiseBrandingLogo', () => {
|
||||
test('re-encodes a valid image to a PNG buffer', async () => {
|
||||
const input = await makePng();
|
||||
|
||||
const output = await optimiseBrandingLogo(input);
|
||||
|
||||
const metadata = await sharp(output).metadata();
|
||||
|
||||
expect(metadata.format).toBe('png');
|
||||
});
|
||||
|
||||
test('bounds the image to a maximum of 512px on its largest side', async () => {
|
||||
const input = await makePng(2000, 1000);
|
||||
|
||||
const output = await optimiseBrandingLogo(input);
|
||||
|
||||
const metadata = await sharp(output).metadata();
|
||||
|
||||
expect(metadata.width).toBeLessThanOrEqual(512);
|
||||
expect(metadata.height).toBeLessThanOrEqual(512);
|
||||
});
|
||||
|
||||
test('rejects input that is not a valid image', async () => {
|
||||
await expect(optimiseBrandingLogo(Buffer.from('this is not an image'))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from './fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const LOGO_PATH = path.join(__dirname, '../../assets/logo.png');
|
||||
|
||||
type MultipartFile = { name: string; mimeType: string; buffer: Buffer };
|
||||
|
||||
const enableBrandingAndUpload = async (page: Page) => {
|
||||
// Enable custom branding so the file input is no longer disabled.
|
||||
await page.getByTestId('enable-branding').click();
|
||||
await page.getByRole('option', { name: 'Yes' }).click();
|
||||
|
||||
// Upload the logo file through the real multipart route.
|
||||
await page.locator('input[type="file"]').setInputFiles(LOGO_PATH);
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* POST a logo straight to the dedicated multipart tRPC route using the
|
||||
* authenticated browser cookies. This bypasses the client-side form validation,
|
||||
* which is the only way to exercise the server-side image validation /
|
||||
* sanitisation (`zfdBrandingImageFile` + `optimiseBrandingLogo`) and the entitlement gate.
|
||||
*/
|
||||
const postOrganisationBrandingLogo = async (page: Page, organisationId: string, file: MultipartFile | null) => {
|
||||
const multipart: Record<string, string | MultipartFile> = {
|
||||
payload: JSON.stringify({ organisationId }),
|
||||
};
|
||||
|
||||
if (file) {
|
||||
multipart.brandingLogo = file;
|
||||
}
|
||||
|
||||
return await page
|
||||
.context()
|
||||
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.settings.updateBrandingLogo`, { multipart });
|
||||
};
|
||||
|
||||
/**
|
||||
* Grant the organisation the custom-branding entitlement. The positive branding
|
||||
* flows require it whenever billing is enabled; with billing disabled the gate is
|
||||
* bypassed, so this keeps these tests valid in both modes.
|
||||
*/
|
||||
const grantCustomBranding = async (organisationClaimId: string) => {
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisationClaimId },
|
||||
data: { flags: { allowLegacyEnvelopes: true, allowCustomBranding: true } },
|
||||
});
|
||||
};
|
||||
|
||||
test('[BRANDING_LOGO]: uploads an organisation branding logo via the dedicated route', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await grantCustomBranding(organisation.organisationClaim.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||
});
|
||||
|
||||
await enableBrandingAndUpload(page);
|
||||
|
||||
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(settings.brandingLogo).toBeTruthy();
|
||||
|
||||
const parsed = JSON.parse(settings.brandingLogo);
|
||||
expect(parsed).toHaveProperty('type');
|
||||
expect(parsed).toHaveProperty('data');
|
||||
});
|
||||
|
||||
test('[BRANDING_LOGO]: uploads a team branding logo via the dedicated route', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await grantCustomBranding(organisation.organisationClaim.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings/branding`,
|
||||
});
|
||||
|
||||
await enableBrandingAndUpload(page);
|
||||
|
||||
// TeamGlobalSettings has no `teamId` column (the FK lives on Team), so read it
|
||||
// through the team relation.
|
||||
const teamWithSettings = await prisma.team.findUniqueOrThrow({
|
||||
where: { id: team.id },
|
||||
include: { teamGlobalSettings: true },
|
||||
});
|
||||
|
||||
expect(teamWithSettings.teamGlobalSettings?.brandingLogo).toBeTruthy();
|
||||
|
||||
const parsed = JSON.parse(teamWithSettings.teamGlobalSettings?.brandingLogo ?? '');
|
||||
expect(parsed).toHaveProperty('type');
|
||||
expect(parsed).toHaveProperty('data');
|
||||
});
|
||||
|
||||
test('[BRANDING_LOGO]: clears the organisation branding logo when the user removes it', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await grantCustomBranding(organisation.organisationClaim.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||
});
|
||||
|
||||
await enableBrandingAndUpload(page);
|
||||
|
||||
// Confirm the logo was stored before we clear it.
|
||||
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(settings.brandingLogo).toBeTruthy();
|
||||
|
||||
// Remove the logo and save again.
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
// Clearing the logo persists an empty string via the dedicated route.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updated = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
return updated.brandingLogo;
|
||||
})
|
||||
.toBe('');
|
||||
});
|
||||
|
||||
test('[BRANDING_LOGO]: validates and sanitises the logo on the server', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await grantCustomBranding(organisation.organisationClaim.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||
});
|
||||
|
||||
// Positive control: a genuine PNG is accepted and stored. This also proves the
|
||||
// direct multipart request shape matches what the route expects.
|
||||
const validResponse = await postOrganisationBrandingLogo(page, organisation.id, {
|
||||
name: 'logo.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: fs.readFileSync(LOGO_PATH),
|
||||
});
|
||||
|
||||
expect(validResponse.ok()).toBeTruthy();
|
||||
|
||||
const afterValid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(afterValid.brandingLogo).toBeTruthy();
|
||||
|
||||
// Bytes that pass the MIME/size allowlist but are not a real image must be
|
||||
// rejected by the server (the `sharp` re-encode) without changing stored state.
|
||||
const invalidResponse = await postOrganisationBrandingLogo(page, organisation.id, {
|
||||
name: 'fake.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from('this is definitely not a valid png'),
|
||||
});
|
||||
|
||||
expect(invalidResponse.ok()).toBeFalsy();
|
||||
expect(invalidResponse.status()).toBeGreaterThanOrEqual(400);
|
||||
expect(invalidResponse.status()).toBeLessThan(500);
|
||||
|
||||
const afterInvalid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
// The previously stored, valid logo is left untouched by the rejected upload.
|
||||
expect(afterInvalid.brandingLogo).toBe(afterValid.brandingLogo);
|
||||
});
|
||||
|
||||
test('[BRANDING_LOGO]: rejects setting a logo without the custom-branding entitlement', async ({ page }) => {
|
||||
// The entitlement is only enforced when billing is enabled; with billing off
|
||||
// the check is intentionally skipped server-side, so this can't be exercised.
|
||||
test.skip(
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true',
|
||||
'Entitlement is only enforced when billing is enabled.',
|
||||
);
|
||||
|
||||
// Seeded organisations have no `allowCustomBranding` claim flag.
|
||||
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||
});
|
||||
|
||||
const response = await postOrganisationBrandingLogo(page, organisation.id, {
|
||||
name: 'logo.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: fs.readFileSync(LOGO_PATH),
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeFalsy();
|
||||
|
||||
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(settings.brandingLogo).toBeFalsy();
|
||||
});
|
||||
@@ -19,7 +19,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
||||
});
|
||||
|
||||
// Wait for the form to load.
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// Change the amount to 2.
|
||||
const amountInput = page.getByTestId('envelope-expiration-amount');
|
||||
@@ -35,7 +35,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Weeks' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
@@ -57,14 +57,14 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// Find the mode select (shows "Custom duration") and change to "Never expires".
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Never expires' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
@@ -109,7 +109,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// The expiration picker mode select should show "Inherit from organisation" by default.
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
@@ -128,7 +128,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Days' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify team setting is overridden.
|
||||
|
||||
@@ -324,10 +324,7 @@ test.describe('Signing Certificate Tests', () => {
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /Update/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
@@ -347,10 +344,7 @@ test.describe('Signing Certificate Tests', () => {
|
||||
.getByRole('combobox')
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Yes' }).click();
|
||||
await page
|
||||
.getByRole('button', { name: /Update/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
|
||||
await page.getByLabel('Organisation URL*').clear();
|
||||
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
|
||||
|
||||
await page.getByRole('button', { name: 'Update organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||
|
||||
// Check we have been redirected to the new organisation URL and the name is updated.
|
||||
await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`);
|
||||
|
||||
@@ -39,7 +39,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
await page.getByTestId('include-signing-certificate-trigger').click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
@@ -73,7 +73,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
await page.getByTestId('document-date-format-trigger').click();
|
||||
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const updatedTeamSettings = await getTeamSettings({
|
||||
@@ -128,7 +128,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).click();
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
@@ -150,7 +150,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com');
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).click();
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails');
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const updatedTeamSettings = await getTeamSettings({
|
||||
@@ -165,7 +165,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
// Test inheritance by setting team back to inherit from organisation
|
||||
await page.getByTestId('enable-branding').click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
@@ -208,7 +208,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('checkbox', { name: 'Email the signer if the document is still pending' }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' }).uncheck();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
@@ -245,7 +245,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const updatedTeamSettings = await getTeamSettings({
|
||||
@@ -292,7 +292,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
@@ -142,3 +142,38 @@ test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Web
|
||||
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('[SIGNING_BRANDING]: custom logo renders when branding is enabled and is hidden when disabled', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
await enableOrganisationBranding({
|
||||
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||
});
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['enabled-disabled-branding-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
// Branding enabled → the custom logo is rendered on the signing page.
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||
|
||||
// Disable branding while keeping the stored logo (the team inherits this).
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { brandingEnabled: false },
|
||||
});
|
||||
|
||||
// Branding disabled → the custom logo is gone and the Documenso fallback
|
||||
// (an internal link to "/") is shown instead.
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
|
||||
await expect(page.getByRole('img', { name: `${team.name}'s Logo` })).toHaveCount(0);
|
||||
await expect(page.locator('a[href="/"]').first()).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ test('[TEAMS]: update team', async ({ page }) => {
|
||||
await page.getByLabel('Team URL*').clear();
|
||||
await page.getByLabel('Team URL*').fill(updatedTeamId);
|
||||
|
||||
await page.getByRole('button', { name: 'Update team' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||
|
||||
// Check we have been redirected to the new team URL and the name is updated.
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test('[TEAMS]: settings save bar docks at the bottom of the form', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await expect(page.getByLabel('Team Name*')).toBeVisible();
|
||||
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
|
||||
// Pristine: the docked Save button is present but disabled; no Undo, no floating notice.
|
||||
await expect(saveButton).toBeVisible();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
|
||||
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||
|
||||
// Make a change → Save enables and Undo appears.
|
||||
const updatedName = `team-${Date.now()}`;
|
||||
await page.getByLabel('Team Name*').clear();
|
||||
await page.getByLabel('Team Name*').fill(updatedName);
|
||||
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await expect(page.getByRole('button', { name: 'Undo' })).toBeVisible();
|
||||
|
||||
// Undo → value restored, Save disabled again, Undo gone.
|
||||
await page.getByRole('button', { name: 'Undo' }).click();
|
||||
await expect(page.getByLabel('Team Name*')).toHaveValue(team.name);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
|
||||
|
||||
// Change again → Save → success toast, returns to a pristine (disabled) state.
|
||||
await page.getByLabel('Team Name*').clear();
|
||||
await page.getByLabel('Team Name*').fill(updatedName);
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
|
||||
await expect(page.getByText('Your team has been successfully updated.').first()).toBeVisible();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[ORGANISATIONS]: settings save bar floats when the form footer is off-screen', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
// Wait for the long document-preferences form to load.
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// Pristine: no floating notice even though the footer is below the fold.
|
||||
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||
|
||||
// Edit a field near the top → the footer is off-screen, so the floating pill appears.
|
||||
await page.getByTestId('document-language-trigger').click();
|
||||
await page.getByRole('option', { name: 'German' }).click();
|
||||
|
||||
await expect(page.getByText('You have unsaved changes')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||
|
||||
// Scroll to the footer → the floating pill merges into the docked buttons and the
|
||||
// notice disappears.
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
|
||||
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||
});
|
||||
@@ -75,7 +75,7 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||
await item.click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
// Wait for the update to complete
|
||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
@@ -140,7 +140,7 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||
await item.click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
// Wait for finish
|
||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
import MailChecker from 'mailchecker';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { env } from '../utils/env';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
|
||||
|
||||
export const SALT_ROUNDS = 12;
|
||||
|
||||
export const URL_PATTERN = /https?:\/\/|www\./i;
|
||||
|
||||
/**
|
||||
* Shared name schema that disallows URLs to prevent phishing via email rendering.
|
||||
*/
|
||||
export const ZNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, { message: 'Please enter a valid name.' })
|
||||
.max(255, { message: 'Name cannot be more than 255 characters.' })
|
||||
.refine((value) => !URL_PATTERN.test(value), {
|
||||
message: 'Name cannot contain URLs.',
|
||||
});
|
||||
|
||||
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
|
||||
DOCUMENSO: 'Documenso',
|
||||
GOOGLE: 'Google',
|
||||
|
||||
@@ -9,3 +9,13 @@
|
||||
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
|
||||
*/
|
||||
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Branding logo upload constraints. Enforced server-side at the TRPC request
|
||||
* boundary (`zfdBrandingImageFile`) and reused by the client form for matching UX.
|
||||
*/
|
||||
export const BRANDING_LOGO_MAX_SIZE_MB = 5;
|
||||
|
||||
export const BRANDING_LOGO_MAX_SIZE_BYTES = BRANDING_LOGO_MAX_SIZE_MB * 1024 * 1024;
|
||||
|
||||
export const BRANDING_LOGO_ALLOWED_TYPES: string[] = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { putFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { optimiseBrandingLogo } from '../../utils/images/logo';
|
||||
|
||||
/**
|
||||
* Validate, sanitise and store an uploaded branding logo. Returns the
|
||||
* `JSON.stringify({ type, data })` reference persisted in the `brandingLogo`
|
||||
* column (the same format the serving endpoints already expect).
|
||||
*/
|
||||
export const buildBrandingLogoData = async (file: File): Promise<string> => {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const optimised = await optimiseBrandingLogo(buffer).catch(() => {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'The branding logo must be a valid image file.',
|
||||
});
|
||||
});
|
||||
|
||||
const documentData = await putFileServerSide({
|
||||
name: 'branding-logo.png',
|
||||
type: 'image/png',
|
||||
arrayBuffer: async () => Promise.resolve(optimised),
|
||||
});
|
||||
|
||||
return JSON.stringify(documentData);
|
||||
};
|
||||
@@ -83,15 +83,17 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
|
||||
|
||||
// Handle hard or soft deleting the actual document if user has permission.
|
||||
if (hasDeleteAccess) {
|
||||
await handleDocumentOwnerDelete({
|
||||
const updatedEnvelope = await handleDocumentOwnerDelete({
|
||||
envelope,
|
||||
user,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
const envelopeForWebhook = { ...envelope, ...(updatedEnvelope ?? {}) };
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeForWebhook)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
@@ -36,8 +36,6 @@ export type FindDocumentsOptions = {
|
||||
senderIds?: number[];
|
||||
query?: string;
|
||||
folderId?: string;
|
||||
includeAllFolders?: boolean;
|
||||
tagIds?: string[];
|
||||
/**
|
||||
* When true (default), use a windowed count that caps early for faster pagination.
|
||||
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
|
||||
@@ -104,22 +102,6 @@ const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) =>
|
||||
.select(sql.lit(1).as('one')),
|
||||
);
|
||||
|
||||
export const applyEnvelopeTagFilter = (qb: EnvelopeQueryBuilder, tagIds?: string[]) => {
|
||||
if (!tagIds || tagIds.length === 0) {
|
||||
return qb;
|
||||
}
|
||||
|
||||
return qb.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('EnvelopeTag')
|
||||
.whereRef('EnvelopeTag.envelopeId', '=', 'Envelope.id')
|
||||
.where('EnvelopeTag.tagId', 'in', tagIds)
|
||||
.select(sql.lit(1).as('one')),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const findDocuments = async ({
|
||||
userId,
|
||||
teamId,
|
||||
@@ -133,8 +115,6 @@ export const findDocuments = async ({
|
||||
senderIds,
|
||||
query = '',
|
||||
folderId,
|
||||
includeAllFolders = false,
|
||||
tagIds,
|
||||
useWindowedCount = true,
|
||||
}: FindDocumentsOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@@ -167,12 +147,8 @@ export const findDocuments = async ({
|
||||
qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT));
|
||||
|
||||
// Folder filter
|
||||
if (!includeAllFolders) {
|
||||
qb =
|
||||
folderId !== undefined
|
||||
? qb.where('Envelope.folderId', '=', folderId)
|
||||
: qb.where('Envelope.folderId', 'is', null);
|
||||
}
|
||||
qb =
|
||||
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
|
||||
|
||||
// Period filter
|
||||
if (period) {
|
||||
@@ -223,9 +199,6 @@ export const findDocuments = async ({
|
||||
);
|
||||
}
|
||||
|
||||
// Tag filter (OR semantics — envelope must have at least one of the selected tags)
|
||||
qb = applyEnvelopeTagFilter(qb, tagIds);
|
||||
|
||||
return qb;
|
||||
};
|
||||
|
||||
@@ -547,7 +520,6 @@ export const findDocuments = async ({
|
||||
envelopeItems: {
|
||||
select: { id: true, envelopeId: true, title: true, order: true },
|
||||
},
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { applyEnvelopeTagFilter, type PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
|
||||
import type { DB } from '@documenso/prisma/generated/types';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
@@ -57,9 +57,7 @@ export type GetStatsInput = {
|
||||
period?: PeriodSelectorValue;
|
||||
search?: string;
|
||||
folderId?: string;
|
||||
includeAllFolders?: boolean;
|
||||
senderIds?: number[];
|
||||
tagIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -82,16 +80,7 @@ const cappedCount = async (qb: EnvelopeQueryBuilder): Promise<number> => {
|
||||
return Math.min(Number(result.total ?? 0), STATS_COUNT_CAP);
|
||||
};
|
||||
|
||||
export const getStats = async ({
|
||||
userId,
|
||||
teamId,
|
||||
period,
|
||||
search = '',
|
||||
folderId,
|
||||
includeAllFolders = false,
|
||||
senderIds,
|
||||
tagIds,
|
||||
}: GetStatsInput) => {
|
||||
export const getStats = async ({ userId, teamId, period, search = '', folderId, senderIds }: GetStatsInput) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true },
|
||||
@@ -116,12 +105,8 @@ export const getStats = async ({
|
||||
qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT));
|
||||
|
||||
// Folder filter
|
||||
if (!includeAllFolders) {
|
||||
qb =
|
||||
folderId !== undefined
|
||||
? qb.where('Envelope.folderId', '=', folderId)
|
||||
: qb.where('Envelope.folderId', 'is', null);
|
||||
}
|
||||
qb =
|
||||
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
|
||||
|
||||
// Period filter
|
||||
if (period) {
|
||||
@@ -136,9 +121,6 @@ export const getStats = async ({
|
||||
qb = qb.where('Envelope.userId', 'in', senderIds);
|
||||
}
|
||||
|
||||
// Tag filter (OR semantics — envelope must have at least one of the selected tags)
|
||||
qb = applyEnvelopeTagFilter(qb, tagIds);
|
||||
|
||||
// Search filter
|
||||
if (hasSearch) {
|
||||
qb = qb.where(({ or, eb }) =>
|
||||
|
||||
@@ -37,9 +37,9 @@ import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@@ -10,8 +10,8 @@ import { nanoid, prefixedId } from '../../universal/id';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export interface DuplicateEnvelopeOptions {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
|
||||
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
|
||||
|
||||
export type QuotaFlags = {
|
||||
isDocumentQuotaExceeded: boolean;
|
||||
@@ -22,39 +22,6 @@ type ComputeQuotaFlagsOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
|
||||
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
|
||||
*/
|
||||
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
|
||||
if (quota === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (quota === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return usage >= quota;
|
||||
};
|
||||
|
||||
/**
|
||||
* A counter is "nearing" its quota once usage reaches the warning threshold
|
||||
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
|
||||
* exceeded are mutually exclusive per counter.
|
||||
*/
|
||||
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
|
||||
if (quota === null || quota === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isQuotaExceeded(quota, usage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
|
||||
};
|
||||
|
||||
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
|
||||
return {
|
||||
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const QUOTA_WARNING_THRESHOLD = 0.8;
|
||||
import { getQuotaWarningCount } from '../../universal/quota-usage';
|
||||
|
||||
export type QuotaAlertKind = 'quota' | 'quotaNearing';
|
||||
|
||||
@@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
|
||||
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
|
||||
// warning threshold equals the quota itself, the warning can never fire — the
|
||||
// exhausting request is handled by the quota branch above.
|
||||
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
|
||||
const warningCount = getQuotaWarningCount(quota);
|
||||
|
||||
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
|
||||
|
||||
|
||||
@@ -84,19 +84,19 @@ export const syncSubscriptionRateLimit = createRateLimit({
|
||||
|
||||
export const apiV1RateLimit = createRateLimit({
|
||||
action: 'api.v1',
|
||||
max: 100,
|
||||
max: 1000,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const apiV2RateLimit = createRateLimit({
|
||||
action: 'api.v2',
|
||||
max: 100,
|
||||
max: 1000,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const apiTrpcRateLimit = createRateLimit({
|
||||
action: 'api.trpc',
|
||||
max: 100,
|
||||
max: 1000,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TTagType } from '../../types/tag-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
|
||||
export type CreateTagOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
name: string;
|
||||
type: TTagType;
|
||||
};
|
||||
|
||||
export const createTag = async ({ userId, teamId, name, type }: CreateTagOptions) => {
|
||||
// This indirectly verifies whether the user has access to the team.
|
||||
await getTeamSettings({ userId, teamId });
|
||||
|
||||
const normalizedName = name.trim().replace(/\s+/g, ' ');
|
||||
const normalizedNameKey = normalizedName.toLowerCase();
|
||||
|
||||
if (!normalizedName) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Tag name cannot be empty',
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await prisma.tag.findFirst({
|
||||
where: {
|
||||
teamId,
|
||||
normalizedName: normalizedNameKey,
|
||||
type,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'A tag with this name already exists for this type',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.tag.create({
|
||||
data: {
|
||||
name: normalizedName,
|
||||
normalizedName: normalizedNameKey,
|
||||
type,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type DeleteTagOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
tagId: string;
|
||||
};
|
||||
|
||||
export const deleteTag = async ({ userId, teamId, tagId }: DeleteTagOptions) => {
|
||||
await getTeamById({ userId, teamId });
|
||||
|
||||
const tag = await prisma.tag.findFirst({
|
||||
where: {
|
||||
id: tagId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Tag not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.tag.delete({
|
||||
where: {
|
||||
id: tag.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import type { TTagType } from '../../types/tag-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type FindTagsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
type?: TTagType;
|
||||
query?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
|
||||
export const findTags = async ({ userId, teamId, type, query, page = 1, perPage = 10 }: FindTagsOptions) => {
|
||||
await getTeamById({ userId, teamId });
|
||||
|
||||
const whereClause: Prisma.TagWhereInput = {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
type,
|
||||
name: query ? { contains: query, mode: 'insensitive' } : undefined,
|
||||
};
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.tag.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
prisma.tag.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { mapEnvelopeTagsToTags } from '../../utils/tags';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type GetEnvelopeTagsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
envelopeId: string;
|
||||
};
|
||||
|
||||
export const getEnvelopeTags = async ({ userId, teamId, envelopeId }: GetEnvelopeTagsOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
OR: [
|
||||
{ userId },
|
||||
{
|
||||
teamId: team.id,
|
||||
visibility: { in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
const envelopeTags = await prisma.envelopeTag.findMany({
|
||||
where: { envelopeId },
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
});
|
||||
|
||||
return mapEnvelopeTagsToTags(envelopeTags);
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { TagType } from '../../types/tag-type';
|
||||
import { mapEnvelopeTagsToTags } from '../../utils/tags';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type SetEnvelopeTagsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
envelopeId: string;
|
||||
tagIds: string[];
|
||||
};
|
||||
|
||||
export const setEnvelopeTags = async ({ userId, teamId, envelopeId, tagIds }: SetEnvelopeTagsOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
// Verify the envelope exists and the user has access.
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
OR: [
|
||||
{ userId },
|
||||
{
|
||||
teamId: team.id,
|
||||
visibility: { in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Determine the expected tag type based on the envelope type.
|
||||
const expectedTagType = envelope.type === EnvelopeType.DOCUMENT ? TagType.DOCUMENT : TagType.TEMPLATE;
|
||||
|
||||
// Verify all tagIds belong to the same team and match the envelope type.
|
||||
if (tagIds.length > 0) {
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
id: { in: tagIds },
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
type: expectedTagType,
|
||||
},
|
||||
});
|
||||
|
||||
if (tags.length !== tagIds.length) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'One or more tags are invalid or do not match the envelope type',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Delete EnvelopeTag rows not in the new set.
|
||||
await tx.envelopeTag.deleteMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
tagId: { notIn: tagIds },
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch current assignments to find which ones need to be created.
|
||||
const existing = await tx.envelopeTag.findMany({
|
||||
where: { envelopeId },
|
||||
select: { tagId: true },
|
||||
});
|
||||
|
||||
const existingTagIds = new Set(existing.map((et) => et.tagId));
|
||||
const toCreate = tagIds.filter((tagId) => !existingTagIds.has(tagId));
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await tx.envelopeTag.createMany({
|
||||
data: toCreate.map((tagId) => ({
|
||||
envelopeId,
|
||||
tagId,
|
||||
assignedBy: userId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const tags = await prisma.envelopeTag.findMany({
|
||||
where: { envelopeId },
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
});
|
||||
|
||||
return mapEnvelopeTagsToTags(tags);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type UpdateTagOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
tagId: string;
|
||||
data: {
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const updateTag = async ({ userId, teamId, tagId, data }: UpdateTagOptions) => {
|
||||
const { name } = data;
|
||||
|
||||
await getTeamById({ userId, teamId });
|
||||
|
||||
const tag = await prisma.tag.findFirst({
|
||||
where: {
|
||||
id: tagId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Tag not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (name !== undefined) {
|
||||
const normalizedName = name.trim().replace(/\s+/g, ' ');
|
||||
const normalizedNameKey = normalizedName.toLowerCase();
|
||||
|
||||
if (!normalizedName) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Tag name cannot be empty',
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedNameKey !== tag.normalizedName) {
|
||||
const existing = await prisma.tag.findFirst({
|
||||
where: {
|
||||
teamId,
|
||||
normalizedName: normalizedNameKey,
|
||||
type: tag.type,
|
||||
id: { not: tagId },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'A tag with this name already exists for this type',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.tag.update({
|
||||
where: { id: tagId },
|
||||
data: {
|
||||
name: normalizedName,
|
||||
normalizedName: normalizedNameKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tag;
|
||||
};
|
||||
@@ -51,8 +51,8 @@ import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { incrementDocumentId } from '../envelope/increment-id';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
|
||||
|
||||
@@ -58,11 +58,6 @@ export const findOrganisationTemplates = async ({
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
|
||||
@@ -13,8 +13,6 @@ export type FindTemplatesOptions = {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
folderId?: string;
|
||||
includeAllFolders?: boolean;
|
||||
tagIds?: string[];
|
||||
};
|
||||
|
||||
export const findTemplates = async ({
|
||||
@@ -24,8 +22,6 @@ export const findTemplates = async ({
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
folderId,
|
||||
includeAllFolders = false,
|
||||
tagIds,
|
||||
}: FindTemplatesOptions) => {
|
||||
const { teamRole } = await getMemberRoles({
|
||||
teamId,
|
||||
@@ -50,8 +46,7 @@ export const findTemplates = async ({
|
||||
{ userId, teamId },
|
||||
],
|
||||
},
|
||||
...(includeAllFolders ? [] : [folderId ? { folderId } : { folderId: null }]),
|
||||
...(tagIds && tagIds.length > 0 ? [{ tags: { some: { tagId: { in: tagIds } } } }] : []),
|
||||
folderId ? { folderId } : { folderId: null },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -72,11 +67,6 @@ export const findTemplates = async ({
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
|
||||
@@ -2441,6 +2441,7 @@ msgstr "Branding-Logo"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "Markenpräferenzen"
|
||||
|
||||
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
|
||||
msgstr "Derzeit können alle Organisationsmitglieder auf dieses Team zugreifen"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Currently branding can only be configured for Teams and above plans."
|
||||
msgstr "Zurzeit kann das Branding nur für Teams und darüber konfiguriert werden."
|
||||
|
||||
@@ -4214,8 +4216,8 @@ msgstr "Dokument storniert"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
|
||||
#: packages/lib/server-only/admin/admin-super-delete-document.ts
|
||||
#: packages/lib/server-only/document/delete-document.ts
|
||||
msgid "Document Cancelled"
|
||||
msgstr "Dokument storniert"
|
||||
|
||||
@@ -7942,6 +7944,11 @@ msgstr "Original"
|
||||
msgid "Otherwise, the document will be created as a draft."
|
||||
msgstr "Andernfalls wird das Dokument als Entwurf erstellt."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Overlapping fields detected"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
msgid "Override organisation settings"
|
||||
@@ -9197,9 +9204,7 @@ msgstr "Umschlag erneut senden"
|
||||
msgid "Resend verification"
|
||||
msgstr "Bestätigung erneut senden"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx
|
||||
msgid "Reset"
|
||||
msgstr "Zurücksetzen"
|
||||
@@ -9384,6 +9389,7 @@ msgid "Save as Template"
|
||||
msgstr "Als Vorlage speichern"
|
||||
|
||||
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Save changes"
|
||||
msgstr "Änderungen speichern"
|
||||
|
||||
@@ -10224,6 +10230,11 @@ msgstr "Website Einstellungen"
|
||||
msgid "Skip"
|
||||
msgstr "Überspringen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
|
||||
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
|
||||
msgstr "Einige Unterzeichner haben noch kein Unterschriftsfeld zugewiesen bekommen. Bitte weisen Sie jedem Unterzeichner mindestens ein Unterschriftsfeld zu, bevor Sie fortfahren."
|
||||
@@ -12254,6 +12265,7 @@ msgstr "Nicht autorisiert"
|
||||
msgid "Uncompleted"
|
||||
msgstr "Unvollendet"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
|
||||
msgid "Undo"
|
||||
msgstr "Rückgängig"
|
||||
@@ -12303,6 +12315,10 @@ msgstr "Verknüpfung aufheben"
|
||||
msgid "Unpin"
|
||||
msgstr "Lösen"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
|
||||
msgid "Unsealed Documents"
|
||||
@@ -12322,9 +12338,6 @@ msgstr "Unbetitelte Gruppe"
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/tables/admin-claims-table.tsx
|
||||
@@ -12347,6 +12360,7 @@ msgstr "Banner aktualisieren"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Update Billing"
|
||||
msgstr "Rechnungsdaten aktualisieren"
|
||||
|
||||
@@ -12374,10 +12388,6 @@ msgstr "E-Mail aktualisieren"
|
||||
msgid "Update Fields"
|
||||
msgstr "Felder aktualisieren"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
msgid "Update organisation"
|
||||
msgstr "Organisation aktualisieren"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
@@ -12413,10 +12423,6 @@ msgstr "Rolle aktualisieren"
|
||||
msgid "Update Subscription Claim"
|
||||
msgstr "Abonnementsanspruch aktualisieren"
|
||||
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
msgid "Update team"
|
||||
msgstr "Team aktualisieren"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
msgid "Update team email"
|
||||
@@ -12947,7 +12953,7 @@ msgstr "Warten"
|
||||
msgid "Waiting for others"
|
||||
msgstr "Warten auf andere"
|
||||
|
||||
#: packages/lib/server-only/document/send-pending-email.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
|
||||
msgid "Waiting for others to complete signing."
|
||||
msgstr "Warten auf andere, um die Unterzeichnung abzuschließen."
|
||||
|
||||
@@ -13921,8 +13927,7 @@ msgstr "Du wurdest eingeladen, {0} auf Documenso beizutreten"
|
||||
msgid "You have been invited to join the following organisation"
|
||||
msgstr "Sie wurden eingeladen, der folgenden Organisation beizutreten"
|
||||
|
||||
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts
|
||||
#: packages/lib/server-only/recipient/set-document-recipients.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
|
||||
msgid "You have been removed from a document"
|
||||
msgstr "Du wurdest von einem Dokument entfernt"
|
||||
|
||||
@@ -14042,6 +14047,10 @@ msgstr "Sie haben den Zugriff erfolgreich widerrufen."
|
||||
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
|
||||
msgstr "Sie haben das Recht, Ihre Zustimmung zur Verwendung elektronischer Unterschriften jederzeit vor Abschluss des Unterzeichnungsprozesses zu widerrufen. Um Ihre Zustimmung zu widerrufen, kontaktieren Sie bitte den Absender des Dokuments. Sollten Sie den Absender nicht erreichen, können Sie sich für Unterstützung an <0>{SUPPORT_EMAIL}</0> wenden. Seien Sie sich bewusst, dass der Widerruf der Zustimmung den Abschluss der zugehörigen Transaktion oder Dienstleistung verzögern oder stoppen kann."
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "You have unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
msgid "You have updated {memberName}."
|
||||
msgstr "Sie haben {memberName} aktualisiert."
|
||||
@@ -14721,4 +14730,3 @@ msgstr "Ihr Verifizierungscode:"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "your-domain.com another-domain.com"
|
||||
msgstr "your-domain.com another-domain.com"
|
||||
|
||||
|
||||
@@ -2436,6 +2436,7 @@ msgstr "Branding Logo"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "Branding Preferences"
|
||||
|
||||
@@ -3567,6 +3568,7 @@ msgid "Currently all organisation members can access this team"
|
||||
msgstr "Currently all organisation members can access this team"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Currently branding can only be configured for Teams and above plans."
|
||||
msgstr "Currently branding can only be configured for Teams and above plans."
|
||||
|
||||
@@ -4209,8 +4211,8 @@ msgstr "Document cancelled"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
|
||||
#: packages/lib/server-only/admin/admin-super-delete-document.ts
|
||||
#: packages/lib/server-only/document/delete-document.ts
|
||||
msgid "Document Cancelled"
|
||||
msgstr "Document Cancelled"
|
||||
|
||||
@@ -7937,6 +7939,11 @@ msgstr "Original"
|
||||
msgid "Otherwise, the document will be created as a draft."
|
||||
msgstr "Otherwise, the document will be created as a draft."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Overlapping fields detected"
|
||||
msgstr "Overlapping fields detected"
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
msgid "Override organisation settings"
|
||||
@@ -8772,6 +8779,10 @@ msgstr "Recipient ID:"
|
||||
msgid "Recipient rejected the document"
|
||||
msgstr "Recipient rejected the document"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "Recipient rejected the document externally"
|
||||
msgstr "Recipient rejected the document externally"
|
||||
|
||||
#: apps/remix/app/components/general/admin-global-settings-section.tsx
|
||||
msgid "Recipient removed"
|
||||
msgstr "Recipient removed"
|
||||
@@ -9188,9 +9199,7 @@ msgstr "Resend Envelope"
|
||||
msgid "Resend verification"
|
||||
msgstr "Resend verification"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx
|
||||
msgid "Reset"
|
||||
msgstr "Reset"
|
||||
@@ -9375,6 +9384,7 @@ msgid "Save as Template"
|
||||
msgstr "Save as Template"
|
||||
|
||||
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Save changes"
|
||||
msgstr "Save changes"
|
||||
|
||||
@@ -10215,6 +10225,11 @@ msgstr "Site Settings"
|
||||
msgid "Skip"
|
||||
msgstr "Skip"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
|
||||
msgstr "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
|
||||
|
||||
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
|
||||
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
|
||||
msgstr "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
|
||||
@@ -11093,6 +11108,23 @@ msgstr "The document signing process will be stopped"
|
||||
msgid "The document was created but could not be sent to recipients."
|
||||
msgstr "The document was created but could not be sent to recipients."
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "The document was rejected externally by {onBehalfOf} on behalf of {user}"
|
||||
msgstr "The document was rejected externally by {onBehalfOf} on behalf of {user}"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "The document was rejected externally by {onBehalfOf} on behalf of the recipient"
|
||||
msgstr "The document was rejected externally by {onBehalfOf} on behalf of the recipient"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "The document was rejected externally on behalf of {user}"
|
||||
msgstr "The document was rejected externally on behalf of {user}"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "The document was rejected externally on behalf of the recipient"
|
||||
msgstr "The document was rejected externally on behalf of the recipient"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
msgid "The document will be hidden from your account"
|
||||
msgstr "The document will be hidden from your account"
|
||||
@@ -12228,6 +12260,7 @@ msgstr "Unauthorized"
|
||||
msgid "Uncompleted"
|
||||
msgstr "Uncompleted"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
|
||||
msgid "Undo"
|
||||
msgstr "Undo"
|
||||
@@ -12277,6 +12310,10 @@ msgstr "Unlink"
|
||||
msgid "Unpin"
|
||||
msgstr "Unpin"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Unsaved changes"
|
||||
msgstr "Unsaved changes"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
|
||||
msgid "Unsealed Documents"
|
||||
@@ -12296,9 +12333,6 @@ msgstr "Untitled Group"
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/tables/admin-claims-table.tsx
|
||||
@@ -12321,6 +12355,7 @@ msgstr "Update Banner"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Update Billing"
|
||||
msgstr "Update Billing"
|
||||
|
||||
@@ -12348,10 +12383,6 @@ msgstr "Update email"
|
||||
msgid "Update Fields"
|
||||
msgstr "Update Fields"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
msgid "Update organisation"
|
||||
msgstr "Update organisation"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
@@ -12387,10 +12418,6 @@ msgstr "Update role"
|
||||
msgid "Update Subscription Claim"
|
||||
msgstr "Update Subscription Claim"
|
||||
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
msgid "Update team"
|
||||
msgstr "Update team"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
msgid "Update team email"
|
||||
@@ -12921,7 +12948,7 @@ msgstr "Waiting"
|
||||
msgid "Waiting for others"
|
||||
msgstr "Waiting for others"
|
||||
|
||||
#: packages/lib/server-only/document/send-pending-email.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
|
||||
msgid "Waiting for others to complete signing."
|
||||
msgstr "Waiting for others to complete signing."
|
||||
|
||||
@@ -13895,8 +13922,7 @@ msgstr "You have been invited to join {0} on Documenso"
|
||||
msgid "You have been invited to join the following organisation"
|
||||
msgstr "You have been invited to join the following organisation"
|
||||
|
||||
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts
|
||||
#: packages/lib/server-only/recipient/set-document-recipients.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
|
||||
msgid "You have been removed from a document"
|
||||
msgstr "You have been removed from a document"
|
||||
|
||||
@@ -14016,6 +14042,10 @@ msgstr "You have successfully revoked access."
|
||||
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
|
||||
msgstr "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "You have unsaved changes"
|
||||
msgstr "You have unsaved changes"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
msgid "You have updated {memberName}."
|
||||
msgstr "You have updated {memberName}."
|
||||
|
||||
@@ -2441,6 +2441,7 @@ msgstr "Logotipo de Marca"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "Preferencias de marca"
|
||||
|
||||
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
|
||||
msgstr "Actualmente, todos los miembros de la organización pueden acceder a este equipo"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Currently branding can only be configured for Teams and above plans."
|
||||
msgstr "Actualmente la marca solo se puede configurar para Equipos y planes superiores."
|
||||
|
||||
@@ -4214,8 +4216,8 @@ msgstr "Documento cancelado"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
|
||||
#: packages/lib/server-only/admin/admin-super-delete-document.ts
|
||||
#: packages/lib/server-only/document/delete-document.ts
|
||||
msgid "Document Cancelled"
|
||||
msgstr "Documento cancelado"
|
||||
|
||||
@@ -7942,6 +7944,11 @@ msgstr "Original"
|
||||
msgid "Otherwise, the document will be created as a draft."
|
||||
msgstr "De lo contrario, el documento se creará como un borrador."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Overlapping fields detected"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
msgid "Override organisation settings"
|
||||
@@ -9197,9 +9204,7 @@ msgstr "Reenviar sobre (envelope)"
|
||||
msgid "Resend verification"
|
||||
msgstr "Reenviar verificación"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx
|
||||
msgid "Reset"
|
||||
msgstr "Restablecer"
|
||||
@@ -9384,6 +9389,7 @@ msgid "Save as Template"
|
||||
msgstr "Guardar como plantilla"
|
||||
|
||||
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Save changes"
|
||||
msgstr "Guardar cambios"
|
||||
|
||||
@@ -10224,6 +10230,11 @@ msgstr "Configuraciones del sitio"
|
||||
msgid "Skip"
|
||||
msgstr "Omitir"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
|
||||
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
|
||||
msgstr "Algunos firmantes no han sido asignados a un campo de firma. Asigne al menos 1 campo de firma a cada firmante antes de continuar."
|
||||
@@ -12254,6 +12265,7 @@ msgstr "No autorizado"
|
||||
msgid "Uncompleted"
|
||||
msgstr "Incompleto"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
|
||||
msgid "Undo"
|
||||
msgstr "Deshacer"
|
||||
@@ -12303,6 +12315,10 @@ msgstr "Desvincular"
|
||||
msgid "Unpin"
|
||||
msgstr "Desanclar"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
|
||||
msgid "Unsealed Documents"
|
||||
@@ -12322,9 +12338,6 @@ msgstr "Grupo sin título"
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/tables/admin-claims-table.tsx
|
||||
@@ -12347,6 +12360,7 @@ msgstr "Actualizar banner"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Update Billing"
|
||||
msgstr "Actualizar facturación"
|
||||
|
||||
@@ -12374,10 +12388,6 @@ msgstr "Actualizar correo electrónico"
|
||||
msgid "Update Fields"
|
||||
msgstr "Actualizar Campos"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
msgid "Update organisation"
|
||||
msgstr "Actualizar organización"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
@@ -12413,10 +12423,6 @@ msgstr "Actualizar rol"
|
||||
msgid "Update Subscription Claim"
|
||||
msgstr "Actualizar reclamo de suscripción"
|
||||
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
msgid "Update team"
|
||||
msgstr "Actualizar equipo"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
msgid "Update team email"
|
||||
@@ -12947,7 +12953,7 @@ msgstr "Esperando"
|
||||
msgid "Waiting for others"
|
||||
msgstr "Esperando a otros"
|
||||
|
||||
#: packages/lib/server-only/document/send-pending-email.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
|
||||
msgid "Waiting for others to complete signing."
|
||||
msgstr "Esperando a que otros completen la firma."
|
||||
|
||||
@@ -13921,8 +13927,7 @@ msgstr "Te han invitado a unirte a {0} en Documenso"
|
||||
msgid "You have been invited to join the following organisation"
|
||||
msgstr "Has sido invitado a unirte a la siguiente organización"
|
||||
|
||||
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts
|
||||
#: packages/lib/server-only/recipient/set-document-recipients.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
|
||||
msgid "You have been removed from a document"
|
||||
msgstr "Te han eliminado de un documento"
|
||||
|
||||
@@ -14042,6 +14047,10 @@ msgstr "Has revocado el acceso con éxito."
|
||||
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
|
||||
msgstr "Usted tiene el derecho de retirar su consentimiento para usar firmas electrónicas en cualquier momento antes de completar el proceso de firma. Para retirar su consentimiento, comuníquese con el remitente del documento. Si no se comunica con el remitente, puede comunicarse con <0>{SUPPORT_EMAIL}</0> para obtener asistencia. Tenga en cuenta que retirar el consentimiento puede retrasar o detener la finalización de la transacción o servicio relacionado."
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "You have unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
msgid "You have updated {memberName}."
|
||||
msgstr "Has actualizado a {memberName}."
|
||||
@@ -14721,4 +14730,3 @@ msgstr "Su código de verificación:"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "your-domain.com another-domain.com"
|
||||
msgstr "su-dominio.com otro-dominio.com"
|
||||
|
||||
|
||||
@@ -2441,6 +2441,7 @@ msgstr "Logo de la marque"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "Préférences de branding"
|
||||
|
||||
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
|
||||
msgstr "Actuellement, tous les membres de l'organisation peuvent accéder à cette équipe"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Currently branding can only be configured for Teams and above plans."
|
||||
msgstr "Actuellement, la personnalisation de la marque ne peut être configurée que pour les plans Équipe et plus."
|
||||
|
||||
@@ -4214,8 +4216,8 @@ msgstr "Document annulé"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
|
||||
#: packages/lib/server-only/admin/admin-super-delete-document.ts
|
||||
#: packages/lib/server-only/document/delete-document.ts
|
||||
msgid "Document Cancelled"
|
||||
msgstr "Document Annulé"
|
||||
|
||||
@@ -7942,6 +7944,11 @@ msgstr "Original"
|
||||
msgid "Otherwise, the document will be created as a draft."
|
||||
msgstr "Sinon, le document sera créé sous forme de brouillon."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Overlapping fields detected"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
msgid "Override organisation settings"
|
||||
@@ -9197,9 +9204,7 @@ msgstr "Renvoyer l’enveloppe"
|
||||
msgid "Resend verification"
|
||||
msgstr "Renvoyer la vérification"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx
|
||||
msgid "Reset"
|
||||
msgstr "Réinitialiser"
|
||||
@@ -9384,6 +9389,7 @@ msgid "Save as Template"
|
||||
msgstr "Enregistrer comme modèle"
|
||||
|
||||
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Save changes"
|
||||
msgstr "Enregistrer les modifications"
|
||||
|
||||
@@ -10224,6 +10230,11 @@ msgstr "Paramètres du site"
|
||||
msgid "Skip"
|
||||
msgstr "Ignorer"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
|
||||
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
|
||||
msgstr "Certains signataires n'ont pas été assignés à un champ de signature. Veuillez assigner au moins 1 champ de signature à chaque signataire avant de continuer."
|
||||
@@ -12254,6 +12265,7 @@ msgstr "Non autorisé"
|
||||
msgid "Uncompleted"
|
||||
msgstr "Non complet"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
|
||||
msgid "Undo"
|
||||
msgstr "Annuler"
|
||||
@@ -12303,6 +12315,10 @@ msgstr "Délier"
|
||||
msgid "Unpin"
|
||||
msgstr "Détacher"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
|
||||
msgid "Unsealed Documents"
|
||||
@@ -12322,9 +12338,6 @@ msgstr "Groupe sans titre"
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/tables/admin-claims-table.tsx
|
||||
@@ -12347,6 +12360,7 @@ msgstr "Mettre à jour la bannière"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Update Billing"
|
||||
msgstr "Mettre à jour la facturation"
|
||||
|
||||
@@ -12374,10 +12388,6 @@ msgstr "Mettre à jour l'e-mail"
|
||||
msgid "Update Fields"
|
||||
msgstr "Mettre à jour les champs"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
msgid "Update organisation"
|
||||
msgstr "Mettre à jour l'organisation"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
@@ -12413,10 +12423,6 @@ msgstr "Mettre à jour le rôle"
|
||||
msgid "Update Subscription Claim"
|
||||
msgstr "Mettre à jour la réclamation d'abonnement"
|
||||
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
msgid "Update team"
|
||||
msgstr "Mettre à jour l'équipe"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
msgid "Update team email"
|
||||
@@ -12947,7 +12953,7 @@ msgstr "En attente"
|
||||
msgid "Waiting for others"
|
||||
msgstr "En attente des autres"
|
||||
|
||||
#: packages/lib/server-only/document/send-pending-email.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
|
||||
msgid "Waiting for others to complete signing."
|
||||
msgstr "En attente que d'autres terminent la signature."
|
||||
|
||||
@@ -13921,8 +13927,7 @@ msgstr "Vous avez été invité à rejoindre {0} sur Documenso"
|
||||
msgid "You have been invited to join the following organisation"
|
||||
msgstr "Vous avez été invité à rejoindre l'organisation suivante"
|
||||
|
||||
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts
|
||||
#: packages/lib/server-only/recipient/set-document-recipients.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
|
||||
msgid "You have been removed from a document"
|
||||
msgstr "Vous avez été supprimé d'un document"
|
||||
|
||||
@@ -14042,6 +14047,10 @@ msgstr "Vous avez révoqué l'accès avec succès."
|
||||
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
|
||||
msgstr "Vous avez le droit de retirer votre consentement à l'utilisation des signatures électroniques à tout moment avant de terminer le processus de signature. Pour retirer votre consentement, veuillez contacter l'expéditeur du document. Si vous ne contactez pas l'expéditeur, vous pouvez contacter <0>{SUPPORT_EMAIL}</0> pour obtenir de l'aide. Sachez que le retrait de consentement peut retarder ou arrêter l'achèvement de la transaction ou du service associé."
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "You have unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
msgid "You have updated {memberName}."
|
||||
msgstr "Vous avez mis à jour {memberName}."
|
||||
@@ -14721,4 +14730,3 @@ msgstr "Votre code de vérification :"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "your-domain.com another-domain.com"
|
||||
msgstr "your-domain.com another-domain.com"
|
||||
|
||||
|
||||
@@ -2441,6 +2441,7 @@ msgstr "Logo del Marchio"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "Preferenze per il branding"
|
||||
|
||||
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
|
||||
msgstr "Attualmente tutti i membri dell'organizzazione possono accedere a questo team"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Currently branding can only be configured for Teams and above plans."
|
||||
msgstr "Attualmente il marchio può essere configurato solo per i piani Team e superiori."
|
||||
|
||||
@@ -4214,8 +4216,8 @@ msgstr "Documento annullato"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
|
||||
#: packages/lib/server-only/admin/admin-super-delete-document.ts
|
||||
#: packages/lib/server-only/document/delete-document.ts
|
||||
msgid "Document Cancelled"
|
||||
msgstr "Documento Annullato"
|
||||
|
||||
@@ -7942,6 +7944,11 @@ msgstr "Originale"
|
||||
msgid "Otherwise, the document will be created as a draft."
|
||||
msgstr "Altrimenti, il documento sarà creato come bozza."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Overlapping fields detected"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
msgid "Override organisation settings"
|
||||
@@ -9197,9 +9204,7 @@ msgstr "Invia nuovamente busta"
|
||||
msgid "Resend verification"
|
||||
msgstr "Reinvia verifica"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx
|
||||
msgid "Reset"
|
||||
msgstr "Ripristina"
|
||||
@@ -9384,6 +9389,7 @@ msgid "Save as Template"
|
||||
msgstr "Salva come modello"
|
||||
|
||||
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Save changes"
|
||||
msgstr "Salva le modifiche"
|
||||
|
||||
@@ -10224,6 +10230,11 @@ msgstr "Impostazioni del sito"
|
||||
msgid "Skip"
|
||||
msgstr "Salta"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
|
||||
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
|
||||
msgstr "Alcuni firmatari non hanno un campo firma assegnato. Assegna almeno 1 campo di firma a ciascun firmatario prima di procedere."
|
||||
@@ -12254,6 +12265,7 @@ msgstr "Non autorizzato"
|
||||
msgid "Uncompleted"
|
||||
msgstr "Incompleto"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
|
||||
msgid "Undo"
|
||||
msgstr "Annulla"
|
||||
@@ -12303,6 +12315,10 @@ msgstr "Scollega"
|
||||
msgid "Unpin"
|
||||
msgstr "Rimuovi"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
|
||||
msgid "Unsealed Documents"
|
||||
@@ -12322,9 +12338,6 @@ msgstr "Gruppo senza nome"
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/tables/admin-claims-table.tsx
|
||||
@@ -12347,6 +12360,7 @@ msgstr "Aggiorna banner"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Update Billing"
|
||||
msgstr "Aggiorna fatturazione"
|
||||
|
||||
@@ -12374,10 +12388,6 @@ msgstr "Aggiorna email"
|
||||
msgid "Update Fields"
|
||||
msgstr "Aggiorna campi"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
msgid "Update organisation"
|
||||
msgstr "Aggiorna organizzazione"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
@@ -12413,10 +12423,6 @@ msgstr "Aggiorna ruolo"
|
||||
msgid "Update Subscription Claim"
|
||||
msgstr "Aggiorna reclamo di sottoscrizione"
|
||||
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
msgid "Update team"
|
||||
msgstr "Aggiorna team"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
msgid "Update team email"
|
||||
@@ -12947,7 +12953,7 @@ msgstr "In attesa"
|
||||
msgid "Waiting for others"
|
||||
msgstr "In attesa di altri"
|
||||
|
||||
#: packages/lib/server-only/document/send-pending-email.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
|
||||
msgid "Waiting for others to complete signing."
|
||||
msgstr "In attesa che altri completino la firma."
|
||||
|
||||
@@ -13921,8 +13927,7 @@ msgstr "Sei stato invitato a unirti a {0} su Documenso"
|
||||
msgid "You have been invited to join the following organisation"
|
||||
msgstr "Sei stato invitato a unirti alla seguente organizzazione"
|
||||
|
||||
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts
|
||||
#: packages/lib/server-only/recipient/set-document-recipients.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
|
||||
msgid "You have been removed from a document"
|
||||
msgstr "Sei stato rimosso da un documento"
|
||||
|
||||
@@ -14042,6 +14047,10 @@ msgstr "Hai revocato con successo l'accesso."
|
||||
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
|
||||
msgstr "Hai il diritto di ritirare il tuo consenso all'uso delle firme elettroniche in qualsiasi momento prima di completare il processo di firma. Per ritirare il tuo consenso, contatta il mittente del documento. Nel caso in cui non riesci a contattare il mittente, puoi contattare <0>{SUPPORT_EMAIL}</0> per assistenza. Sii consapevole che il ritiro del consenso potrebbe ritardare o fermare il completamento della transazione o del servizio correlato."
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "You have unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
msgid "You have updated {memberName}."
|
||||
msgstr "Hai aggiornato {memberName}."
|
||||
@@ -14721,4 +14730,3 @@ msgstr "Il tuo codice di verifica:"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "your-domain.com another-domain.com"
|
||||
msgstr "tuo-dominio.com altro-dominio.com"
|
||||
|
||||
|
||||
@@ -2441,6 +2441,7 @@ msgstr "ブランディングロゴ"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "ブランディング設定"
|
||||
|
||||
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
|
||||
msgstr "現在、すべての組織メンバーがこのチームにアクセスできます"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Currently branding can only be configured for Teams and above plans."
|
||||
msgstr "現在、ブランディングは Teams プラン以上のみ設定できます。"
|
||||
|
||||
@@ -4214,8 +4216,8 @@ msgstr "文書は取り消されました"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
|
||||
#: packages/lib/server-only/admin/admin-super-delete-document.ts
|
||||
#: packages/lib/server-only/document/delete-document.ts
|
||||
msgid "Document Cancelled"
|
||||
msgstr "文書はキャンセルされました"
|
||||
|
||||
@@ -7942,6 +7944,11 @@ msgstr "オリジナル"
|
||||
msgid "Otherwise, the document will be created as a draft."
|
||||
msgstr "チェックを入れない場合、文書は下書きとして作成されます。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Overlapping fields detected"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
msgid "Override organisation settings"
|
||||
@@ -9197,9 +9204,7 @@ msgstr "封筒を再送信"
|
||||
msgid "Resend verification"
|
||||
msgstr "認証を再送"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx
|
||||
msgid "Reset"
|
||||
msgstr "リセット"
|
||||
@@ -9384,6 +9389,7 @@ msgid "Save as Template"
|
||||
msgstr "テンプレートとして保存"
|
||||
|
||||
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Save changes"
|
||||
msgstr "変更を保存"
|
||||
|
||||
@@ -10224,6 +10230,11 @@ msgstr "サイト設定"
|
||||
msgid "Skip"
|
||||
msgstr "スキップ"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
|
||||
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
|
||||
msgstr "一部の署名者に署名フィールドが割り当てられていません。続行する前に、各署名者に少なくとも 1 つの署名フィールドを割り当ててください。"
|
||||
@@ -12254,6 +12265,7 @@ msgstr "権限がありません"
|
||||
msgid "Uncompleted"
|
||||
msgstr "未完了"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
|
||||
msgid "Undo"
|
||||
msgstr "元に戻す"
|
||||
@@ -12303,6 +12315,10 @@ msgstr "リンク解除"
|
||||
msgid "Unpin"
|
||||
msgstr "ピン留めを解除"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
|
||||
msgid "Unsealed Documents"
|
||||
@@ -12322,9 +12338,6 @@ msgstr "無題のグループ"
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/tables/admin-claims-table.tsx
|
||||
@@ -12347,6 +12360,7 @@ msgstr "バナーを更新"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Update Billing"
|
||||
msgstr "請求情報を更新"
|
||||
|
||||
@@ -12374,10 +12388,6 @@ msgstr "メールを更新"
|
||||
msgid "Update Fields"
|
||||
msgstr "フィールドを更新"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
msgid "Update organisation"
|
||||
msgstr "組織を更新"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
@@ -12413,10 +12423,6 @@ msgstr "役割を更新"
|
||||
msgid "Update Subscription Claim"
|
||||
msgstr "サブスクリプションクレームを更新"
|
||||
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
msgid "Update team"
|
||||
msgstr "チームを更新"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
msgid "Update team email"
|
||||
@@ -12947,7 +12953,7 @@ msgstr "保留中"
|
||||
msgid "Waiting for others"
|
||||
msgstr "他の人の完了待ち"
|
||||
|
||||
#: packages/lib/server-only/document/send-pending-email.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
|
||||
msgid "Waiting for others to complete signing."
|
||||
msgstr "他の署名者による署名完了を待っています。"
|
||||
|
||||
@@ -13921,8 +13927,7 @@ msgstr "Documenso で {0} に参加するよう招待されています"
|
||||
msgid "You have been invited to join the following organisation"
|
||||
msgstr "次の組織に参加するよう招待されています。"
|
||||
|
||||
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts
|
||||
#: packages/lib/server-only/recipient/set-document-recipients.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
|
||||
msgid "You have been removed from a document"
|
||||
msgstr "ドキュメントから削除されました"
|
||||
|
||||
@@ -14042,6 +14047,10 @@ msgstr "アクセスを正常に取り消しました。"
|
||||
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
|
||||
msgstr "署名プロセスを完了する前であれば、電子署名の利用に対する同意をいつでも撤回する権利があります。同意を撤回するには、文書の送信者に連絡してください。送信者に連絡できない場合は、<0>{SUPPORT_EMAIL}</0> までお問い合わせください。同意を撤回すると、関連する取引やサービスの完了が遅延または中止される可能性がある点にご注意ください。"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "You have unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
msgid "You have updated {memberName}."
|
||||
msgstr "{memberName} を更新しました。"
|
||||
@@ -14721,4 +14730,3 @@ msgstr "認証コード:"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "your-domain.com another-domain.com"
|
||||
msgstr "your-domain.com another-domain.com"
|
||||
|
||||
|
||||
@@ -2441,6 +2441,7 @@ msgstr "브랜딩 로고"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "브랜딩 환경설정"
|
||||
|
||||
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
|
||||
msgstr "현재 모든 조직 구성원이 이 팀에 접근할 수 있습니다."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Currently branding can only be configured for Teams and above plans."
|
||||
msgstr "브랜딩은 현재 Teams 요금제 이상에서만 구성할 수 있습니다."
|
||||
|
||||
@@ -4214,8 +4216,8 @@ msgstr "문서가 취소되었습니다"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
|
||||
#: packages/lib/server-only/admin/admin-super-delete-document.ts
|
||||
#: packages/lib/server-only/document/delete-document.ts
|
||||
msgid "Document Cancelled"
|
||||
msgstr "문서가 취소됨"
|
||||
|
||||
@@ -7942,6 +7944,11 @@ msgstr "원본"
|
||||
msgid "Otherwise, the document will be created as a draft."
|
||||
msgstr "그렇지 않으면 문서는 초안으로 생성됩니다."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Overlapping fields detected"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
msgid "Override organisation settings"
|
||||
@@ -9197,9 +9204,7 @@ msgstr "봉투 다시 보내기"
|
||||
msgid "Resend verification"
|
||||
msgstr "인증 다시 보내기"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx
|
||||
msgid "Reset"
|
||||
msgstr "초기화"
|
||||
@@ -9384,6 +9389,7 @@ msgid "Save as Template"
|
||||
msgstr "템플릿으로 저장"
|
||||
|
||||
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Save changes"
|
||||
msgstr "변경 사항 저장"
|
||||
|
||||
@@ -10224,6 +10230,11 @@ msgstr "사이트 설정"
|
||||
msgid "Skip"
|
||||
msgstr "건너뛰기"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
|
||||
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
|
||||
msgstr "일부 서명자에게 서명 필드가 할당되지 않았습니다. 진행하기 전에 각 서명자에게 최소 1개 이상의 서명 필드를 할당해 주세요."
|
||||
@@ -12254,6 +12265,7 @@ msgstr "권한이 없습니다"
|
||||
msgid "Uncompleted"
|
||||
msgstr "미완료"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
|
||||
msgid "Undo"
|
||||
msgstr "실행 취소"
|
||||
@@ -12303,6 +12315,10 @@ msgstr "연결 해제"
|
||||
msgid "Unpin"
|
||||
msgstr "고정 해제"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
|
||||
msgid "Unsealed Documents"
|
||||
@@ -12322,9 +12338,6 @@ msgstr "제목 없는 그룹"
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/tables/admin-claims-table.tsx
|
||||
@@ -12347,6 +12360,7 @@ msgstr "배너 업데이트"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Update Billing"
|
||||
msgstr "결제 정보 업데이트"
|
||||
|
||||
@@ -12374,10 +12388,6 @@ msgstr "이메일 업데이트"
|
||||
msgid "Update Fields"
|
||||
msgstr "필드 업데이트"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
msgid "Update organisation"
|
||||
msgstr "조직 업데이트"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
@@ -12413,10 +12423,6 @@ msgstr "역할 업데이트"
|
||||
msgid "Update Subscription Claim"
|
||||
msgstr "구독 클레임 업데이트"
|
||||
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
msgid "Update team"
|
||||
msgstr "팀 업데이트"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
msgid "Update team email"
|
||||
@@ -12947,7 +12953,7 @@ msgstr "대기 중"
|
||||
msgid "Waiting for others"
|
||||
msgstr "다른 사람을 기다리는 중"
|
||||
|
||||
#: packages/lib/server-only/document/send-pending-email.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
|
||||
msgid "Waiting for others to complete signing."
|
||||
msgstr "다른 서명자들이 이 문서에 서명 완료하기를 기다리는 중입니다."
|
||||
|
||||
@@ -13921,8 +13927,7 @@ msgstr "Documenso에서 {0} 조직에 초대되었습니다."
|
||||
msgid "You have been invited to join the following organisation"
|
||||
msgstr "다음 조직에 참여하라는 초대를 받았습니다."
|
||||
|
||||
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts
|
||||
#: packages/lib/server-only/recipient/set-document-recipients.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
|
||||
msgid "You have been removed from a document"
|
||||
msgstr "문서에서 제거되었습니다."
|
||||
|
||||
@@ -14042,6 +14047,10 @@ msgstr "접근 권한을 성공적으로 철회했습니다."
|
||||
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
|
||||
msgstr "전자 서명을 완료하기 전 언제든지 전자 서명 사용에 대한 동의를 철회할 권리가 있습니다. 동의를 철회하려면 문서 발송자에게 문의해 주세요. 발송자에게 연락할 수 없는 경우 <0>{SUPPORT_EMAIL}</0>로 도움을 요청해 주세요. 동의를 철회하면 관련 거래나 서비스가 지연되거나 중단될 수 있습니다."
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "You have unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
msgid "You have updated {memberName}."
|
||||
msgstr "{memberName}을(를) 업데이트했습니다."
|
||||
@@ -14721,4 +14730,3 @@ msgstr "인증 코드:"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "your-domain.com another-domain.com"
|
||||
msgstr "your-domain.com another-domain.com"
|
||||
|
||||
|
||||
@@ -2441,6 +2441,7 @@ msgstr "Branding-logo"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "Brandingvoorkeuren"
|
||||
|
||||
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
|
||||
msgstr "Momenteel hebben alle organisatieleden toegang tot dit team"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Currently branding can only be configured for Teams and above plans."
|
||||
msgstr "Branding kan momenteel alleen worden geconfigureerd voor Teams- en hogere abonnementen."
|
||||
|
||||
@@ -4214,8 +4216,8 @@ msgstr "Document geannuleerd"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
|
||||
#: packages/lib/server-only/admin/admin-super-delete-document.ts
|
||||
#: packages/lib/server-only/document/delete-document.ts
|
||||
msgid "Document Cancelled"
|
||||
msgstr "Document geannuleerd"
|
||||
|
||||
@@ -7942,6 +7944,11 @@ msgstr "Origineel"
|
||||
msgid "Otherwise, the document will be created as a draft."
|
||||
msgstr "Anders wordt het document als concept aangemaakt."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Overlapping fields detected"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
msgid "Override organisation settings"
|
||||
@@ -9197,9 +9204,7 @@ msgstr "Envelope opnieuw verzenden"
|
||||
msgid "Resend verification"
|
||||
msgstr "Verificatie opnieuw verzenden"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx
|
||||
msgid "Reset"
|
||||
msgstr "Resetten"
|
||||
@@ -9384,6 +9389,7 @@ msgid "Save as Template"
|
||||
msgstr "Opslaan als sjabloon"
|
||||
|
||||
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Save changes"
|
||||
msgstr "Wijzigingen opslaan"
|
||||
|
||||
@@ -10224,6 +10230,11 @@ msgstr "Site‑instellingen"
|
||||
msgid "Skip"
|
||||
msgstr "Overslaan"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
|
||||
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
|
||||
msgstr "Sommige ondertekenaars hebben geen handtekeningveld toegewezen gekregen. Wijs ten minste 1 handtekeningveld toe aan elke ondertekenaar voordat je doorgaat."
|
||||
@@ -12254,6 +12265,7 @@ msgstr "Niet gemachtigd"
|
||||
msgid "Uncompleted"
|
||||
msgstr "Onvoltooid"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
|
||||
msgid "Undo"
|
||||
msgstr "Ongedaan maken"
|
||||
@@ -12303,6 +12315,10 @@ msgstr "Ontkoppelen"
|
||||
msgid "Unpin"
|
||||
msgstr "Losmaken"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
|
||||
msgid "Unsealed Documents"
|
||||
@@ -12322,9 +12338,6 @@ msgstr "Naamloze groep"
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/tables/admin-claims-table.tsx
|
||||
@@ -12347,6 +12360,7 @@ msgstr "Banner bijwerken"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Update Billing"
|
||||
msgstr "Facturering bijwerken"
|
||||
|
||||
@@ -12374,10 +12388,6 @@ msgstr "E-mail bijwerken"
|
||||
msgid "Update Fields"
|
||||
msgstr "Velden bijwerken"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
msgid "Update organisation"
|
||||
msgstr "Organisatie bijwerken"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
@@ -12413,10 +12423,6 @@ msgstr "Rol bijwerken"
|
||||
msgid "Update Subscription Claim"
|
||||
msgstr "Abonnementsclaim bijwerken"
|
||||
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
msgid "Update team"
|
||||
msgstr "Team bijwerken"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
msgid "Update team email"
|
||||
@@ -12947,7 +12953,7 @@ msgstr "Wachten"
|
||||
msgid "Waiting for others"
|
||||
msgstr "Wachten op anderen"
|
||||
|
||||
#: packages/lib/server-only/document/send-pending-email.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
|
||||
msgid "Waiting for others to complete signing."
|
||||
msgstr "Wachten tot anderen het ondertekenen hebben voltooid."
|
||||
|
||||
@@ -13921,8 +13927,7 @@ msgstr "Je bent uitgenodigd om {0} op Documenso te joinen"
|
||||
msgid "You have been invited to join the following organisation"
|
||||
msgstr "Je bent uitgenodigd om lid te worden van de volgende organisatie"
|
||||
|
||||
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts
|
||||
#: packages/lib/server-only/recipient/set-document-recipients.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
|
||||
msgid "You have been removed from a document"
|
||||
msgstr "Je bent verwijderd uit een document"
|
||||
|
||||
@@ -14042,6 +14047,10 @@ msgstr "Je hebt de toegang succesvol ingetrokken."
|
||||
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
|
||||
msgstr "Je hebt het recht je toestemming voor het gebruik van elektronische handtekeningen op elk moment vóór voltooiing van het ondertekeningsproces in te trekken. Neem hiervoor contact op met de verzender van het document. Als dat niet lukt, kun je contact opnemen met <0>{SUPPORT_EMAIL}</0> voor hulp. Houd er rekening mee dat het intrekken van toestemming de voltooiing van de betreffende transactie of dienst kan vertragen of stopzetten."
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "You have unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
msgid "You have updated {memberName}."
|
||||
msgstr "Je hebt {memberName} bijgewerkt."
|
||||
@@ -14721,4 +14730,3 @@ msgstr "Uw verificatiecode:"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "your-domain.com another-domain.com"
|
||||
msgstr "your-domain.com another-domain.com"
|
||||
|
||||
|
||||
@@ -2441,6 +2441,7 @@ msgstr "Logo marki"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "Ustawienia brandingu"
|
||||
|
||||
@@ -2501,7 +2502,6 @@ msgstr "Akceptując prośbę, przyznasz zespołowi {0} następujące uprawnienia
|
||||
|
||||
#: packages/email/templates/confirm-team-email.tsx
|
||||
msgid "By accepting this request, you will be granting <0>{teamName}</0> access to:"
|
||||
msgstr "Akceptując prośbę, umożliwisz zespołowi <0>{teamName}</0> na:"
|
||||
msgstr "Akceptując prośbę, umożliwisz zespołowi <0>{teamName}</0>:"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
@@ -3573,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
|
||||
msgstr "Obecnie wszyscy użytkownicy organizacji mogą uzyskać dostęp tego zespołu"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Currently branding can only be configured for Teams and above plans."
|
||||
msgstr "Branding możesz skonfigurować tylko w planie Teams i wyższym."
|
||||
|
||||
@@ -4215,8 +4216,8 @@ msgstr "Anulowano dokument"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
|
||||
#: packages/lib/server-only/admin/admin-super-delete-document.ts
|
||||
#: packages/lib/server-only/document/delete-document.ts
|
||||
msgid "Document Cancelled"
|
||||
msgstr "Dokument został anulowany"
|
||||
|
||||
@@ -7943,6 +7944,11 @@ msgstr "Oryginalny"
|
||||
msgid "Otherwise, the document will be created as a draft."
|
||||
msgstr "W przeciwnym razie dokument zostanie utworzony jako wersja robocza."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Overlapping fields detected"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
msgid "Override organisation settings"
|
||||
@@ -9198,9 +9204,7 @@ msgstr "Wyślij ponownie kopertę"
|
||||
msgid "Resend verification"
|
||||
msgstr "Wyślij ponownie wiadomość weryfikacyjną"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx
|
||||
msgid "Reset"
|
||||
msgstr "Resetuj"
|
||||
@@ -9385,6 +9389,7 @@ msgid "Save as Template"
|
||||
msgstr "Zapisz jako szablon"
|
||||
|
||||
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Save changes"
|
||||
msgstr "Zapisz zmiany"
|
||||
|
||||
@@ -10225,6 +10230,11 @@ msgstr "Ustawienia strony"
|
||||
msgid "Skip"
|
||||
msgstr "Pomiń"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
|
||||
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
|
||||
msgstr "Niektórym podpisującym nie przypisano pola podpisu. Przypisz co najmniej jedno pole podpisu do każdego podpisującego."
|
||||
@@ -12255,6 +12265,7 @@ msgstr "Nieautoryzowany"
|
||||
msgid "Uncompleted"
|
||||
msgstr "Niezakończono"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
|
||||
msgid "Undo"
|
||||
msgstr "Cofnij"
|
||||
@@ -12304,6 +12315,10 @@ msgstr "Rozłącz"
|
||||
msgid "Unpin"
|
||||
msgstr "Odepnij"
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "Unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
|
||||
msgid "Unsealed Documents"
|
||||
@@ -12323,9 +12338,6 @@ msgstr "Grupa bez nazwy"
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/email-preferences-form.tsx
|
||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/tables/admin-claims-table.tsx
|
||||
@@ -12348,6 +12360,7 @@ msgstr "Zaktualizuj baner"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Update Billing"
|
||||
msgstr "Zaktualizuj płatności"
|
||||
|
||||
@@ -12375,10 +12388,6 @@ msgstr "Zaktualizuj adres e-mail"
|
||||
msgid "Update Fields"
|
||||
msgstr "Zaktualizuj pola"
|
||||
|
||||
#: apps/remix/app/components/forms/organisation-update-form.tsx
|
||||
msgid "Update organisation"
|
||||
msgstr "Zaktualizuj organizację"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
@@ -12414,10 +12423,6 @@ msgstr "Zaktualizuj rolę"
|
||||
msgid "Update Subscription Claim"
|
||||
msgstr "Zaktualizuj subskrypcję"
|
||||
|
||||
#: apps/remix/app/components/forms/team-update-form.tsx
|
||||
msgid "Update team"
|
||||
msgstr "Zaktualizuj zespół"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
msgid "Update team email"
|
||||
@@ -12948,7 +12953,7 @@ msgstr "Oczekiwanie"
|
||||
msgid "Waiting for others"
|
||||
msgstr "Oczekiwanie na innych"
|
||||
|
||||
#: packages/lib/server-only/document/send-pending-email.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
|
||||
msgid "Waiting for others to complete signing."
|
||||
msgstr "Oczekiwanie na zakończenie podpisywania przez innych."
|
||||
|
||||
@@ -13922,8 +13927,7 @@ msgstr "Dołącz do organizacji {0} w Documenso"
|
||||
msgid "You have been invited to join the following organisation"
|
||||
msgstr "Masz zaproszenie do dołączenia do następującej organizacji"
|
||||
|
||||
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts
|
||||
#: packages/lib/server-only/recipient/set-document-recipients.ts
|
||||
#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
|
||||
msgid "You have been removed from a document"
|
||||
msgstr "Usunięto Cię z dokumentu"
|
||||
|
||||
@@ -14043,6 +14047,10 @@ msgstr "Dostęp został unieważniony."
|
||||
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
|
||||
msgstr "Masz prawo wycofać swoją zgodę na używanie podpisów elektronicznych w dowolnym momencie przed zakończeniem procesu podpisywania. Aby wycofać zgodę, skontaktuj się z nadawcą dokumentu. Jeśli nie możesz skontaktować się z nadawcą, napisz do nas na adres <0>{SUPPORT_EMAIL}</0>. Pamiętaj, że wycofanie zgody może opóźnić lub wstrzymać realizację danej transakcji lub usługi."
|
||||
|
||||
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
|
||||
msgid "You have unsaved changes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
msgid "You have updated {memberName}."
|
||||
msgstr "Użytkownik {memberName} został zaktualizowany."
|
||||
@@ -14722,4 +14730,3 @@ msgstr "Twój kod weryfikacyjny:"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "your-domain.com another-domain.com"
|
||||
msgstr "twoja-domena.pl inna-domena.pl"
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user