mirror of
https://github.com/documenso/documenso.git
synced 2026-08-16 19:41:57 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3dc96e8c1 | ||
|
|
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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -275,9 +275,9 @@ export const ManagePublicTemplateDialog = ({
|
||||
|
||||
<TableCell>
|
||||
{selectedTemplateId === row.id ? (
|
||||
<CheckCircle2Icon className="h-5 w-5 text-neutral-600 dark:text-neutral-200" />
|
||||
<CheckCircle2Icon className="h-5 w-5 text-foreground" />
|
||||
) : (
|
||||
<CircleIcon className="h-5 w-5 text-neutral-300 dark:text-neutral-600" />
|
||||
<CircleIcon className="h-5 w-5 text-muted-foreground/40" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -215,7 +215,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
||||
<li className="relative" key={index}>
|
||||
<div className="absolute -left-12">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 font-bold text-sm">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-border font-bold text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,8 +259,8 @@ export const TemplateDirectLinkDialog = ({
|
||||
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
|
||||
<DialogContent className="relative">
|
||||
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-background/50">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -317,9 +317,9 @@ export const TemplateDirectLinkDialog = ({
|
||||
|
||||
<TableCell>
|
||||
{selectedRecipientId === row.id ? (
|
||||
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
|
||||
<CircleDotIcon className="h-5 w-5 text-muted-foreground/40" />
|
||||
) : (
|
||||
<CircleIcon className="h-5 w-5 text-neutral-300" />
|
||||
<CircleIcon className="h-5 w-5 text-muted-foreground/40" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -146,7 +146,7 @@ export const AvatarImageForm = ({ className, team, organisation }: AvatarImageFo
|
||||
<div className="relative">
|
||||
<Avatar className="h-16 w-16 border-2 border-solid">
|
||||
{avatarImageId && <AvatarImage src={formatAvatarUrl(avatarImageId)} />}
|
||||
<AvatarFallback className="text-gray-400 text-sm">{initials}</AvatarFallback>
|
||||
<AvatarFallback className="text-muted-foreground text-sm">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{hasAvatarImage && (
|
||||
|
||||
@@ -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
-1
@@ -155,7 +155,7 @@ export const DocumentSigningFieldContainer = ({
|
||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) && field.fieldMeta?.label && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute -top-16 right-0 left-0 rounded-md p-2 text-center text-gray-700 text-xs',
|
||||
'absolute -top-16 right-0 left-0 rounded-md p-2 text-center text-foreground text-xs',
|
||||
{
|
||||
'border border-border bg-foreground/5': !field.inserted,
|
||||
},
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
}, [recipientFieldsRemaining, selectedAssistantRecipientFields, currentEnvelopeItem]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-screen bg-gray-50 dark:bg-background">
|
||||
<div className="min-h-screen w-screen bg-muted dark:bg-background">
|
||||
<SignFieldEmailDialog.Root />
|
||||
<SignFieldTextDialog.Root />
|
||||
<SignFieldNumberDialog.Root />
|
||||
|
||||
@@ -75,7 +75,7 @@ export const DocumentPageViewRecentActivity = ({ documentId, userId }: DocumentP
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-border" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -109,27 +109,27 @@ export const DocumentPageViewRecentActivity = ({ documentId, userId }: DocumentP
|
||||
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget text-foreground/40">
|
||||
{match(auditLog.type)
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
|
||||
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
|
||||
<div className="rounded-full border border-border bg-widget p-1">
|
||||
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
|
||||
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
|
||||
<div className="rounded-full border border-border bg-widget p-1">
|
||||
<CheckIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => (
|
||||
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
|
||||
<div className="rounded-full border border-border bg-widget p-1">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
|
||||
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
|
||||
<div className="rounded-full border border-border bg-widget p-1">
|
||||
<MailOpen className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-border" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -261,7 +261,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
|
||||
{/* Right Section - Form Fields Panel */}
|
||||
{currentEnvelopeItem && false && (
|
||||
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-gray-200 border-l bg-white py-4">
|
||||
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-4">
|
||||
{/* Add fields section. */}
|
||||
<section className="px-4">
|
||||
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||
@@ -326,7 +326,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
|
||||
{/* Recipient selector section. */}
|
||||
<section className="px-4">
|
||||
<h3 className="mb-2 font-semibold text-gray-900 text-sm">
|
||||
<h3 className="mb-2 font-semibold text-foreground text-sm">
|
||||
<Trans>Selected Recipient</Trans>
|
||||
</h3>
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export const EnvelopeItemTitleInput = ({
|
||||
{/* Hidden span to measure text width */}
|
||||
<span
|
||||
ref={measureRef}
|
||||
className="pointer-events-none absolute top-0 left-0 whitespace-nowrap font-medium text-gray-600 text-sm opacity-0"
|
||||
className="pointer-events-none absolute top-0 left-0 whitespace-nowrap font-medium text-muted-foreground text-sm opacity-0"
|
||||
style={{ font: 'inherit' }}
|
||||
>
|
||||
{envelopeItemTitle || placeholder}
|
||||
|
||||
@@ -271,7 +271,7 @@ export const EnvelopeEditor = () => {
|
||||
`cursor-pointer rounded-lg text-left transition-colors ${
|
||||
isActive
|
||||
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
|
||||
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
|
||||
: 'border border-border hover:bg-muted/50'
|
||||
}`,
|
||||
{
|
||||
'p-3': !minimizeLeftSidebar,
|
||||
@@ -284,10 +284,10 @@ export const EnvelopeEditor = () => {
|
||||
className={`rounded border p-2 ${
|
||||
isActive
|
||||
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
|
||||
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
|
||||
: 'border-muted bg-muted'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`} />
|
||||
<Icon className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-muted-foreground'}`} />
|
||||
</div>
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
|
||||
@@ -31,14 +31,14 @@ export const EnvelopeItemSelector = ({
|
||||
>
|
||||
<div
|
||||
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full font-medium text-xs ${
|
||||
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
|
||||
isSelected ? 'bg-green-100 text-green-600' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{number}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<div className="truncate font-medium text-sm">{primaryText}</div>
|
||||
<div className="text-gray-500 text-xs">{secondaryText}</div>
|
||||
<div className="text-muted-foreground text-xs">{secondaryText}</div>
|
||||
</div>
|
||||
{actionSlot ?? (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ export const TemplateDirectLinkBadge = ({ token, enabled, className }: TemplateD
|
||||
<button
|
||||
title="Copy direct link"
|
||||
className={cn(
|
||||
'flex flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600',
|
||||
'flex flex-row items-center rounded border border-border bg-muted px-1.5 py-0.5 text-xs',
|
||||
className,
|
||||
)}
|
||||
onClick={async () => onCopyClick(token)}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const TemplatePageViewRecentActivity = ({
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-border" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -108,7 +108,7 @@ export const TemplatePageViewRecentActivity = ({
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget text-foreground/40">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-border" />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
|
||||
@@ -127,8 +127,8 @@ export const AdminDashboardUsersTable = ({ users, totalPages, perPage, page }: A
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -204,8 +204,8 @@ export const AdminOrganisationOverviewTable = ({
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -110,7 +110,7 @@ export const AdminOrganisationsTable = ({
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 font-medium text-xs ${
|
||||
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
isPaid ? 'bg-green-100 text-green-800' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isPaid ? (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -253,8 +253,8 @@ export const OrganisationInsightsTable = ({
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ export const SettingsPublicProfileTemplatesTable = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-6 divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
|
||||
<div className="mt-6 divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
{/* Loading and error handling states. */}
|
||||
{publicDirectTemplates.length === 0 && (
|
||||
<>
|
||||
@@ -132,7 +132,7 @@ export const SettingsPublicProfileTemplatesTable = () => {
|
||||
|
||||
<div>
|
||||
<p className="break-all text-sm">{template.publicTitle}</p>
|
||||
<p className="break-all text-neutral-400 text-xs">{template.publicDescription}</p>
|
||||
<p className="break-all text-muted-foreground text-xs">{template.publicDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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">
|
||||
@@ -147,7 +134,7 @@ export const TemplatesTable = ({
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<div className="mb-2 flex w-fit flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600">
|
||||
<div className="mb-2 flex w-fit flex-row items-center rounded border border-border bg-muted px-1.5 py-0.5 text-xs">
|
||||
<Link2Icon className="mr-1 h-3 w-3" />
|
||||
<Trans>direct link</Trans>
|
||||
</div>
|
||||
@@ -323,8 +310,8 @@ export const TemplatesTable = ({
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -149,8 +149,8 @@ export default function AdminDocumentsPage() {
|
||||
</DataTable>
|
||||
|
||||
{isFindDocumentsLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -182,8 +182,8 @@ export default function AdminEmailDomainsPage() {
|
||||
</DataTable>
|
||||
|
||||
{isFindEmailDomainsLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -173,8 +173,8 @@ export default function AdminUnsealedDocumentsPage() {
|
||||
</DataTable>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 border border-solid">
|
||||
{org.avatarImageId && <AvatarImage src={formatAvatarUrl(org.avatarImageId)} />}
|
||||
<AvatarFallback className="text-gray-400 text-sm">
|
||||
<AvatarFallback className="text-muted-foreground text-sm">
|
||||
{org.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -182,7 +182,7 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 border border-solid">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-gray-400 text-sm">
|
||||
<AvatarFallback className="text-muted-foreground text-sm">
|
||||
{team.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function OrganisationSettingsTeamsPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 border-2 border-solid">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-gray-400 text-sm">
|
||||
<AvatarFallback className="text-muted-foreground text-sm">
|
||||
{team.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -95,8 +95,8 @@ export default function WebhookPage() {
|
||||
<WebhookCreateDialog />
|
||||
</SettingsHeader>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -59,7 +59,9 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
<Avatar className="h-24 w-24 border-2 border-solid dark:border-border">
|
||||
{publicProfile.avatarImageId && <AvatarImage src={formatAvatarUrl(publicProfile.avatarImageId)} />}
|
||||
|
||||
<AvatarFallback className="text-gray-400 text-sm">{extractInitials(publicProfile.name)}</AvatarFallback>
|
||||
<AvatarFallback className="text-muted-foreground text-sm">
|
||||
{extractInitials(publicProfile.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="mt-4 flex flex-row items-center justify-center">
|
||||
@@ -138,7 +140,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
<Table className="w-full" overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-full rounded-tl-md bg-neutral-50 dark:bg-neutral-700">
|
||||
<TableHead className="w-full rounded-tl-md bg-muted">
|
||||
<Trans>Documents</Trans>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
|
||||
@@ -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
+4
-4
@@ -15,7 +15,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",
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
@@ -4661,9 +4661,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@libpdf/core": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.0.tgz",
|
||||
"integrity": "sha512-G9nZRjf9DGDJaS/C23YWogk8akPM7O/6HfMslxVsKTKRbbbb+0szpQIetcGGUGRu7KtmBDmGDWCgz//DXSmq8A==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.1.tgz",
|
||||
"integrity": "sha512-DWGxWw1na8oFPixz+b6kOgu4tMD8xJLDgyPGVUNqIswO5POHAxsqmTvUvDW0IrwHP7kFRHBV3I3T/KhTABf9rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
|
||||
+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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user