Compare commits

...

4 Commits

Author SHA1 Message Date
Catalin Pit d20d9595c9 feat: initial tags 2026-07-01 10:10:50 +03:00
Kendry Grullon 562d78e2d7 feat: add granular signin disable flags and OIDC auto-redirect (#2857) 2026-06-30 16:08:09 +10:00
Grégory Chevalier 3b110cf70d fix: french translation for confirmation message (#3050) 2026-06-30 15:52:59 +10:00
David Nguyen 7062fadf0b fix: add additional team group permission checks (#3052) 2026-06-30 15:45:48 +10:00
75 changed files with 2198 additions and 78 deletions
@@ -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 15 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.
+14
View File
@@ -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
+2
View File
@@ -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` |
+63 -49
View File
@@ -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">
@@ -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,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}
+6
View File
@@ -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,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);
});
@@ -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({
+27
View File
@@ -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';
};
@@ -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 } },
},
});
+22 -4
View File
@@ -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 }) =>
@@ -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,
},
});
};
+47
View File
@@ -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;
};
@@ -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([
+5
View File
@@ -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"
+5
View File
@@ -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"
+5
View File
@@ -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"
+6 -1
View File
@@ -8424,7 +8424,7 @@ msgstr "Veuillez réessayer ou contacter notre support."
#. placeholder {0}: `'${t(deleteMessage)}'`
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please type {0} to confirm"
msgstr "Veuiillez taper {0} pour confirmer"
msgstr "Veuillez taper {0} pour confirmer"
#. placeholder {0}: user.email
#: apps/remix/app/components/dialogs/account-delete-dialog.tsx
@@ -8917,6 +8917,11 @@ msgstr "URL de redirection"
msgid "Redirecting"
msgstr "Redirection"
#. 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"
+5
View File
@@ -8917,6 +8917,11 @@ msgstr "URL di reindirizzamento"
msgid "Redirecting"
msgstr "Reindirizzamento"
#. 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"
+5
View File
@@ -8917,6 +8917,11 @@ msgstr "リダイレクト URL"
msgid "Redirecting"
msgstr "リダイレクト中"
#. 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"
+5
View File
@@ -8917,6 +8917,11 @@ msgstr "리디렉션 URL"
msgid "Redirecting"
msgstr "리디렉션 중"
#. 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"
+5
View File
@@ -8917,6 +8917,11 @@ msgstr "Redirect-URL"
msgid "Redirecting"
msgstr "Doorsturen"
#. 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"
+5
View File
@@ -8918,6 +8918,11 @@ msgstr "Adres URL przekierowania"
msgid "Redirecting"
msgstr "Przekierowywanie"
#. 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"
+5
View File
@@ -8908,6 +8908,11 @@ msgstr "URL de Redirecionamento"
msgid "Redirecting"
msgstr "Redirecionando"
#. 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"
+5
View File
@@ -8917,6 +8917,11 @@ msgstr "重定向 URL"
msgid "Redirecting"
msgstr "正在重定向"
#. 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"
+2 -1
View File
@@ -6,9 +6,9 @@ import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSche
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { LegacyDocumentSchema } from '@documenso/prisma/types/document-legacy-schema';
import { z } from 'zod';
import { ZFieldSchema } from './field';
import { ZRecipientLiteSchema } from './recipient';
import { ZTagLiteSchema } from './tag';
/**
* The full document response schema.
@@ -169,6 +169,7 @@ export const ZDocumentManySchema = LegacyDocumentSchema.pick({
id: true,
url: true,
}).nullable(),
tags: ZTagLiteSchema.array(),
});
export type TDocumentMany = z.infer<typeof ZDocumentManySchema>;
+9
View File
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const TagType = {
DOCUMENT: 'DOCUMENT',
TEMPLATE: 'TEMPLATE',
} as const;
export const ZTagTypeSchema = z.enum([TagType.DOCUMENT, TagType.TEMPLATE]);
export type TTagType = z.infer<typeof ZTagTypeSchema>;
+10
View File
@@ -0,0 +1,10 @@
import TagSchema from '@documenso/prisma/generated/zod/modelSchema/TagSchema';
import type { z } from 'zod';
export const ZTagLiteSchema = TagSchema.pick({
id: true,
name: true,
type: true,
});
export type TTagLite = z.infer<typeof ZTagLiteSchema>;
+2 -1
View File
@@ -6,9 +6,9 @@ import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { LegacyTemplateDirectLinkSchema, TemplateSchema } from '@documenso/prisma/types/template-legacy-schema';
import { z } from 'zod';
import { ZFieldSchema } from './field';
import { ZRecipientLiteSchema } from './recipient';
import { ZTagLiteSchema } from './tag';
/**
* The full template response schema.
@@ -156,6 +156,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
}).nullable(),
// Backwards compatibility.
templateDocumentDataId: z.string().default(''),
tags: ZTagLiteSchema.array(),
});
export type TTemplateMany = z.infer<typeof ZTemplateManySchema>;
+3
View File
@@ -8,6 +8,7 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
import { SignatureLevel } from '../types/signature-level';
import { mapSecondaryIdToDocumentId } from './envelope';
import { mapRecipientToLegacyRecipient } from './recipients';
import { type EnvelopeTagWithTag, mapEnvelopeTagsToTags } from './tags';
export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | DocumentStatus) => {
const status = typeof document === 'string' ? document : document.status;
@@ -109,6 +110,7 @@ type MapEnvelopeToDocumentManyOptions = Envelope & {
user: Pick<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'id' | 'url'>;
recipients: Recipient[];
tags?: EnvelopeTagWithTag<TDocumentMany['tags'][number]>[];
};
/**
@@ -150,5 +152,6 @@ export const mapEnvelopesToDocumentMany = (envelope: MapEnvelopeToDocumentManyOp
url: envelope.team.url,
},
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
tags: mapEnvelopeTagsToTags(envelope.tags),
};
};
+7
View File
@@ -0,0 +1,7 @@
export type EnvelopeTagWithTag<TTag> = {
tag: TTag;
};
export const mapEnvelopeTagsToTags = <TTag>(envelopeTags?: EnvelopeTagWithTag<TTag>[] | null) => {
return (envelopeTags ?? []).map((envelopeTag) => envelopeTag.tag);
};
@@ -0,0 +1,53 @@
-- CreateEnum
CREATE TYPE "TagType" AS ENUM ('DOCUMENT', 'TEMPLATE');
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"normalizedName" TEXT NOT NULL,
"type" "TagType" NOT NULL,
"teamId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EnvelopeTag" (
"envelopeId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
"assignedBy" INTEGER,
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EnvelopeTag_pkey" PRIMARY KEY ("envelopeId", "tagId")
);
-- CreateIndex
CREATE UNIQUE INDEX "Tag_teamId_normalizedName_type_key" ON "Tag"("teamId", "normalizedName", "type");
-- CreateIndex
CREATE INDEX "Tag_teamId_idx" ON "Tag"("teamId");
-- CreateIndex
CREATE INDEX "Tag_teamId_type_idx" ON "Tag"("teamId", "type");
-- CreateIndex
CREATE INDEX "EnvelopeTag_tagId_idx" ON "EnvelopeTag"("tagId");
-- AddForeignKey
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_teamId_fkey"
FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE;
-- AddForeignKey
ALTER TABLE "EnvelopeTag" ADD CONSTRAINT "EnvelopeTag_envelopeId_fkey"
FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE;
-- AddForeignKey
ALTER TABLE "EnvelopeTag" ADD CONSTRAINT "EnvelopeTag_tagId_fkey"
FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE;
-- AddForeignKey
ALTER TABLE "EnvelopeTag" ADD CONSTRAINT "EnvelopeTag_assignedBy_fkey"
FOREIGN KEY ("assignedBy") REFERENCES "User"("id") ON DELETE SET NULL;
+39
View File
@@ -66,6 +66,8 @@ model User {
folders Folder[]
envelopes Envelope[]
envelopeTags EnvelopeTag[]
verificationTokens VerificationToken[]
apiTokens ApiToken[]
securityAuditLogs UserSecurityAuditLog[]
@@ -402,6 +404,40 @@ enum FolderType {
TEMPLATE
}
enum TagType {
DOCUMENT
TEMPLATE
}
model Tag {
id String @id @default(cuid())
name String
normalizedName String
type TagType
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
envelopes EnvelopeTag[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([teamId, normalizedName, 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: SetNull)
assignedAt DateTime @default(now())
@@id([envelopeId, tagId])
@@index([tagId])
}
model Folder {
id String @id @default(cuid())
name String
@@ -486,6 +522,8 @@ model Envelope {
envelopeAttachments EnvelopeAttachment[]
tags EnvelopeTag[]
@@index([type])
@@index([status])
@@index([userId])
@@ -1052,6 +1090,7 @@ model Team {
envelopes Envelope[]
folders Folder[]
tags Tag[]
apiTokens ApiToken[]
webhooks Webhook[]
teamGroups TeamGroup[]
@@ -26,6 +26,8 @@ export const findDocumentsInternalRoute = authenticatedProcedure
period,
senderIds,
folderId,
includeAllFolders,
tagIds,
} = input;
const [stats, documents] = await Promise.all([
@@ -35,7 +37,9 @@ export const findDocumentsInternalRoute = authenticatedProcedure
period,
search: query,
folderId,
includeAllFolders,
senderIds,
tagIds,
}),
findDocuments({
userId: user.id,
@@ -49,6 +53,8 @@ export const findDocumentsInternalRoute = authenticatedProcedure
period,
senderIds,
folderId,
includeAllFolders,
tagIds,
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
}),
]);
@@ -10,6 +10,8 @@ export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.e
senderIds: z.array(z.number()).optional(),
status: z.nativeEnum(ExtendedDocumentStatus).optional(),
folderId: z.string().optional(),
includeAllFolders: z.boolean().optional(),
tagIds: z.array(z.string()).optional(),
});
export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
@@ -11,7 +11,8 @@ export const findDocumentsRoute = authenticatedProcedure
.query(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { query, templateId, page, perPage, orderByDirection, orderByColumn, source, status, folderId } = input;
const { query, templateId, page, perPage, orderByDirection, orderByColumn, source, status, folderId, tagIds } =
input;
const documents = await findDocuments({
userId: user.id,
@@ -23,6 +24,7 @@ export const findDocumentsRoute = authenticatedProcedure
page,
perPage,
folderId,
tagIds,
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
useWindowedCount: false,
});
@@ -20,6 +20,7 @@ export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({
source: z.nativeEnum(DocumentSource).describe('Filter documents by how it was created.').optional(),
status: z.nativeEnum(DocumentStatus).describe('Filter documents by the current status').optional(),
folderId: z.string().describe('Filter documents by folder ID').optional(),
tagIds: z.array(z.string()).describe('Filter documents by tag IDs').optional(),
orderByColumn: z.enum(['createdAt']).optional(),
orderByDirection: z.enum(['asc', 'desc']).describe('').default('desc'),
});
@@ -55,6 +55,11 @@ export const getDocumentsByIdsRoute = authenticatedProcedure
url: true,
},
},
tags: {
include: {
tag: true,
},
},
},
});
+2
View File
@@ -10,6 +10,7 @@ import { folderRouter } from './folder-router/router';
import { organisationRouter } from './organisation-router/router';
import { profileRouter } from './profile-router/router';
import { recipientRouter } from './recipient-router/router';
import { tagRouter } from './tag-router/router';
import { teamRouter } from './team-router/router';
import { templateRouter } from './template-router/router';
import { router } from './trpc';
@@ -29,6 +30,7 @@ export const appRouter = router({
apiToken: apiTokenRouter,
team: teamRouter,
template: templateRouter,
tag: tagRouter,
webhook: webhookRouter,
embeddingPresign: embeddingPresignRouter,
});
+217
View File
@@ -0,0 +1,217 @@
import { createTag } from '@documenso/lib/server-only/tag/create-tag';
import { deleteTag } from '@documenso/lib/server-only/tag/delete-tag';
import { findTags } from '@documenso/lib/server-only/tag/find-tags';
import { getEnvelopeTags } from '@documenso/lib/server-only/tag/get-envelope-tags';
import { setEnvelopeTags } from '@documenso/lib/server-only/tag/set-envelope-tags';
import { updateTag } from '@documenso/lib/server-only/tag/update-tag';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
import { authenticatedProcedure, router } from '../trpc';
import {
ZCreateTagRequestSchema,
ZCreateTagResponseSchema,
ZDeleteTagRequestSchema,
ZFindTagsRequestSchema,
ZFindTagsResponseSchema,
ZGetEnvelopeTagsRequestSchema,
ZGetEnvelopeTagsResponseSchema,
ZSetEnvelopeTagsRequestSchema,
ZSetEnvelopeTagsResponseSchema,
ZUpdateTagRequestSchema,
ZUpdateTagResponseSchema,
} from './schema';
export const tagRouter = router({
/**
* @public
*/
findTags: authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/tag',
summary: 'Find tags',
description: 'Find tags for the current team based on a search criteria',
tags: ['Tag'],
},
})
.input(ZFindTagsRequestSchema)
.output(ZFindTagsResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { type, query, page, perPage } = input;
ctx.logger.info({
input: {
type,
query,
},
});
return await findTags({
userId: user.id,
teamId,
type,
query,
page,
perPage,
});
}),
/**
* @private
*/
getEnvelopeTags: authenticatedProcedure
.input(ZGetEnvelopeTagsRequestSchema)
.output(ZGetEnvelopeTagsResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
return await getEnvelopeTags({
userId: user.id,
teamId,
envelopeId,
});
}),
/**
* @public
*/
createTag: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/tag/create',
summary: 'Create tag',
description: 'Creates a new tag in your team',
tags: ['Tag'],
},
})
.input(ZCreateTagRequestSchema)
.output(ZCreateTagResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { name, type } = input;
ctx.logger.info({
input: {
type,
},
});
return await createTag({
userId: user.id,
teamId,
name,
type,
});
}),
/**
* @public
*/
updateTag: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/tag/update',
summary: 'Update tag',
description: 'Updates an existing tag',
tags: ['Tag'],
},
})
.input(ZUpdateTagRequestSchema)
.output(ZUpdateTagResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { tagId, data } = input;
ctx.logger.info({
input: {
tagId,
},
});
return await updateTag({
userId: user.id,
teamId,
tagId,
data,
});
}),
/**
* @public
*/
deleteTag: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/tag/delete',
summary: 'Delete tag',
description: 'Deletes an existing tag and removes it from all assigned envelopes',
tags: ['Tag'],
},
})
.input(ZDeleteTagRequestSchema)
.output(ZSuccessResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { tagId } = input;
ctx.logger.info({
input: {
tagId,
},
});
await deleteTag({
userId: user.id,
teamId,
tagId,
});
return ZGenericSuccessResponse;
}),
/**
* @public
*/
setEnvelopeTags: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/tag/assign',
summary: 'Set envelope tags',
description: 'Set the full set of tags assigned to an envelope',
tags: ['Tag'],
},
})
.input(ZSetEnvelopeTagsRequestSchema)
.output(ZSetEnvelopeTagsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId, tagIds } = input;
ctx.logger.info({
input: {
envelopeId,
tagCount: tagIds.length,
},
});
return await setEnvelopeTags({
userId: user.id,
teamId,
envelopeId,
tagIds,
});
}),
});
+61
View File
@@ -0,0 +1,61 @@
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZTagTypeSchema } from '@documenso/lib/types/tag-type';
import TagSchema from '@documenso/prisma/generated/zod/modelSchema/TagSchema';
import { z } from 'zod';
export const ZTagSchema = TagSchema.pick({
id: true,
name: true,
type: true,
teamId: true,
createdAt: true,
updatedAt: true,
});
export type TTag = z.infer<typeof ZTagSchema>;
export const ZCreateTagRequestSchema = z.object({
name: z.string().min(1).max(50),
type: ZTagTypeSchema,
});
export const ZCreateTagResponseSchema = ZTagSchema;
export const ZUpdateTagRequestSchema = z.object({
tagId: z.string().describe('The ID of the tag to update'),
data: z.object({
name: z.string().min(1).max(50).optional().describe('The name of the tag'),
}),
});
export const ZUpdateTagResponseSchema = ZTagSchema;
export const ZDeleteTagRequestSchema = z.object({
tagId: z.string(),
});
export const ZFindTagsRequestSchema = ZFindSearchParamsSchema.extend({
type: ZTagTypeSchema.optional().describe('Filter tags by type'),
query: z.string().optional().describe('Search tags by name'),
});
export const ZFindTagsResponseSchema = ZFindResultResponse.extend({
data: z.array(ZTagSchema),
});
export const ZSetEnvelopeTagsRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to assign tags to'),
tagIds: z.array(z.string()).describe('The tag IDs to assign to the envelope'),
});
export const ZSetEnvelopeTagsResponseSchema = z.array(ZTagSchema);
export const ZGetEnvelopeTagsRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to get tags for'),
});
export const ZGetEnvelopeTagsResponseSchema = z.array(ZTagSchema);
export type TFindTagsResponse = z.infer<typeof ZFindTagsResponseSchema>;
export type TSetEnvelopeTagsResponse = z.infer<typeof ZSetEnvelopeTagsResponseSchema>;
export type TGetEnvelopeTagsResponse = z.infer<typeof ZGetEnvelopeTagsResponseSchema>;
@@ -53,6 +53,15 @@ export const deleteTeamGroupRoute = authenticatedProcedure
});
}
// You cannot delete internal team groups. These are the system-managed
// admin/manager/member groups that back the team's role-based access, and
// deleting them would silently strip team members of their access.
if (group.organisationGroup.type === OrganisationGroupType.INTERNAL_TEAM) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to delete internal team groups',
});
}
// You cannot delete internal organisation groups.
// The only exception is deleting the "member" organisation group which is used to allow
// all organisation members to access a team.
@@ -45,9 +45,12 @@ export const updateTeamGroupRoute = authenticatedProcedure
});
}
if (teamGroup.organisationGroup.type === OrganisationGroupType.INTERNAL_ORGANISATION) {
if (
teamGroup.organisationGroup.type === OrganisationGroupType.INTERNAL_ORGANISATION ||
teamGroup.organisationGroup.type === OrganisationGroupType.INTERNAL_TEAM
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to update internal organisation groups',
message: 'You are not allowed to update internal groups',
});
}
@@ -2,6 +2,7 @@ import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelo
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients';
import { mapEnvelopeTagsToTags } from '@documenso/lib/utils/tags';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
@@ -69,6 +70,11 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
enabled: true,
},
},
tags: {
include: {
tag: true,
},
},
},
});
@@ -115,6 +121,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
}
: null,
templateDocumentDataId: firstTemplateDocumentData.id, // Backwards compatibility.
tags: mapEnvelopeTagsToTags(envelope.tags),
};
});
@@ -26,6 +26,7 @@ import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-action
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients';
import { mapEnvelopeTagsToTags } from '@documenso/lib/utils/tags';
import { mapEnvelopeToTemplateLite } from '@documenso/lib/utils/templates';
import type { Envelope } from '@prisma/client';
import { DocumentDataType, EnvelopeType } from '@prisma/client';
@@ -120,6 +121,7 @@ export const templateRouter = router({
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
templateMeta: envelope.documentMeta,
directLink: envelope.directLink,
tags: mapEnvelopeTagsToTags(envelope.tags),
};
}),
};
@@ -167,6 +169,7 @@ export const templateRouter = router({
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
templateMeta: envelope.documentMeta,
directLink: envelope.directLink,
tags: mapEnvelopeTagsToTags(envelope.tags),
};
}),
};
@@ -280,6 +280,8 @@ export const ZUpdateTemplateResponseSchema = ZTemplateLiteSchema;
export const ZFindTemplatesRequestSchema = ZFindSearchParamsSchema.extend({
type: z.nativeEnum(TemplateType).describe('Filter templates by type.').optional(),
folderId: z.string().describe('The ID of the folder to filter templates by.').optional(),
includeAllFolders: z.boolean().describe('Include templates from all folders.').optional(),
tagIds: z.array(z.string()).describe('Filter templates by tag IDs.').optional(),
});
export const ZFindOrganisationTemplatesRequestSchema = ZFindSearchParamsSchema;
+7
View File
@@ -93,6 +93,13 @@ declare namespace NodeJS {
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP?: string;
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS?: string;
NEXT_PUBLIC_DISABLE_SIGNIN?: string;
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN?: string;
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN?: string;
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN?: string;
NEXT_PUBLIC_DISABLE_OIDC_SIGNIN?: string;
NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT?: string;
NEXT_PRIVATE_BROWSERLESS_URL?: string;
NEXT_PRIVATE_JOBS_PROVIDER?: 'inngest' | 'local' | 'bullmq';
+24
View File
@@ -0,0 +1,24 @@
import type { TTagLite } from '@documenso/lib/types/tag';
import type { ReactNode } from 'react';
import { cn } from '../../lib/utils';
export type TagBadgeProps = {
tag: Pick<TTagLite, 'name'>;
className?: string;
children?: ReactNode;
};
export const TagBadge = ({ tag, className, children }: TagBadgeProps) => {
return (
<span
className={cn(
'inline-flex items-center rounded-md px-2 py-0.5 font-medium text-xs ring-1 ring-inset',
'bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20',
className,
)}
>
{tag.name}
{children}
</span>
);
};
+77
View File
@@ -0,0 +1,77 @@
import type { TagType } from '@documenso/lib/types/tag-type';
import { trpc } from '@documenso/trpc/react';
import { Trans } from '@lingui/react/macro';
import { TagIcon } from 'lucide-react';
import { useSearchParams } from 'react-router';
import { Button } from '../button';
import { Checkbox } from '../checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { TagBadge } from './tag-badge';
export type TagFilterProps = {
type: (typeof TagType)[keyof typeof TagType];
};
export const TagFilter = ({ type }: TagFilterProps) => {
const [searchParams] = useSearchParams();
const [, setSearchParams] = useSearchParams();
const { data: tags } = trpc.tag.findTags.useQuery({
type,
perPage: 100,
});
const selectedTagIds = searchParams.get('tagIds')?.split(',').filter(Boolean) ?? [];
const toggleTag = (tagId: string) => {
const newTagIds = selectedTagIds.includes(tagId)
? selectedTagIds.filter((id) => id !== tagId)
: [...selectedTagIds, tagId];
setSearchParams(
(prev) => {
if (newTagIds.length > 0) {
prev.set('tagIds', newTagIds.join(','));
} else {
prev.delete('tagIds');
}
return prev;
},
{ preventScrollReset: true },
);
};
if (!tags || tags.data.length === 0) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<TagIcon className="h-4 w-4" />
<Trans>Tags</Trans>
{selectedTagIds.length > 0 && (
<span className="ml-1 rounded-full bg-primary px-1.5 py-0.5 text-primary-foreground text-xs">
{selectedTagIds.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2">
{tags.data.map((tag) => (
<label
key={tag.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-accent"
>
<Checkbox checked={selectedTagIds.includes(tag.id)} onCheckedChange={() => toggleTag(tag.id)} />
<TagBadge tag={tag} />
</label>
))}
</div>
</PopoverContent>
</Popover>
);
};
+162
View File
@@ -0,0 +1,162 @@
import type { TTagLite } from '@documenso/lib/types/tag';
import type { TagType } from '@documenso/lib/types/tag-type';
import { trpc } from '@documenso/trpc/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { PlusIcon, XIcon } from 'lucide-react';
import { useState } from 'react';
import { cn } from '../../lib/utils';
import { Button } from '../button';
import { Input } from '../input';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { TagBadge } from './tag-badge';
export type TagInputProps = {
type: (typeof TagType)[keyof typeof TagType];
envelopeId: string;
assignedTags: TTagLite[];
className?: string;
};
export const TagInput = ({ type, envelopeId, assignedTags, className }: TagInputProps) => {
const { t } = useLingui();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const utils = trpc.useUtils();
const { data: tagsData } = trpc.tag.findTags.useQuery({
type,
perPage: 100,
});
const createTagMutation = trpc.tag.createTag.useMutation({
onSuccess: () => {
void utils.tag.findTags.invalidate();
},
});
const setEnvelopeTagsMutation = trpc.tag.setEnvelopeTags.useMutation({
onSuccess: () => {
void utils.tag.getEnvelopeTags.invalidate({ envelopeId });
},
});
const allTags = tagsData?.data ?? [];
const assignedTagIds = new Set(assignedTags.map((t) => t.id));
const availableTags = allTags.filter((t) => !assignedTagIds.has(t.id));
const filteredTags = search
? availableTags.filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
: availableTags;
const exactMatch = allTags.find((t) => t.name.toLowerCase() === search.toLowerCase().trim());
const canCreate = search.trim().length > 0 && !exactMatch;
const handleAssignTag = (tagId: string) => {
setEnvelopeTagsMutation.mutate({
envelopeId,
tagIds: [...assignedTagIds, tagId],
});
setSearch('');
setOpen(false);
};
const handleUnassignTag = (tagId: string) => {
setEnvelopeTagsMutation.mutate({
envelopeId,
tagIds: assignedTags.filter((t) => t.id !== tagId).map((t) => t.id),
});
};
const handleCreateAndAssign = async () => {
const tag = await createTagMutation.mutateAsync({
name: search.trim(),
type,
});
handleAssignTag(tag.id);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && canCreate) {
e.preventDefault();
void handleCreateAndAssign();
}
};
const isMutating = createTagMutation.isPending || setEnvelopeTagsMutation.isPending;
return (
<div className={cn('flex flex-wrap items-center gap-1.5', className)}>
{assignedTags.map((tag) => (
<TagBadge key={tag.id} tag={tag} className="gap-1 pr-1">
<button
type="button"
onClick={() => handleUnassignTag(tag.id)}
className="ml-0.5 rounded-sm opacity-60 hover:opacity-100"
aria-label={t`Remove ${tag.name}`}
>
<XIcon className="h-3 w-3" />
</button>
</TagBadge>
))}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-6 gap-1 px-2 text-xs" disabled={isMutating}>
<PlusIcon className="h-3 w-3" />
<Trans>Add tag</Trans>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="flex flex-col">
<Input
placeholder={t`Search or create tag...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
className="h-9 rounded-none border-0 border-b focus-visible:ring-0"
autoFocus
/>
<div className="max-h-48 overflow-y-auto p-1">
{filteredTags.length > 0 && (
<div className="flex flex-col gap-0.5">
{filteredTags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => handleAssignTag(tag.id)}
className="flex items-center rounded-md px-2 py-1.5 text-left hover:bg-accent"
>
<TagBadge tag={tag} />
</button>
))}
</div>
)}
{canCreate && (
<button
type="button"
onClick={() => void handleCreateAndAssign()}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
>
<PlusIcon className="h-4 w-4" />
<span>
<Trans>Create "{search.trim()}"</Trans>
</span>
</button>
)}
{filteredTags.length === 0 && !canCreate && (
<div className="px-2 py-3 text-muted-foreground text-sm">
<Trans>No tags found</Trans>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
};
+32
View File
@@ -0,0 +1,32 @@
import type { TTagLite } from '@documenso/lib/types/tag';
import { Link } from 'react-router';
import { TagBadge } from './tag-badge';
export type TagListProps = {
tags: TTagLite[];
maxVisible?: number;
getTagHref?: (tag: TTagLite) => string;
};
export const TagList = ({ tags, maxVisible = 3, getTagHref }: TagListProps) => {
if (tags.length === 0) {
return null;
}
return (
<div className="flex max-w-[12rem] flex-wrap gap-1">
{tags.slice(0, maxVisible).map((tag) =>
getTagHref ? (
<Link key={tag.id} to={getTagHref(tag)}>
<TagBadge tag={tag} />
</Link>
) : (
<TagBadge key={tag.id} tag={tag} />
),
)}
{tags.length > maxVisible && <span className="text-muted-foreground text-xs">+{tags.length - maxVisible}</span>}
</div>
);
};
+12
View File
@@ -163,6 +163,18 @@ services:
sync: false
- key: NEXT_PUBLIC_DISABLE_OIDC_SIGNUP
sync: false
- key: NEXT_PUBLIC_DISABLE_SIGNIN
sync: false
- key: NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN
sync: false
- key: NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN
sync: false
- key: NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN
sync: false
- key: NEXT_PUBLIC_DISABLE_OIDC_SIGNIN
sync: false
- key: NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT
sync: false
- key: NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS
sync: false
+6
View File
@@ -53,6 +53,12 @@
"NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP",
"NEXT_PUBLIC_DISABLE_OIDC_SIGNUP",
"NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS",
"NEXT_PUBLIC_DISABLE_SIGNIN",
"NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN",
"NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN",
"NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN",
"NEXT_PUBLIC_DISABLE_OIDC_SIGNIN",
"NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT",
"NEXT_PRIVATE_PLAIN_API_KEY",
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
"NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY",