mirror of
https://github.com/documenso/documenso.git
synced 2026-07-02 01:01:00 +10:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d20d9595c9 | |||
| 562d78e2d7 | |||
| 3b110cf70d | |||
| 7062fadf0b | |||
| 9cdd2e7ff9 | |||
| a70b0702c3 | |||
| 1f170ef5e5 | |||
| 8f68393241 | |||
| 381293af0c | |||
| 97835b8dbb | |||
| 977d07330b |
@@ -0,0 +1,382 @@
|
||||
---
|
||||
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.
|
||||
@@ -180,6 +180,20 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=
|
||||
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
|
||||
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
|
||||
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
|
||||
# OPTIONAL: Set to "true" to disable all signin methods (email, Google, Microsoft, OIDC).
|
||||
NEXT_PUBLIC_DISABLE_SIGNIN=
|
||||
# OPTIONAL: Set to "true" to disable email/password signin only. Also closes /forgot-password and /reset-password.
|
||||
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=
|
||||
# OPTIONAL: Set to "true" to hide the Google signin button.
|
||||
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=
|
||||
# OPTIONAL: Set to "true" to hide the Microsoft signin button.
|
||||
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=
|
||||
# OPTIONAL: Set to "true" to hide the OIDC signin button.
|
||||
NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=
|
||||
# OPTIONAL: When OIDC is the only enabled signin transport, /signin auto-redirects
|
||||
# to the OIDC provider (rendering only a spinner). Set to "true" to disable this
|
||||
# and keep showing the signin page.
|
||||
NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=
|
||||
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
|
||||
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
||||
|
||||
|
||||
@@ -74,3 +74,5 @@ tmp/
|
||||
|
||||
# opencode
|
||||
.opencode/package-lock.json
|
||||
|
||||
SUPPORT_KNOWLEDGE_BASE.md
|
||||
|
||||
@@ -272,6 +272,12 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch. Disable all signin methods application-wide | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin. Also closes `/forgot-password` and `/reset-password` | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable the automatic `/signin` redirect when OIDC is the only enabled transport | `false` |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
|
||||
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
|
||||
|
||||
@@ -303,6 +309,44 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
||||
```
|
||||
|
||||
### Sign-in Restrictions
|
||||
|
||||
You can control which methods are available for users to sign in with the following environment variables:
|
||||
|
||||
- **`NEXT_PUBLIC_DISABLE_SIGNIN`** (master switch): Set to `true` to block all signin methods (email/password, Google, Microsoft, OIDC). Hides every signin entry point on `/signin` and rejects email/password signin server-side with a `SIGNIN_DISABLED` error.
|
||||
- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN`**: Set to `true` to disable email/password signin only. The email/password form is hidden, the `/forgot-password` and `/reset-password` pages redirect to `/signin`, and the corresponding server endpoints reject requests. SSO signin is unaffected.
|
||||
- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNIN`**: Set to `true` to hide the matching SSO button on the signin page. Useful when an SSO provider is kept configured for account linking but not advertised as a signin entry point.
|
||||
|
||||
These flags are opt-in: when none are set, signin behaviour is unchanged from a stock Documenso instance.
|
||||
|
||||
```bash
|
||||
# Allow only OIDC signin (e.g. enterprise SSO-only)
|
||||
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
|
||||
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
|
||||
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
|
||||
|
||||
# Or disable signin entirely
|
||||
NEXT_PUBLIC_DISABLE_SIGNIN="true"
|
||||
```
|
||||
|
||||
### OIDC Auto-redirect
|
||||
|
||||
When OIDC is the only enabled signin transport on your instance, `/signin` automatically redirects users straight to the OIDC provider instead of showing the signin form. The page renders a spinner while the redirect happens. No extra configuration is required — disabling every other signin method is enough to trigger it.
|
||||
|
||||
- **`NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT`**: Set to `true` to opt out of the automatic redirect and keep rendering the signin page even when OIDC is the only enabled transport.
|
||||
|
||||
The redirect only triggers when OIDC is configured and email/password, Google, and Microsoft signin are all disabled. If any other transport remains enabled, the signin form is shown as normal.
|
||||
|
||||
```bash
|
||||
# OIDC-only signin: disabling all other methods auto-redirects to the provider
|
||||
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
|
||||
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
|
||||
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
|
||||
|
||||
# Opt out of the auto-redirect while still OIDC-only
|
||||
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI Features
|
||||
@@ -446,6 +490,16 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
|
||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
|
||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
||||
|
||||
# Sign-in restrictions (optional)
|
||||
# NEXT_PUBLIC_DISABLE_SIGNIN="true"
|
||||
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
|
||||
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
|
||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
|
||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN="true"
|
||||
|
||||
# Opt out of the automatic OIDC redirect when OIDC is the only enabled transport (optional)
|
||||
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -163,6 +163,19 @@ NEXT_PUBLIC_DISABLE_SIGNUP=false
|
||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
|
||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
|
||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
|
||||
|
||||
# Signin restrictions (optional)
|
||||
# Master switch — disables every signin method
|
||||
# NEXT_PUBLIC_DISABLE_SIGNIN=true
|
||||
# Per-method switches (optional). Each disables that signin path.
|
||||
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=true
|
||||
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=true
|
||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=true
|
||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=true
|
||||
|
||||
# When OIDC is the only enabled transport, /signin auto-redirects to the provider.
|
||||
# Set this to opt out and keep showing the signin page (optional).
|
||||
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=true
|
||||
```
|
||||
|
||||
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
|
||||
|
||||
@@ -112,6 +112,12 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
|
||||
|
||||
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
|
||||
|
||||
|
||||
@@ -159,6 +159,12 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`| Hide the Microsoft signin button | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||
export type SignInFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
isEmailPasswordSigninEnabled?: boolean;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
@@ -68,6 +69,7 @@ export type SignInFormProps = {
|
||||
export const SignInForm = ({
|
||||
className,
|
||||
initialEmail,
|
||||
isEmailPasswordSigninEnabled = true,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
@@ -324,66 +326,78 @@ export const SignInForm = ({
|
||||
<Form {...form}>
|
||||
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting || isPasskeyLoading}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
{isEmailPasswordSigninEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
<FormMessage />
|
||||
|
||||
<p className="mt-2 text-right">
|
||||
<Link to="/forgot-password" className="text-muted-foreground text-sm duration-200 hover:opacity-70">
|
||||
<Trans>Forgot your password?</Trans>
|
||||
</Link>
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<p className="mt-2 text-right">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
||||
>
|
||||
<Trans>Forgot your password?</Trans>
|
||||
</Link>
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
appearance: 'always',
|
||||
}}
|
||||
/>
|
||||
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
appearance: 'always',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
loading={isSubmitting}
|
||||
className="dark:bg-documenso dark:hover:opacity-90"
|
||||
>
|
||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button type="submit" size="lg" loading={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||
</Button>
|
||||
|
||||
{!isEmbeddedRedirect && (
|
||||
<>
|
||||
{hasSocialAuthEnabled && (
|
||||
{isEmailPasswordSigninEnabled && hasSocialAuthEnabled && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="bg-transparent text-muted-foreground">
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { toSafeHref } from '@documenso/lib/utils/is-http-url';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
@@ -53,7 +54,7 @@ export const DocumentSigningAttachmentsPopover = ({
|
||||
{attachments?.data.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={attachment.data}
|
||||
href={toSafeHref(attachment.data)}
|
||||
title={attachment.data}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -24,7 +25,7 @@ export type DocumentAttachmentsPopoverProps = {
|
||||
|
||||
const ZAttachmentFormSchema = z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
url: z.string().url('Must be a valid URL'),
|
||||
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
|
||||
});
|
||||
|
||||
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||
@@ -156,7 +157,7 @@ export const DocumentAttachmentsPopover = ({
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-sm">{attachment.label}</p>
|
||||
<a
|
||||
href={attachment.data}
|
||||
href={toSafeHref(attachment.data)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
@@ -22,7 +23,7 @@ export type EmbeddedEditorAttachmentPopoverProps = {
|
||||
|
||||
const ZAttachmentFormSchema = z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
url: z.string().url('Must be a valid URL'),
|
||||
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
|
||||
});
|
||||
|
||||
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||
@@ -117,7 +118,7 @@ export const EmbeddedEditorAttachmentPopover = ({
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-sm">{attachment.label}</p>
|
||||
<a
|
||||
href={attachment.data}
|
||||
href={toSafeHref(attachment.data)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
|
||||
|
||||
+92
-54
@@ -49,6 +49,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||
|
||||
/**
|
||||
* Whether the field was automatically selected on creation (drag-drop or marquee).
|
||||
*
|
||||
* We purposefully supress the floating toolbar for newly created fields.
|
||||
*/
|
||||
const [isAutoSelectedField, setIsAutoSelectedField] = useState(false);
|
||||
|
||||
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
|
||||
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
|
||||
pageData,
|
||||
@@ -237,10 +244,26 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
fieldGroup.off('transformend');
|
||||
fieldGroup.off('dragend');
|
||||
|
||||
// Set up field selection.
|
||||
fieldGroup.on('click', () => {
|
||||
// Set up field selection. Shift + click toggles this field in/out of the current
|
||||
// multi-selection, so fields can be added to a group by clicking them --
|
||||
// complementing marquee drag-selection. A plain click (no modifier) selects just
|
||||
// this field.
|
||||
fieldGroup.on('click', (event) => {
|
||||
removePendingField();
|
||||
setSelectedFields([fieldGroup]);
|
||||
|
||||
const isMultiSelectModifier = event.evt.shiftKey;
|
||||
|
||||
if (isMultiSelectModifier) {
|
||||
const currentNodes = interactiveTransformer.current?.nodes() ?? [];
|
||||
const isAlreadySelected = currentNodes.includes(fieldGroup);
|
||||
|
||||
setSelectedFields(
|
||||
isAlreadySelected ? currentNodes.filter((node) => node !== fieldGroup) : [...currentNodes, fieldGroup],
|
||||
);
|
||||
} else {
|
||||
setSelectedFields([fieldGroup]);
|
||||
}
|
||||
|
||||
pageLayer.current?.batchDraw();
|
||||
});
|
||||
|
||||
@@ -445,43 +468,18 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
}
|
||||
});
|
||||
|
||||
// Clicks should select/deselect shapes
|
||||
// Clicking empty stage area clears the selection. Field clicks -- including
|
||||
// Shift+click multi-select -- are handled by each field group's own click
|
||||
// handler in `unsafeRenderFieldOnLayer`.
|
||||
currentStage.on('click tap', (e) => {
|
||||
// if we are selecting with rect, do nothing
|
||||
// If we are selecting with the marquee rectangle, do nothing.
|
||||
if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If empty area clicked, remove all selections
|
||||
// If empty area clicked, remove all selections.
|
||||
if (e.target === stage.current) {
|
||||
setSelectedFields([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do nothing if field not clicked, or if field is not editable
|
||||
if (!e.target.hasName('field-group') || e.target.draggable() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// do we pressed shift or ctrl?
|
||||
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
|
||||
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
|
||||
|
||||
if (!metaPressed && !isSelected) {
|
||||
// if no key pressed and the node is not selected
|
||||
// select just one
|
||||
setSelectedFields([e.target]);
|
||||
} else if (metaPressed && isSelected) {
|
||||
// if we pressed keys and node was selected
|
||||
// we need to remove it from selection:
|
||||
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
|
||||
// remove node from array
|
||||
nodes.splice(nodes.indexOf(e.target), 1);
|
||||
setSelectedFields(nodes);
|
||||
} else if (metaPressed && !isSelected) {
|
||||
// add the node into selection
|
||||
const nodes = transformer.nodes().concat([e.target]);
|
||||
setSelectedFields(nodes);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -521,13 +519,48 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
setSelectedFields(liveSelectedFieldGroups);
|
||||
}
|
||||
|
||||
// Mirror the editor's single selected field onto the canvas (Konva) selection.
|
||||
//
|
||||
// `addField` already marks a newly created field as the selected field, so this
|
||||
// makes a field placed via the palette (drag-drop) or marquee creation show its
|
||||
// resize handles immediately -- no second click needed. It also clears the canvas
|
||||
// selection when the selected field is cleared (e.g. when the author starts
|
||||
// placing another field), so the floating action toolbar can't intercept the next
|
||||
// placement click. Runs after the render loop above so the field's group exists.
|
||||
const selectedFormId = editorFields.selectedField?.formId ?? null;
|
||||
const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1;
|
||||
|
||||
if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) {
|
||||
const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId;
|
||||
|
||||
if (!isAlreadySelected) {
|
||||
const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`);
|
||||
|
||||
if (fieldGroupToSelect instanceof Konva.Group) {
|
||||
setSelectedFields([fieldGroupToSelect], { isAutoSelect: true });
|
||||
}
|
||||
}
|
||||
} else if (selectedFormId === null && isSingleCanvasSelection) {
|
||||
setSelectedFields([]);
|
||||
}
|
||||
|
||||
// Rerender the transformer
|
||||
interactiveTransformer.current?.forceUpdate();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]);
|
||||
}, [
|
||||
localPageFields,
|
||||
selectedKonvaFieldGroups,
|
||||
overlappingFieldFormIds,
|
||||
isFieldChanging,
|
||||
editorFields.selectedField?.formId,
|
||||
]);
|
||||
|
||||
const setSelectedFields = (nodes: Konva.Node[], options?: { isAutoSelect?: boolean }) => {
|
||||
// Any explicit (user-driven) selection shows the action toolbar; only auto-selection
|
||||
// on field creation suppresses it.
|
||||
setIsAutoSelectedField(Boolean(options?.isAutoSelect));
|
||||
|
||||
const setSelectedFields = (nodes: Konva.Node[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const fieldGroups = nodes.filter(
|
||||
(node) => node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
|
||||
@@ -663,25 +696,30 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && (
|
||||
<FieldActionButtons
|
||||
handleDuplicateSelectedFields={duplicatedSelectedFields}
|
||||
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
|
||||
handleDeleteSelectedFields={deletedSelectedFields}
|
||||
handleChangeRecipient={changeSelectedFieldsRecipients}
|
||||
handleChangeFieldType={changeSelectedFieldsType}
|
||||
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px',
|
||||
left: interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px',
|
||||
transform: 'translateX(-50%)',
|
||||
gap: '8px',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 50,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedKonvaFieldGroups.length > 0 &&
|
||||
interactiveTransformer.current &&
|
||||
!isFieldChanging &&
|
||||
!isAutoSelectedField && (
|
||||
<FieldActionButtons
|
||||
handleDuplicateSelectedFields={duplicatedSelectedFields}
|
||||
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
|
||||
handleDeleteSelectedFields={deletedSelectedFields}
|
||||
handleChangeRecipient={changeSelectedFieldsRecipients}
|
||||
handleChangeFieldType={changeSelectedFieldsType}
|
||||
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top:
|
||||
interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px',
|
||||
left:
|
||||
interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px',
|
||||
transform: 'translateX(-50%)',
|
||||
gap: '8px',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 50,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pendingFieldCreation && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -49,6 +50,8 @@ export const DocumentsTable = ({
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url ?? '');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
@@ -96,6 +99,11 @@ 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',
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -32,6 +33,7 @@ type TemplatesTableProps = {
|
||||
documentRootPath: string;
|
||||
templateRootPath: string;
|
||||
enableSelection?: boolean;
|
||||
enableTagLinks?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||||
};
|
||||
@@ -45,6 +47,7 @@ export const TemplatesTable = ({
|
||||
documentRootPath,
|
||||
templateRootPath,
|
||||
enableSelection,
|
||||
enableTagLinks = true,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
}: TemplatesTableProps) => {
|
||||
@@ -109,6 +112,16 @@ 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">
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -34,6 +35,7 @@ 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';
|
||||
@@ -252,6 +254,9 @@ 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,6 +2,7 @@ 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';
|
||||
@@ -12,9 +13,11 @@ 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';
|
||||
@@ -46,13 +49,18 @@ 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 } = useParams();
|
||||
const { folderId, tagId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -85,10 +93,15 @@ 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,
|
||||
@@ -114,7 +127,9 @@ export default function DocumentsPage() {
|
||||
|
||||
let path = formatDocumentsPath(team.url);
|
||||
|
||||
if (folderId) {
|
||||
if (tagId) {
|
||||
path += `/tag/${tagId}`;
|
||||
} else if (folderId) {
|
||||
path += `/f/${folderId}`;
|
||||
}
|
||||
|
||||
@@ -134,7 +149,14 @@ export default function DocumentsPage() {
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
|
||||
{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} />}
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
@@ -184,6 +206,8 @@ 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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DocumentPage, { meta } from './documents._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default DocumentPage;
|
||||
@@ -1,14 +1,17 @@
|
||||
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';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import {
|
||||
BrandingPreferencesForm,
|
||||
@@ -36,6 +39,8 @@ export default function TeamsSettingsPage() {
|
||||
|
||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
||||
|
||||
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||
|
||||
const canCustomBranding =
|
||||
organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED();
|
||||
|
||||
@@ -112,39 +117,61 @@ export default function TeamsSettingsPage() {
|
||||
subtitle={t`Here you can set preferences and defaults for branding.`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
canInherit={true}
|
||||
hasAdvancedBranding={canCustomBranding}
|
||||
context="Team"
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onBrandingPreferencesFormSubmit}
|
||||
/>
|
||||
{canConfigureBranding ? (
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
canInherit={true}
|
||||
hasAdvancedBranding={canCustomBranding}
|
||||
context="Team"
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onBrandingPreferencesFormSubmit}
|
||||
/>
|
||||
|
||||
{cssWarnings.length > 0 && (
|
||||
<Alert variant="warning" className="mt-6">
|
||||
{cssWarnings.length > 0 && (
|
||||
<Alert variant="warning" className="mt-6">
|
||||
<AlertTitle>
|
||||
<Trans>CSS rules were dropped during sanitisation</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-5">
|
||||
{cssWarnings.map((warning, index) => (
|
||||
<li key={index}>
|
||||
{warning.detail}
|
||||
{warning.line !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
<Trans>(line {warning.line})</Trans>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>CSS rules were dropped during sanitisation</Trans>
|
||||
<Trans>Branding Preferences</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-5">
|
||||
{cssWarnings.map((warning, index) => (
|
||||
<li key={index}>
|
||||
{warning.detail}
|
||||
{warning.line !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
<Trans>(line {warning.line})</Trans>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>Currently branding can only be configured for Teams and above plans.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||
<Trans>Update Billing</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -21,6 +22,7 @@ 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';
|
||||
@@ -271,6 +273,9 @@ 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,19 +1,21 @@
|
||||
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 } from 'lucide-react';
|
||||
import { Bird, ChevronLeft } from 'lucide-react';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { Link, useParams, useSearchParams } from 'react-router';
|
||||
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
@@ -36,16 +38,20 @@ export default function TemplatesPage() {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { folderId } = useParams();
|
||||
const { folderId, tagId } = 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 = view === 'organisation';
|
||||
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
||||
const isOrgView = !tagId && view === 'organisation';
|
||||
const showOrgTab = !tagId && organisation.type !== OrganisationType.PERSONAL;
|
||||
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
@@ -63,6 +69,8 @@ export default function TemplatesPage() {
|
||||
page,
|
||||
perPage,
|
||||
folderId,
|
||||
includeAllFolders: tagId !== undefined,
|
||||
tagIds: effectiveTagIds.length > 0 ? effectiveTagIds : undefined,
|
||||
},
|
||||
{
|
||||
enabled: !isOrgView,
|
||||
@@ -92,7 +100,14 @@ export default function TemplatesPage() {
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
{!isOrgView && <FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />}
|
||||
{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} />}
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="flex flex-row items-center">
|
||||
@@ -129,6 +144,12 @@ 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">
|
||||
@@ -156,6 +177,7 @@ export default function TemplatesPage() {
|
||||
documentRootPath={documentRootPath}
|
||||
templateRootPath={templateRootPath}
|
||||
enableSelection={!isOrgView}
|
||||
enableTagLinks={!isOrgView}
|
||||
rowSelection={isOrgView ? {} : rowSelection}
|
||||
onRowSelectionChange={isOrgView ? undefined : setRowSelection}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import TemplatePage, { meta } from './templates._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default TemplatePage;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@@ -9,6 +10,14 @@ export function meta() {
|
||||
return appMetaTags(msg`Forgot Password`);
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
if (!isSigninEnabledForProvider('email')) {
|
||||
throw redirect('/signin');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@@ -13,6 +14,10 @@ export function meta() {
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
if (!isSigninEnabledForProvider('email')) {
|
||||
throw redirect('/signin');
|
||||
}
|
||||
|
||||
const { token } = params;
|
||||
|
||||
const isValid = await getResetTokenValidity({ token });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -9,6 +10,14 @@ export function meta() {
|
||||
return appMetaTags(msg`Reset Password`);
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
if (!isSigninEnabledForProvider('email')) {
|
||||
throw redirect('/signin');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_MICROSOFT_SSO_ENABLED,
|
||||
IS_OIDC_AUTO_REDIRECT_DISABLED,
|
||||
IS_OIDC_SSO_ENABLED,
|
||||
isSigninEnabledForProvider,
|
||||
isSignupEnabledForProvider,
|
||||
OIDC_PROVIDER_LABEL,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
@@ -11,6 +14,7 @@ import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, redirect, useSearchParams } from 'react-router';
|
||||
|
||||
@@ -28,10 +32,20 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { isAuthenticated } = await getOptionalSession(request);
|
||||
|
||||
// SSR env variables.
|
||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
||||
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
|
||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED && isSigninEnabledForProvider('google');
|
||||
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED && isSigninEnabledForProvider('microsoft');
|
||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED && isSigninEnabledForProvider('oidc');
|
||||
|
||||
// Automatically redirect to OIDC when it is the only enabled signin transport,
|
||||
// unless the redirect has been explicitly disabled via env.
|
||||
const isOIDCOnlyTransport =
|
||||
isOIDCSSOEnabled && !isEmailPasswordSigninEnabled && !isGoogleSSOEnabled && !isMicrosoftSSOEnabled;
|
||||
|
||||
const shouldAutoRedirectToOIDC = isOIDCOnlyTransport && !IS_OIDC_AUTO_REDIRECT_DISABLED;
|
||||
|
||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||
|
||||
const isSignupEnabled =
|
||||
isSignupEnabledForProvider('email') ||
|
||||
(IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google')) ||
|
||||
@@ -47,18 +61,28 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
return {
|
||||
isEmailPasswordSigninEnabled,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
isSignupEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
shouldAutoRedirectToOIDC,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, isSignupEnabled, oidcProviderLabel, returnTo } =
|
||||
loaderData;
|
||||
const {
|
||||
isEmailPasswordSigninEnabled,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
isSignupEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
shouldAutoRedirectToOIDC,
|
||||
} = loaderData;
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
@@ -76,6 +100,27 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoRedirectToOIDC) {
|
||||
return;
|
||||
}
|
||||
|
||||
void authClient.oidc.signIn({ redirectPath: returnTo ?? '/' });
|
||||
}, [shouldAutoRedirectToOIDC, returnTo]);
|
||||
|
||||
if (shouldAutoRedirectToOIDC) {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
|
||||
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Redirecting to {oidcProviderLabel || 'OIDC'}...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
|
||||
@@ -95,6 +140,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
<hr className="-mx-6 my-4" />
|
||||
|
||||
<SignInForm
|
||||
isEmailPasswordSigninEnabled={isEmailPasswordSigninEnabled}
|
||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
|
||||
@@ -64,6 +64,12 @@ services:
|
||||
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP}
|
||||
- NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=${NEXT_PUBLIC_DISABLE_OIDC_SIGNUP}
|
||||
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
|
||||
- NEXT_PUBLIC_DISABLE_SIGNIN=${NEXT_PUBLIC_DISABLE_SIGNIN}
|
||||
- NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=${NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN}
|
||||
- NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=${NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN}
|
||||
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN}
|
||||
- NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=${NEXT_PUBLIC_DISABLE_OIDC_SIGNIN}
|
||||
- NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=${NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT}
|
||||
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
|
||||
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
|
||||
- NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=${NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
|
||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const requestMetadata = {
|
||||
auth: null,
|
||||
requestMetadata: {},
|
||||
source: 'app' as const,
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const canReadEnvelope = async (envelopeId: string, userId: number, teamId: number) => {
|
||||
try {
|
||||
await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: envelopeId },
|
||||
userId,
|
||||
teamId,
|
||||
type: null,
|
||||
}).then(({ envelopeWhereInput }) => prisma.envelope.findFirstOrThrow({ where: envelopeWhereInput }));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
test('[DOCUMENTS]: a member cannot delete a document with restricted visibility', async () => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: {
|
||||
visibility: DocumentVisibility.ADMIN,
|
||||
status: DocumentStatus.DRAFT,
|
||||
},
|
||||
});
|
||||
|
||||
// The member cannot read an ADMIN-visibility document, so they must not be
|
||||
// able to delete it either.
|
||||
expect(await canReadEnvelope(envelope.id, member.id, team.id)).toBe(false);
|
||||
|
||||
await expect(
|
||||
deleteDocument({
|
||||
id: { type: 'envelopeId', id: envelope.id },
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
requestMetadata,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const stillExists = await prisma.envelope.findUnique({ where: { id: envelope.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a manager cannot cancel a document with restricted visibility', async () => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: {
|
||||
visibility: DocumentVisibility.ADMIN,
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
// A manager outranks a member but still cannot read an ADMIN-visibility
|
||||
// document, so cancellation must be blocked despite the sufficient role.
|
||||
expect(await canReadEnvelope(envelope.id, manager.id, team.id)).toBe(false);
|
||||
|
||||
await expect(
|
||||
cancelDocument({
|
||||
id: { type: 'envelopeId', id: envelope.id },
|
||||
userId: manager.id,
|
||||
teamId: team.id,
|
||||
reason: 'test-cancel',
|
||||
requestMetadata,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const after = await prisma.envelope.findUnique({ where: { id: envelope.id } });
|
||||
expect(after?.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: { name: 'attachment-url-test', token: hashString(token), expires: null, userId, teamId },
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
/**
|
||||
* Attachment URLs are rendered as link hrefs, so they must be restricted to
|
||||
* http(s). The API must reject any other scheme.
|
||||
*/
|
||||
const NON_HTTP_URLS = [
|
||||
'javascript:alert(document.cookie)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox(1)',
|
||||
'file:///etc/passwd',
|
||||
];
|
||||
|
||||
test('[ATTACHMENTS]: rejects attachment URLs with a non-http(s) protocol', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id);
|
||||
|
||||
for (const url of NON_HTTP_URLS) {
|
||||
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'attachment', data: url } },
|
||||
});
|
||||
|
||||
expect(res.ok(), `expected ${url} to be rejected`).toBe(false);
|
||||
}
|
||||
|
||||
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||
expect(attachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[ATTACHMENTS]: accepts attachment URLs with an http(s) protocol', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id);
|
||||
|
||||
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'safe', data: 'https://example.com/file.pdf' } },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||
expect(attachments).toHaveLength(1);
|
||||
expect(attachments[0].data).toBe('https://example.com/file.pdf');
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: { name: 'attachment-access-test', token: hashString(token), expires: null, userId, teamId },
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
const canReadEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => {
|
||||
const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
return res.ok();
|
||||
};
|
||||
|
||||
/**
|
||||
* Attachment create/update/delete/list must enforce document visibility, not
|
||||
* just team membership. A member whose visibility tier excludes a restricted
|
||||
* envelope must not be able to read or mutate its attachments.
|
||||
*/
|
||||
test('[ATTACHMENTS]: a member cannot create or delete attachments on a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||
|
||||
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'attachment', data: 'https://example.com' } },
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBe(false);
|
||||
|
||||
// No attachment should have been created.
|
||||
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||
expect(attachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[ATTACHMENTS]: a member cannot update an attachment on a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
// The owner (who can see the document) creates the attachment.
|
||||
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'original', data: 'https://example.com/original' } },
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
const attachment = await createRes.json();
|
||||
|
||||
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||
|
||||
const updateRes = await request.post(`${API_BASE_URL}/envelope/attachment/update`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
|
||||
data: { id: attachment.id, data: { label: 'tampered', data: 'https://example.com/tampered' } },
|
||||
});
|
||||
|
||||
expect(updateRes.ok()).toBe(false);
|
||||
|
||||
const persisted = await prisma.envelopeAttachment.findUnique({ where: { id: attachment.id } });
|
||||
expect(persisted?.label).toBe('original');
|
||||
});
|
||||
|
||||
test('[ATTACHMENTS]: a member cannot list attachments on a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'restricted', data: 'https://example.com/restricted' } },
|
||||
});
|
||||
|
||||
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||
|
||||
const findRes = await request.get(`${API_BASE_URL}/envelope/attachment?envelopeId=${envelope.id}`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}` },
|
||||
});
|
||||
|
||||
expect(findRes.ok()).toBe(false);
|
||||
|
||||
const body = findRes.ok() ? await findRes.json() : null;
|
||||
const attachments = body?.data ?? [];
|
||||
expect(attachments).toHaveLength(0);
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { getKonvaElementCountForPage } from '../fixtures/konva';
|
||||
import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
|
||||
|
||||
type TFieldFlowResult = {
|
||||
externalId: string;
|
||||
@@ -46,6 +46,7 @@ const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: str
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
await surface.root.getByTestId('toast-close').click();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,6 +99,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number
|
||||
await canvas.click({ position, force: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Shift+click a field on the canvas to toggle it in/out of the current multi-selection.
|
||||
*/
|
||||
const shiftClickFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await root.waitForTimeout(300);
|
||||
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
|
||||
await canvas.click({ position, modifiers: ['Shift'], force: true });
|
||||
};
|
||||
|
||||
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
|
||||
const externalId = `e2e-fields-${nanoid()}`;
|
||||
|
||||
@@ -760,9 +772,106 @@ const assertChangeFieldTypePersistedInDatabase = async ({
|
||||
expect(actualMetaTypes).toEqual(['date', 'date']);
|
||||
};
|
||||
|
||||
// --- Shift+click multi-select flow ---
|
||||
|
||||
type TShiftClickFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const SHIFT_CLICK_FIELD_POSITIONS = {
|
||||
signature: { x: 150, y: 120 },
|
||||
text: { x: 150, y: 260 },
|
||||
name: { x: 150, y: 400 },
|
||||
};
|
||||
|
||||
const runShiftClickMultiSelectFlow = async (surface: TEnvelopeEditorSurface): Promise<TShiftClickFlowResult> => {
|
||||
const externalId = `e2e-shift-click-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
// Place three fields, spaced far enough apart that their action toolbars don't
|
||||
// overlap a neighbouring field's click target.
|
||||
await placeFieldOnPdf(root, 'Signature', SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await placeFieldOnPdf(root, 'Text', SHIFT_CLICK_FIELD_POSITIONS.text);
|
||||
await placeFieldOnPdf(root, 'Name', SHIFT_CLICK_FIELD_POSITIONS.name);
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
|
||||
|
||||
// A plain click selects exactly one field.
|
||||
await selectFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
|
||||
// Shift+click a second field ADDS it to the selection (the new behaviour).
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.text);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||
|
||||
// Shift+click an already-selected field REMOVES it from the selection.
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
|
||||
// Shift+click it again RE-ADDS it, leaving Signature + Text selected and Name excluded.
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||
|
||||
// Delete the two selected fields via the floating action toolbar. Only the
|
||||
// un-selected Name field should remain -- proving the multi-selection contained
|
||||
// exactly the two Shift-clicked fields.
|
||||
await expect(root.locator('button[title="Remove"]')).toBeVisible();
|
||||
await root.locator('button[title="Remove"]').click();
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
|
||||
|
||||
// Navigate away and back to verify persistence.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertShiftClickMultiSelectPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
// Signature + Text were multi-selected via Shift+click and deleted; only Name remains.
|
||||
expect(envelope.fields).toHaveLength(1);
|
||||
expect(envelope.fields[0].type).toBe(FieldType.NAME);
|
||||
};
|
||||
|
||||
// --- Test describe blocks ---
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
@@ -815,6 +924,16 @@ test.describe('document editor', () => {
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
@@ -867,6 +986,21 @@ test.describe('template editor', () => {
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-shift-click',
|
||||
});
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
@@ -944,6 +1078,22 @@ test.describe('embedded create', () => {
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-shift-click',
|
||||
});
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
|
||||
@@ -16,3 +16,35 @@ export const getKonvaElementCountForPage = async (page: Page, pageNumber: number
|
||||
{ pageNumber, elementSelector },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns how many field groups are currently attached to the page's Konva
|
||||
* transformer, i.e. the size of the active canvas selection. Used to assert
|
||||
* multi-select behaviour (marquee drag and Shift+click).
|
||||
*/
|
||||
export const getKonvaTransformerNodeCountForPage = async (page: Page, pageNumber: number) => {
|
||||
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
|
||||
|
||||
return await page.evaluate(
|
||||
({ pageNumber }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
|
||||
|
||||
const stage = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
|
||||
|
||||
if (!stage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const transformer = stage.find('Transformer')[0];
|
||||
|
||||
if (!transformer) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return (transformer as Konva.Transformer).nodes().length;
|
||||
},
|
||||
{ pageNumber },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -340,3 +340,67 @@ test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', ()
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group membership scoping', () => {
|
||||
test('cannot add a member from another organisation to a group', async ({ page }) => {
|
||||
// Organisation A, where the actor is the owner/admin.
|
||||
const { user: actor, organisation: organisationA } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// A separate organisation B with a member the actor has no authority over.
|
||||
const { organisation: organisationB } = await seedUser({ isPersonalOrganisation: false });
|
||||
const [foreignUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Foreign', organisationRole: 'MEMBER' }],
|
||||
organisationId: organisationB.id,
|
||||
});
|
||||
|
||||
const foreignMember = await getOrganisationMember(foreignUser.id, organisationB.id);
|
||||
|
||||
// A custom group the actor legitimately controls in organisation A.
|
||||
const groupA = await createCustomGroup(organisationA.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: actor.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.update', {
|
||||
id: groupA.id,
|
||||
memberIds: [foreignMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const injectedMembership = await prisma.organisationGroupMember.findFirst({
|
||||
where: { groupId: groupA.id, organisationMemberId: foreignMember.id },
|
||||
});
|
||||
|
||||
expect(injectedMembership).toBeNull();
|
||||
});
|
||||
|
||||
test('can add a member from the same organisation to a group (positive control)', async ({ page }) => {
|
||||
const { user: actor, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [memberUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Member', organisationRole: 'MEMBER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const member = await getOrganisationMember(memberUser.id, organisation.id);
|
||||
|
||||
const group = await createCustomGroup(organisation.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: actor.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.update', {
|
||||
id: group.id,
|
||||
memberIds: [member.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const membership = await prisma.organisationGroupMember.findFirst({
|
||||
where: { groupId: group.id, organisationMemberId: member.id },
|
||||
});
|
||||
|
||||
expect(membership).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedCompletedDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: { name: 'recipient-access-test', token: hashString(token), expires: null, userId, teamId },
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
/**
|
||||
* Reading a recipient exposes its signing token (a bearer credential), so the
|
||||
* recipient read must enforce document visibility — a member who cannot read a
|
||||
* restricted document must not be able to read its recipients either. This
|
||||
* mirrors the field read, which is asserted as a control below.
|
||||
*/
|
||||
test('[RECIPIENT]: a member cannot read a recipient of a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirstOrThrow({ where: { envelopeId: document.id } });
|
||||
|
||||
const res = await request.get(`${API_BASE_URL}/envelope/recipient/${recipient.id}`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}` },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
const body = res.ok() ? await res.json() : null;
|
||||
expect(body?.token).toBeUndefined();
|
||||
});
|
||||
|
||||
test('[RECIPIENT]: a member cannot read a field of a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const field = await prisma.field.findFirst({ where: { envelopeId: document.id } });
|
||||
|
||||
test.skip(!field, 'No field seeded on completed document');
|
||||
|
||||
const res = await request.get(`${API_BASE_URL}/envelope/field/${field!.id}`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}` },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
/**
|
||||
* Calls a team-group tRPC mutation directly, bypassing the UI.
|
||||
*
|
||||
* The UI only ever surfaces CUSTOM / INTERNAL_ORGANISATION groups, so these
|
||||
* authorisation rules must be enforced on the server - a crafted request can
|
||||
* target any `teamGroupId`, including the system-managed INTERNAL_TEAM groups.
|
||||
*/
|
||||
const callTeamGroupMutation = (
|
||||
page: Page,
|
||||
procedure: 'team.group.delete' | 'team.group.update',
|
||||
teamId: number,
|
||||
input: Record<string, unknown>,
|
||||
) =>
|
||||
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Every team is created with three system-managed INTERNAL_TEAM groups
|
||||
* (admin/manager/member). They are the backbone of team-specific access and,
|
||||
* like organisation internal groups, must not be deletable - deleting them
|
||||
* silently strips team members of access while leaving the team row in place.
|
||||
*/
|
||||
test('[TEAMS]: internal team groups cannot be deleted via the API', async ({ page }) => {
|
||||
// Member inheritance OFF: membership is granted exclusively through the team's
|
||||
// INTERNAL_TEAM groups, so removing them is what causes the access loss.
|
||||
const { user: owner, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
// A direct team member whose access depends on the INTERNAL_TEAM member group.
|
||||
const directMember = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const internalTeamGroups = await prisma.teamGroup.findMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||
},
|
||||
});
|
||||
|
||||
// admin + manager + member.
|
||||
expect(internalTeamGroups).toHaveLength(3);
|
||||
|
||||
for (const group of internalTeamGroups) {
|
||||
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
|
||||
teamId: team.id,
|
||||
teamGroupId: group.id,
|
||||
});
|
||||
|
||||
expect(response.status(), `INTERNAL_TEAM ${group.teamRole} group must not be deletable`).not.toBe(200);
|
||||
}
|
||||
|
||||
// None of the internal groups were removed.
|
||||
const remaining = await prisma.teamGroup.count({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||
},
|
||||
});
|
||||
|
||||
expect(remaining).toBe(3);
|
||||
|
||||
// The direct member therefore keeps their team access.
|
||||
const memberStillHasAccess = await prisma.teamGroup.findFirst({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
organisationGroup: {
|
||||
type: OrganisationGroupType.INTERNAL_TEAM,
|
||||
organisationGroupMembers: {
|
||||
some: { organisationMember: { userId: directMember.id } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(memberStillHasAccess).not.toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Guards against over-blocking: user-created (CUSTOM) team groups are not
|
||||
* internal and must remain removable by team managers/admins.
|
||||
*/
|
||||
test('[TEAMS]: custom team groups can still be deleted', async ({ page }) => {
|
||||
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
const customGroup = await prisma.organisationGroup.create({
|
||||
data: {
|
||||
id: generateDatabaseId('org_group'),
|
||||
name: `custom-${team.url}`,
|
||||
type: OrganisationGroupType.CUSTOM,
|
||||
organisationRole: OrganisationMemberRole.MEMBER,
|
||||
organisationId: organisation.id,
|
||||
teamGroups: {
|
||||
create: {
|
||||
id: generateDatabaseId('team_group'),
|
||||
teamId: team.id,
|
||||
teamRole: TeamMemberRole.MEMBER,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { teamGroups: true },
|
||||
});
|
||||
|
||||
const customTeamGroup = customGroup.teamGroups[0];
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
|
||||
teamId: team.id,
|
||||
teamGroupId: customTeamGroup.id,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const deleted = await prisma.teamGroup.findUnique({ where: { id: customTeamGroup.id } });
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The same root cause affects updates: an INTERNAL_TEAM group's role must not be
|
||||
* editable either, otherwise a team admin could rewrite the backbone roles
|
||||
* (e.g. promote the member group to admin).
|
||||
*/
|
||||
test('[TEAMS]: internal team groups cannot be updated via the API', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const internalMemberGroup = await prisma.teamGroup.findFirstOrThrow({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
teamRole: TeamMemberRole.MEMBER,
|
||||
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||
},
|
||||
});
|
||||
|
||||
const response = await callTeamGroupMutation(page, 'team.group.update', team.id, {
|
||||
id: internalMemberGroup.id,
|
||||
data: { teamRole: TeamMemberRole.ADMIN },
|
||||
});
|
||||
|
||||
expect(response.status()).not.toBe(200);
|
||||
|
||||
const reloaded = await prisma.teamGroup.findUniqueOrThrow({ where: { id: internalMemberGroup.id } });
|
||||
|
||||
expect(reloaded.teamRole).toBe(TeamMemberRole.MEMBER);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
/**
|
||||
* Editing the team public profile is a team-management action and must require
|
||||
* MANAGE_TEAM, consistent with renaming the team or changing its URL.
|
||||
*/
|
||||
test('[TEAMS]: a member cannot edit the team public profile', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({ page, email: member.email });
|
||||
|
||||
const profileRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
|
||||
data: JSON.stringify({
|
||||
json: {
|
||||
teamId: team.id,
|
||||
data: { profileEnabled: true, profileBio: 'edited-by-member' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(profileRes.status()).not.toBe(200);
|
||||
|
||||
const profile = await prisma.teamProfile.findUnique({ where: { teamId: team.id } });
|
||||
expect(profile?.enabled ?? false).toBe(false);
|
||||
expect(profile?.bio ?? '').not.toBe('edited-by-member');
|
||||
|
||||
// The name/url path of the same route is also management-gated.
|
||||
const nameRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
|
||||
data: JSON.stringify({
|
||||
json: { teamId: team.id, data: { name: 'renamed-by-member' } },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(nameRes.status()).not.toBe(200);
|
||||
|
||||
const reloaded = await prisma.team.findUnique({ where: { id: team.id } });
|
||||
expect(reloaded?.name).not.toBe('renamed-by-member');
|
||||
|
||||
expect(owner.id).toBeTruthy();
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const AuthenticationErrorCode = {
|
||||
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
|
||||
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
|
||||
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
|
||||
SigninDisabled: 'SIGNIN_DISABLED',
|
||||
SignupDisabled: 'SIGNUP_DISABLED',
|
||||
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
|
||||
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
isDisposableEmail,
|
||||
isEmailDomainAllowedForSignup,
|
||||
isSigninEnabledForProvider,
|
||||
isSignupEnabledForProvider,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
|
||||
@@ -64,6 +65,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (!isSigninEnabledForProvider('email')) {
|
||||
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json');
|
||||
|
||||
const loginLimitResult = await loginRateLimit.check({
|
||||
@@ -244,6 +251,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
const { password, currentPassword } = c.req.valid('json');
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (!isSigninEnabledForProvider('email')) {
|
||||
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const { session, user } = await getSession(c);
|
||||
|
||||
await updatePassword({
|
||||
@@ -346,6 +359,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (!isSigninEnabledForProvider('email')) {
|
||||
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const forgotLimitResult = await forgotPasswordRateLimit.check({
|
||||
@@ -377,6 +396,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (!isSigninEnabledForProvider('email')) {
|
||||
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const { token, password } = c.req.valid('json');
|
||||
|
||||
const resetLimitResult = await resetPasswordRateLimit.check({
|
||||
|
||||
@@ -28,7 +28,11 @@ export const TemplateDocumentCompleted = ({
|
||||
<Section className="mb-4">
|
||||
<Column align="center">
|
||||
<Text className="font-semibold text-base text-foreground">
|
||||
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
||||
<Img
|
||||
src={getAssetUrl('/static/completed.png')}
|
||||
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
|
||||
alt=""
|
||||
/>
|
||||
<Trans>Completed</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
@@ -47,7 +51,7 @@ export const TemplateDocumentCompleted = ({
|
||||
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
||||
href={downloadLink}
|
||||
>
|
||||
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
|
||||
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
|
||||
<Section className="mb-4">
|
||||
<Column align="center">
|
||||
<Text className="font-semibold text-base text-foreground">
|
||||
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
||||
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" alt="" />
|
||||
<Trans>Waiting for others</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
|
||||
@@ -30,7 +30,11 @@ export const TemplateDocumentRecipientSigned = ({
|
||||
<Section className="mb-4">
|
||||
<Column align="center">
|
||||
<Text className="font-semibold text-base text-foreground">
|
||||
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
||||
<Img
|
||||
src={getAssetUrl('/static/completed.png')}
|
||||
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
|
||||
alt=""
|
||||
/>
|
||||
<Trans>Completed</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
|
||||
@@ -26,7 +26,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
|
||||
<Section>
|
||||
<Column align="center">
|
||||
<Text className="font-semibold text-base text-foreground">
|
||||
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
||||
<Img
|
||||
src={getAssetUrl('/static/completed.png')}
|
||||
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
|
||||
alt=""
|
||||
/>
|
||||
<Trans>Completed</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
@@ -51,7 +55,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
|
||||
href={signUpUrl}
|
||||
className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
||||
>
|
||||
<Img src={getAssetUrl('/static/user-plus.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
|
||||
<Img
|
||||
src={getAssetUrl('/static/user-plus.png')}
|
||||
className="mr-2 mb-0.5 inline h-5 w-5 align-middle"
|
||||
alt=""
|
||||
/>
|
||||
<Trans>Create account</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -59,7 +67,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
|
||||
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
||||
href="https://documenso.com/pricing"
|
||||
>
|
||||
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
|
||||
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
|
||||
<Trans>View plans</Trans>
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: Template
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} />;
|
||||
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} alt="" />;
|
||||
};
|
||||
|
||||
export default TemplateImage;
|
||||
|
||||
@@ -30,9 +30,10 @@ export const AccessAuth2FAEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -21,8 +21,9 @@ export const AdminUserCreatedTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -24,11 +24,14 @@ export const BulkSendCompleteEmail = ({
|
||||
}: BulkSendCompleteEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const previewText = msg`Bulk send operation complete for template "${templateName}"`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -18,8 +18,9 @@ export const ConfirmEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -30,9 +30,9 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -23,9 +23,10 @@ export const DocumentCancelTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -26,9 +26,9 @@ export const DocumentCompletedEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
|
||||
@@ -33,9 +33,9 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
|
||||
@@ -56,9 +56,10 @@ export const DocumentInviteEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
@@ -85,7 +86,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
<Text className="my-4 font-semibold text-base">
|
||||
<Trans>
|
||||
{inviterName}{' '}
|
||||
<Link className="font-normal text-muted-foreground" href="mailto:{inviterEmail}">
|
||||
<Link className="font-normal text-muted-foreground" href={`mailto:${inviterEmail}`}>
|
||||
({inviterEmail})
|
||||
</Link>
|
||||
</Trans>
|
||||
|
||||
@@ -20,9 +20,9 @@ export const DocumentPendingEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -28,9 +28,9 @@ export const DocumentRecipientSignedEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
|
||||
@@ -28,9 +28,10 @@ export function DocumentRejectedEmail({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -28,9 +28,10 @@ export function DocumentRejectionConfirmedEmail({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -37,9 +37,10 @@ export const DocumentReminderEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -20,9 +20,9 @@ export const DocumentSelfSignedEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
|
||||
@@ -23,9 +23,10 @@ export const DocumentSuperDeleteEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -20,9 +20,10 @@ export const ForgotPasswordTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -30,8 +30,9 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -34,9 +34,9 @@ export const OrganisationDeleteEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -29,9 +29,9 @@ export const OrganisationInviteEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -31,9 +31,9 @@ export const OrganisationJoinEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -31,9 +31,9 @@ export const OrganisationLeaveEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -29,9 +29,9 @@ export const OrganisationLimitAlertEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -23,9 +23,10 @@ export const RecipientExpiredTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -22,9 +22,10 @@ export const RecipientRemovedFromDocumentTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -22,9 +22,10 @@ export const ResetPasswordTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -29,9 +29,9 @@ export const TeamDeleteEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -30,9 +30,9 @@ export const TeamEmailRemovedTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -41,6 +41,14 @@ export const IS_OIDC_SSO_ENABLED = Boolean(
|
||||
|
||||
export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
|
||||
|
||||
/**
|
||||
* Opt-out flag for the automatic OIDC redirect.
|
||||
*
|
||||
* When OIDC is the only enabled signin transport we redirect to the provider
|
||||
* automatically. Set this to "true" to keep rendering the signin page instead.
|
||||
*/
|
||||
export const IS_OIDC_AUTO_REDIRECT_DISABLED = env('NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT') === 'true';
|
||||
|
||||
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
|
||||
ACCOUNT_SSO_LINK: 'Linked account to SSO',
|
||||
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
|
||||
@@ -188,3 +196,22 @@ export const isSignupEnabledForProvider = (provider: 'email' | 'google' | 'micro
|
||||
|
||||
return env(flagMap[provider]) !== 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if signin is enabled for the given provider.
|
||||
* The master switch takes precedence over the per-provider flags.
|
||||
*/
|
||||
export const isSigninEnabledForProvider = (provider: 'email' | 'google' | 'microsoft' | 'oidc'): boolean => {
|
||||
if (env('NEXT_PUBLIC_DISABLE_SIGNIN') === 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flagMap = {
|
||||
email: 'NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN',
|
||||
google: 'NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN',
|
||||
microsoft: 'NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN',
|
||||
oidc: 'NEXT_PUBLIC_DISABLE_OIDC_SIGNIN',
|
||||
} as const;
|
||||
|
||||
return env(flagMap[provider]) !== 'true';
|
||||
};
|
||||
|
||||
@@ -8,8 +8,9 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { isMemberManagerOrAbove } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@@ -22,9 +23,18 @@ export type CancelDocumentOptions = {
|
||||
};
|
||||
|
||||
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
|
||||
// Note: This is an unsafe request, we validate the ownership/permission later in the function.
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
// Resolve the envelope through the visibility-aware helper so the caller must
|
||||
// have read access (ownership OR team membership with sufficient visibility OR
|
||||
// team-email). This prevents cancelling a document the caller cannot see.
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
@@ -49,16 +59,6 @@ export const cancelDocument = async ({ id, userId, teamId, reason, requestMetada
|
||||
.then((roles) => roles.teamRole)
|
||||
.catch(() => null);
|
||||
|
||||
const isUserTeamMember = teamRole !== null;
|
||||
|
||||
// Callers with no relationship to the document must not be able to determine
|
||||
// whether it exists, so respond as if it was not found.
|
||||
if (!isUserOwner && !isUserTeamMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
|
||||
|
||||
// The document is visible to the caller, but cancelling requires elevated permissions.
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type DeleteDocumentOptions = {
|
||||
@@ -36,7 +36,9 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
|
||||
});
|
||||
}
|
||||
|
||||
// Note: This is an unsafe request, we validate the ownership later in the function.
|
||||
// Note: This is an unsafe request. It is used purely to resolve the recipient
|
||||
// self-hide path below. The authoritative delete authorization is performed
|
||||
// via the visibility-aware `getEnvelopeWhereInput` helper.
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
include: {
|
||||
@@ -51,27 +53,36 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
|
||||
});
|
||||
}
|
||||
|
||||
const isUserTeamMember = await getMemberRoles({
|
||||
teamId: envelope.teamId,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
// Determine whether the user has authorized delete access using the
|
||||
// visibility-aware helper. This enforces ownership OR (team membership AND
|
||||
// the document's visibility is permitted for the member's role) OR team-email
|
||||
// access. A bare team member without sufficient visibility will resolve to
|
||||
// null here and therefore must not be able to delete the document.
|
||||
const hasDeleteAccess = await getEnvelopeWhereInput({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
})
|
||||
.then(() => true)
|
||||
.then(({ envelopeWhereInput }) =>
|
||||
prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
select: { id: true },
|
||||
}),
|
||||
)
|
||||
.then((result) => Boolean(result))
|
||||
.catch(() => false);
|
||||
|
||||
const isUserOwner = envelope.userId === userId;
|
||||
const userRecipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
|
||||
if (!hasDeleteAccess && !userRecipient) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Not allowed',
|
||||
});
|
||||
}
|
||||
|
||||
// Handle hard or soft deleting the actual document if user has permission.
|
||||
if (isUserOwner || isUserTeamMember) {
|
||||
if (hasDeleteAccess) {
|
||||
await handleDocumentOwnerDelete({
|
||||
envelope,
|
||||
user,
|
||||
|
||||
@@ -36,6 +36,8 @@ 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.
|
||||
@@ -102,6 +104,22 @@ 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,
|
||||
@@ -115,6 +133,8 @@ export const findDocuments = async ({
|
||||
senderIds,
|
||||
query = '',
|
||||
folderId,
|
||||
includeAllFolders = false,
|
||||
tagIds,
|
||||
useWindowedCount = true,
|
||||
}: FindDocumentsOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@@ -147,8 +167,12 @@ export const findDocuments = async ({
|
||||
qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT));
|
||||
|
||||
// Folder filter
|
||||
qb =
|
||||
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
|
||||
if (!includeAllFolders) {
|
||||
qb =
|
||||
folderId !== undefined
|
||||
? qb.where('Envelope.folderId', '=', folderId)
|
||||
: qb.where('Envelope.folderId', 'is', null);
|
||||
}
|
||||
|
||||
// Period filter
|
||||
if (period) {
|
||||
@@ -199,6 +223,9 @@ export const findDocuments = async ({
|
||||
);
|
||||
}
|
||||
|
||||
// Tag filter (OR semantics — envelope must have at least one of the selected tags)
|
||||
qb = applyEnvelopeTagFilter(qb, tagIds);
|
||||
|
||||
return qb;
|
||||
};
|
||||
|
||||
@@ -520,6 +547,7 @@ export const findDocuments = async ({
|
||||
envelopeItems: {
|
||||
select: { id: true, envelopeId: true, title: true, order: true },
|
||||
},
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { applyEnvelopeTagFilter, 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,7 +57,9 @@ export type GetStatsInput = {
|
||||
period?: PeriodSelectorValue;
|
||||
search?: string;
|
||||
folderId?: string;
|
||||
includeAllFolders?: boolean;
|
||||
senderIds?: number[];
|
||||
tagIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,7 +82,16 @@ 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, senderIds }: GetStatsInput) => {
|
||||
export const getStats = async ({
|
||||
userId,
|
||||
teamId,
|
||||
period,
|
||||
search = '',
|
||||
folderId,
|
||||
includeAllFolders = false,
|
||||
senderIds,
|
||||
tagIds,
|
||||
}: GetStatsInput) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true },
|
||||
@@ -105,8 +116,12 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT));
|
||||
|
||||
// Folder filter
|
||||
qb =
|
||||
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
|
||||
if (!includeAllFolders) {
|
||||
qb =
|
||||
folderId !== undefined
|
||||
? qb.where('Envelope.folderId', '=', folderId)
|
||||
: qb.where('Envelope.folderId', 'is', null);
|
||||
}
|
||||
|
||||
// Period filter
|
||||
if (period) {
|
||||
@@ -121,6 +136,9 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
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 }) =>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type CreateAttachmentOptions = {
|
||||
envelopeId: string;
|
||||
@@ -15,11 +15,15 @@ export type CreateAttachmentOptions = {
|
||||
};
|
||||
|
||||
export const createAttachment = async ({ envelopeId, teamId, userId, data }: CreateAttachmentOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: envelopeId },
|
||||
userId,
|
||||
teamId,
|
||||
type: null,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
where: envelopeWhereInput,
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type DeleteAttachmentOptions = {
|
||||
id: string;
|
||||
@@ -14,9 +14,6 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
|
||||
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
envelope: {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
@@ -29,6 +26,24 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
|
||||
});
|
||||
}
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: attachment.envelopeId },
|
||||
userId,
|
||||
teamId,
|
||||
type: null,
|
||||
});
|
||||
|
||||
// Additional validation to check the user has visibility-aware access to the envelope.
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Attachment not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||
attachment.envelope.status === DocumentStatus.REJECTED
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type FindAttachmentsByEnvelopeIdOptions = {
|
||||
envelopeId: string;
|
||||
@@ -14,11 +14,15 @@ export const findAttachmentsByEnvelopeId = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: FindAttachmentsByEnvelopeIdOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: envelopeId },
|
||||
userId,
|
||||
teamId,
|
||||
type: null,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
where: envelopeWhereInput,
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type UpdateAttachmentOptions = {
|
||||
id: string;
|
||||
@@ -15,9 +15,6 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
|
||||
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
envelope: {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
@@ -30,6 +27,24 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
|
||||
});
|
||||
}
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: attachment.envelopeId },
|
||||
userId,
|
||||
teamId,
|
||||
type: null,
|
||||
});
|
||||
|
||||
// Additional validation to check the user has visibility-aware access to the envelope.
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Attachment not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||
attachment.envelope.status === DocumentStatus.REJECTED
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
export const getUserByApiToken = async ({ token }: { token: string }) => {
|
||||
const hashedToken = hashString(token);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
apiTokens: {
|
||||
some: {
|
||||
token: hashedToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
apiTokens: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const retrievedToken = user.apiTokens.find((apiToken) => apiToken.token === hashedToken);
|
||||
|
||||
// This should be impossible but we need to satisfy TypeScript
|
||||
if (!retrievedToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
|
||||
throw new Error('Expired token');
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { EnvelopeType } from '@prisma/client';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type GetRecipientByIdOptions = {
|
||||
recipientId: number;
|
||||
@@ -41,6 +42,27 @@ export const getRecipientById = async ({ recipientId, userId, teamId, type }: Ge
|
||||
});
|
||||
}
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: recipient.envelopeId,
|
||||
},
|
||||
type,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
// Additional validation to check visibility.
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: envelopeWhereInput,
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
const legacyId = {
|
||||
documentId: type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId) : null,
|
||||
templateId: type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId) : null,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { match, P } from 'ts-pattern';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { alphaid } from '../../universal/id';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type CreateSharingIdOptions =
|
||||
| {
|
||||
@@ -27,6 +28,7 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
|
||||
),
|
||||
select: {
|
||||
id: true,
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,6 +48,31 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
|
||||
.then((recipient) => recipient?.email);
|
||||
})
|
||||
.with({ userId: P.number }, async ({ userId }) => {
|
||||
// Ensure the authenticated user actually has visibility-aware access to the
|
||||
// envelope before allowing them to create a share link. The share route does
|
||||
// not carry a teamId, so we derive it from the envelope and reuse the canonical
|
||||
// visibility check (owner OR team member with sufficient visibility).
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
userId,
|
||||
teamId: envelope.teamId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
});
|
||||
|
||||
const accessibleEnvelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!accessibleEnvelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
return await prisma.user
|
||||
.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
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>;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
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;
|
||||
};
|
||||
@@ -85,6 +85,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
// Purge all internal organisation groups that have no teams.
|
||||
await tx.organisationGroup.deleteMany({
|
||||
where: {
|
||||
organisationId: team.organisationId,
|
||||
type: OrganisationGroupType.INTERNAL_TEAM,
|
||||
teamGroups: {
|
||||
none: {},
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { TeamProfile } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { updateTeamPublicProfile } from './update-team-public-profile';
|
||||
|
||||
export type GetTeamPublicProfileOptions = {
|
||||
userId: number;
|
||||
@@ -32,25 +31,24 @@ export const getTeamPublicProfile = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Create and return the public profile.
|
||||
// Lazily initialize a disabled public profile on first access. Membership is
|
||||
// already verified by the query above, so this system initialization does not
|
||||
// impose the MANAGE_TEAM gate that updateTeamPublicProfile enforces for writes.
|
||||
if (!team.profile) {
|
||||
const { url, profile } = await updateTeamPublicProfile({
|
||||
userId: userId,
|
||||
teamId,
|
||||
data: {
|
||||
const profile = await prisma.teamProfile.upsert({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
create: {
|
||||
teamId,
|
||||
enabled: false,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Failed to create public profile',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
profile,
|
||||
url,
|
||||
url: team.url,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
@@ -13,7 +14,11 @@ export type UpdatePublicProfileOptions = {
|
||||
|
||||
export const updateTeamPublicProfile = async ({ userId, teamId, data }: UpdatePublicProfileOptions) => {
|
||||
return await prisma.team.update({
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
where: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
data: {
|
||||
profile: {
|
||||
upsert: {
|
||||
|
||||
@@ -58,6 +58,11 @@ export const findOrganisationTemplates = async ({
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
|
||||
@@ -13,6 +13,8 @@ export type FindTemplatesOptions = {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
folderId?: string;
|
||||
includeAllFolders?: boolean;
|
||||
tagIds?: string[];
|
||||
};
|
||||
|
||||
export const findTemplates = async ({
|
||||
@@ -22,6 +24,8 @@ export const findTemplates = async ({
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
folderId,
|
||||
includeAllFolders = false,
|
||||
tagIds,
|
||||
}: FindTemplatesOptions) => {
|
||||
const { teamRole } = await getMemberRoles({
|
||||
teamId,
|
||||
@@ -46,7 +50,8 @@ export const findTemplates = async ({
|
||||
{ userId, teamId },
|
||||
],
|
||||
},
|
||||
folderId ? { folderId } : { folderId: null },
|
||||
...(includeAllFolders ? [] : [folderId ? { folderId } : { folderId: null }]),
|
||||
...(tagIds && tagIds.length > 0 ? [{ tags: { some: { tagId: { in: tagIds } } } }] : []),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -67,6 +72,11 @@ export const findTemplates = async ({
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
|
||||
@@ -18,31 +18,26 @@ export const forgotPassword = async ({ email }: { email: string }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a token that was created in the last hour and hasn't expired
|
||||
// const existingToken = await prisma.passwordResetToken.findFirst({
|
||||
// where: {
|
||||
// userId: user.id,
|
||||
// expiry: {
|
||||
// gt: new Date(),
|
||||
// },
|
||||
// createdAt: {
|
||||
// gt: new Date(Date.now() - ONE_HOUR),
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
// if (existingToken) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const token = crypto.randomBytes(18).toString('hex');
|
||||
|
||||
await prisma.passwordResetToken.create({
|
||||
data: {
|
||||
token,
|
||||
expiry: new Date(Date.now() + ONE_HOUR),
|
||||
userId: user.id,
|
||||
},
|
||||
// Invalidate any prior reset tokens for this user before issuing a new one, so
|
||||
// only a single token is ever live at a time. We still always issue a fresh
|
||||
// token (and email) so the user can request a new link if a prior email never
|
||||
// arrived, while bounding the number of usable tokens to one.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.passwordResetToken.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.passwordResetToken.create({
|
||||
data: {
|
||||
token,
|
||||
expiry: new Date(Date.now() + ONE_HOUR),
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await sendForgotPassword({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { assertNotPrivateUrl } from '../assert-webhook-url';
|
||||
@@ -9,14 +11,36 @@ export const subscribeHandler = async (req: Request) => {
|
||||
const authorization = req.headers.get('authorization');
|
||||
|
||||
if (!authorization) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { webhookUrl, eventTrigger } = await req.json();
|
||||
|
||||
await assertNotPrivateUrl(webhookUrl);
|
||||
|
||||
const result = await validateApiToken({ authorization });
|
||||
const result = await validateApiToken({ authorization }).catch(() => {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
|
||||
});
|
||||
|
||||
const userId = result.userId ?? result.user.id;
|
||||
const teamId = result.teamId ?? undefined;
|
||||
|
||||
// Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the
|
||||
// tRPC webhook mutations (create-webhook.ts). Guards against stale-privilege
|
||||
// use of a token minted while the holder was privileged.
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to manage webhooks for this team',
|
||||
});
|
||||
}
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
data: {
|
||||
@@ -24,15 +48,19 @@ export const subscribeHandler = async (req: Request) => {
|
||||
eventTriggers: [eventTrigger],
|
||||
secret: null,
|
||||
enabled: true,
|
||||
userId: result.userId ?? result.user.id,
|
||||
teamId: result.teamId ?? undefined,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(createdWebhook);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
return Response.json({ message: err.message }, { status: 400 });
|
||||
// Map authorization failures to 401, keep other AppErrors as 400 to
|
||||
// preserve the existing Zapier contract (e.g. invalid webhook URL).
|
||||
const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400;
|
||||
|
||||
return Response.json({ message: err.message }, { status });
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { validateApiToken } from './validateApiToken';
|
||||
@@ -7,23 +10,42 @@ export const unsubscribeHandler = async (req: Request) => {
|
||||
const authorization = req.headers.get('authorization');
|
||||
|
||||
if (!authorization) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { webhookId } = await req.json();
|
||||
|
||||
const result = await validateApiToken({ authorization });
|
||||
const result = await validateApiToken({ authorization }).catch(() => {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
|
||||
});
|
||||
|
||||
const userId = result.userId ?? result.user.id;
|
||||
const teamId = result.teamId ?? undefined;
|
||||
|
||||
// Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the
|
||||
// tRPC delete-webhook-by-id mutation. Guards against stale-privilege use of a
|
||||
// token minted while the holder was privileged.
|
||||
const deletedWebhook = await prisma.webhook.delete({
|
||||
where: {
|
||||
id: webhookId,
|
||||
userId: result.userId ?? result.user.id,
|
||||
teamId: result.teamId ?? undefined,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(deletedWebhook);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
// Map authorization failures to 401, keep other AppErrors as 400 to
|
||||
// preserve the existing Zapier contract.
|
||||
const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400;
|
||||
|
||||
return Response.json({ message: err.message }, { status });
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
|
||||
return Response.json(
|
||||
|
||||
@@ -8917,6 +8917,11 @@ msgstr "Weiterleitungs-URL"
|
||||
msgid "Redirecting"
|
||||
msgstr "Weiterleitung"
|
||||
|
||||
#. placeholder {0}: oidcProviderLabel || 'OIDC'
|
||||
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
|
||||
msgid "Redirecting to {0}..."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Registration Successful"
|
||||
|
||||
@@ -8908,6 +8908,11 @@ msgstr "Redirect URL"
|
||||
msgid "Redirecting"
|
||||
msgstr "Redirecting"
|
||||
|
||||
#. placeholder {0}: oidcProviderLabel || 'OIDC'
|
||||
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
|
||||
msgid "Redirecting to {0}..."
|
||||
msgstr "Redirecting to {0}..."
|
||||
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Registration Successful"
|
||||
|
||||
@@ -8917,6 +8917,11 @@ msgstr "URL de redirección"
|
||||
msgid "Redirecting"
|
||||
msgstr "Redireccionando"
|
||||
|
||||
#. placeholder {0}: oidcProviderLabel || 'OIDC'
|
||||
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
|
||||
msgid "Redirecting to {0}..."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Registration Successful"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user