mirror of
https://github.com/documenso/documenso.git
synced 2025-11-09 20:12:31 +10:00
Compare commits
87 Commits
exp/keyboa
...
eff7d90f43
| Author | SHA1 | Date | |
|---|---|---|---|
| eff7d90f43 | |||
| db5524f8ce | |||
| 3d539b20ad | |||
| 48626b9169 | |||
| 88371b665a | |||
| 1650c55b19 | |||
| 60d73e0921 | |||
| 4a779ec81e | |||
| 7f19ec1265 | |||
| d6a2f5a4c9 | |||
| d05bfa9fed | |||
| d2a009d52e | |||
| 9350c53c7d | |||
| ffce7a2c81 | |||
| 353bdce86b | |||
| e13b9f7c84 | |||
| 9908580bf1 | |||
| b0b07106b4 | |||
| 35250fa308 | |||
| 5cdd7f8623 | |||
| 47bdcd833f | |||
| 03eb6af69a | |||
| 88836404d1 | |||
| 2eebc0e439 | |||
| 4a3859ec60 | |||
| 49b792503f | |||
| c3dc76b1b4 | |||
| daab8461c7 | |||
| 1ffc4bd703 | |||
| f15c0778b5 | |||
| 06cb8b1f23 | |||
| 7f09ba72f4 | |||
| 7b17156e56 | |||
| 86e89e137e | |||
| 26f65dbdd7 | |||
| a902bec96d | |||
| 399f91de73 | |||
| 995bc9c362 | |||
| 3467317271 | |||
| a5eaa8ad47 | |||
| 577691214b | |||
| c7d21c6587 | |||
| 2aa391f917 | |||
| 681540b501 | |||
| f3305ac306 | |||
| 68b4305b6a | |||
| 3de1ea0a02 | |||
| b8fc47b719 | |||
| cfceebd78f | |||
| b9b3ddfb98 | |||
| 8590502338 | |||
| 53f29daf50 | |||
| 197d17ed7b | |||
| 3c646d9475 | |||
| ed4dfc9b55 | |||
| 32ce573de4 | |||
| 2ecfdbdde5 | |||
| a3005f8616 | |||
| 2c0d4f8789 | |||
| 7c8e93b53e | |||
| 93a3809f6a | |||
| 4550bca3d3 | |||
| 9ac7b94d9a | |||
| 374f2c45b4 | |||
| bb5c2edefd | |||
| 19565c1821 | |||
| 2603ae8b90 | |||
| 7d257236a6 | |||
| 31c1a9a783 | |||
| 657db3bc84 | |||
| 184ebdedf1 | |||
| 4012022f55 | |||
| 44f5da95b3 | |||
| 7eb882aea8 | |||
| dbf10e5b7b | |||
| fe4d3ed1fd | |||
| b8d07fd1a6 | |||
| 49fabeb0ec | |||
| 5a5bfe6e34 | |||
| d7e5a9eec7 | |||
| adefac81e2 | |||
| 67501b45cf | |||
| 17b36ac8e4 | |||
| 80e452afa2 | |||
| 1cb9de8083 | |||
| 231ef9c27e | |||
| 6f35342a83 |
10
.env.example
10
.env.example
@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
||||
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||
# Find documentation on setting up Microsoft OAuth here:
|
||||
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
|
||||
|
||||
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
||||
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||
@ -25,6 +29,10 @@ NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||
# URL used by the web app to request itself (e.g. local background jobs)
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
||||
|
||||
# [[SERVER]]
|
||||
# OPTIONAL: The port the server will listen on. Defaults to 3000.
|
||||
PORT=3000
|
||||
|
||||
# [[DATABASE]]
|
||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||
@ -136,3 +144,5 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
# OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
|
||||
NEXT_PRIVATE_LOGGER_FILE_PATH=
|
||||
|
||||
# [[PLAIN SUPPORT]]
|
||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -56,4 +56,7 @@ logs.json
|
||||
|
||||
# claude
|
||||
.claude
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
|
||||
# agents
|
||||
.specs
|
||||
|
||||
57
AGENTS.md
Normal file
57
AGENTS.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Agent Guidelines for Documenso
|
||||
|
||||
## Build/Test/Lint Commands
|
||||
|
||||
- `npm run build` - Build all packages
|
||||
- `npm run lint` - Lint all packages
|
||||
- `npm run lint:fix` - Auto-fix linting issues
|
||||
- `npm run test:e2e` - Run E2E tests with Playwright
|
||||
- `npm run test:dev -w @documenso/app-tests` - Run single E2E test in dev mode
|
||||
- `npm run test-ui:dev -w @documenso/app-tests` - Run E2E tests with UI
|
||||
- `npm run format` - Format code with Prettier
|
||||
- `npm run dev` - Start development server for Remix app
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- Use TypeScript for all code; prefer `type` over `interface`
|
||||
- Use functional components with `const Component = () => {}`
|
||||
- Never use classes; prefer functional/declarative patterns
|
||||
- Use descriptive variable names with auxiliary verbs (isLoading, hasError)
|
||||
- Directory names: lowercase with dashes (auth-wizard)
|
||||
- Use named exports for components
|
||||
- Never use 'use client' directive
|
||||
- Never use 1-line if statements
|
||||
- Structure files: exported component, subcomponents, helpers, static content, types
|
||||
|
||||
## Error Handling & Validation
|
||||
|
||||
- Use custom AppError class when throwing errors
|
||||
- When catching errors on the frontend use `const error = AppError.parse(error)` to get the error code
|
||||
- Use early returns and guard clauses
|
||||
- Use Zod for form validation and react-hook-form for forms
|
||||
- Use error boundaries for unexpected errors
|
||||
|
||||
## UI & Styling
|
||||
|
||||
- Use Shadcn UI, Radix, and Tailwind CSS with mobile-first approach
|
||||
- Use `<Form>` `<FormItem>` elements with fieldset having `:disabled` attribute when loading
|
||||
- Use Lucide icons with longhand names (HomeIcon vs Home)
|
||||
|
||||
## TRPC Routes
|
||||
|
||||
- Each route in own file: `routers/teams/create-team.ts`
|
||||
- Associated types file: `routers/teams/create-team.types.ts`
|
||||
- Request/response schemas: `Z[RouteName]RequestSchema`, `Z[RouteName]ResponseSchema`
|
||||
- Only use GET and POST methods in OpenAPI meta
|
||||
- Deconstruct input argument on its own line
|
||||
- Prefer route names such as get/getMany/find/create/update/delete
|
||||
- "create" routes request schema should have the ID and data in the top level
|
||||
- "update" routes request schema should have the ID in the top level and the data in a nested "data" object
|
||||
|
||||
## Translations & Remix
|
||||
|
||||
- Use `<Trans>string</Trans>` for JSX translations from `@lingui/react/macro`
|
||||
- Use `t\`string\`` macro for TypeScript translations
|
||||
- Use `(params: Route.Params)` and `(loaderData: Route.LoaderData)` for routes
|
||||
- Directly return data from loaders, don't use `json()`
|
||||
- Use `superLoaderJson` when sending complex data through loaders such as dates or prisma decimals
|
||||
692
CODE_STYLE.md
Normal file
692
CODE_STYLE.md
Normal file
@ -0,0 +1,692 @@
|
||||
# Documenso Code Style Guide
|
||||
|
||||
This document captures the code style, patterns, and conventions used in the Documenso codebase. It covers both enforceable rules and subjective "taste" elements that make our code consistent and maintainable.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [General Principles](#general-principles)
|
||||
2. [TypeScript Conventions](#typescript-conventions)
|
||||
3. [Imports & Dependencies](#imports--dependencies)
|
||||
4. [Functions & Methods](#functions--methods)
|
||||
5. [React & Components](#react--components)
|
||||
6. [Error Handling](#error-handling)
|
||||
7. [Async/Await Patterns](#asyncawait-patterns)
|
||||
8. [Whitespace & Formatting](#whitespace--formatting)
|
||||
9. [Naming Conventions](#naming-conventions)
|
||||
10. [Pattern Matching](#pattern-matching)
|
||||
11. [Database & Prisma](#database--prisma)
|
||||
12. [TRPC Patterns](#trpc-patterns)
|
||||
|
||||
---
|
||||
|
||||
## General Principles
|
||||
|
||||
- **Functional over Object-Oriented**: Prefer functional programming patterns over classes
|
||||
- **Explicit over Implicit**: Be explicit about types, return values, and error cases
|
||||
- **Early Returns**: Use guard clauses and early returns to reduce nesting
|
||||
- **Immutability**: Favor `const` over `let`; avoid mutation where possible
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Conventions
|
||||
|
||||
### Type Definitions
|
||||
|
||||
```typescript
|
||||
// ✅ Prefer `type` over `interface`
|
||||
type CreateDocumentOptions = {
|
||||
templateId: number;
|
||||
userId: number;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
// ❌ Avoid interfaces unless absolutely necessary
|
||||
interface CreateDocumentOptions {
|
||||
templateId: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Type Imports
|
||||
|
||||
```typescript
|
||||
// ✅ Use `type` keyword for type-only imports
|
||||
import type { Document, Recipient } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
// Types in function signatures
|
||||
export const findDocuments = async ({ userId, teamId }: FindDocumentsOptions) => {
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### Inline Types for Function Parameters
|
||||
|
||||
```typescript
|
||||
// ✅ Extract inline types to named types
|
||||
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
|
||||
templateRecipientId: number;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
const finalRecipients: FinalRecipient[] = [];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Imports & Dependencies
|
||||
|
||||
### Import Organization
|
||||
|
||||
Imports should be organized in the following order with blank lines between groups:
|
||||
|
||||
```typescript
|
||||
// 1. React imports
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
// 2. Third-party library imports (alphabetically)
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Document, Recipient } from '@prisma/client';
|
||||
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
// 3. Internal package imports (from @documenso/*)
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
// 4. Relative imports
|
||||
import { getTeamById } from '../team/get-team';
|
||||
import type { FindResultResponse } from './types';
|
||||
```
|
||||
|
||||
### Destructuring Imports
|
||||
|
||||
```typescript
|
||||
// ✅ Destructure specific exports
|
||||
// ✅ Use type imports for types
|
||||
import type { Document } from '@prisma/client';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Functions & Methods
|
||||
|
||||
### Arrow Functions
|
||||
|
||||
```typescript
|
||||
// ✅ Always use arrow functions for functions
|
||||
export const createDocument = async ({
|
||||
userId,
|
||||
title,
|
||||
}: CreateDocumentOptions) => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ✅ Callbacks and handlers
|
||||
const onSubmit = useCallback(async () => {
|
||||
// ...
|
||||
}, [dependencies]);
|
||||
|
||||
// ❌ Avoid regular function declarations
|
||||
function createDocument() {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Function Parameters
|
||||
|
||||
```typescript
|
||||
// ✅ Use destructured object parameters for multiple params
|
||||
export const findDocuments = async ({
|
||||
userId,
|
||||
teamId,
|
||||
status = ExtendedDocumentStatus.ALL,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
}: FindDocumentsOptions) => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ✅ Destructure on separate line when needed
|
||||
const onFormSubmit = form.handleSubmit(onSubmit);
|
||||
|
||||
// ✅ Deconstruct nested properties explicitly
|
||||
const { user } = ctx;
|
||||
const { templateId } = input;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React & Components
|
||||
|
||||
### Component Definition
|
||||
|
||||
```typescript
|
||||
// ✅ Use const with arrow function
|
||||
export const AddSignersFormPartial = ({
|
||||
documentFlow,
|
||||
recipients,
|
||||
fields,
|
||||
onSubmit,
|
||||
}: AddSignersFormProps) => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ❌ Never use classes
|
||||
class MyComponent extends React.Component {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Hooks
|
||||
|
||||
```typescript
|
||||
// ✅ Group related hooks together with blank line separation
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { currentStep, totalSteps, previousStep } = useStep();
|
||||
|
||||
const form = useForm<TFormSchema>({
|
||||
resolver: zodResolver(ZFormSchema),
|
||||
defaultValues: {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Event Handlers
|
||||
|
||||
```typescript
|
||||
// ✅ Use arrow functions with descriptive names
|
||||
const onFormSubmit = async () => {
|
||||
await form.trigger();
|
||||
// ...
|
||||
};
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null) => {
|
||||
event?.preventDefault();
|
||||
// ...
|
||||
},
|
||||
[dependencies],
|
||||
);
|
||||
|
||||
// ✅ Inline handlers for simple operations
|
||||
<Button onClick={() => setOpen(false)}>Close</Button>
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
// ✅ Descriptive state names with auxiliary verbs
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
|
||||
// ✅ Complex state in single useState when related
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Try-Catch Blocks
|
||||
|
||||
```typescript
|
||||
// ✅ Use try-catch for operations that might fail
|
||||
try {
|
||||
const document = await getDocumentById({
|
||||
documentId: Number(documentId),
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: document,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Throwing Errors
|
||||
|
||||
```typescript
|
||||
// ✅ Use AppError for application errors
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Template not found',
|
||||
});
|
||||
|
||||
// ✅ Use descriptive error messages
|
||||
if (!template) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Template with ID ${templateId} not found`,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Error Parsing on Frontend
|
||||
|
||||
```typescript
|
||||
// ✅ Parse errors on the frontend
|
||||
try {
|
||||
await updateOrganisation({ organisationId, data });
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`An error occurred`,
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async/Await Patterns
|
||||
|
||||
### Async Function Definitions
|
||||
|
||||
```typescript
|
||||
// ✅ Mark async functions clearly
|
||||
export const createDocument = async ({
|
||||
userId,
|
||||
title,
|
||||
}: Options): Promise<Document> => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ✅ Use await for promises
|
||||
const document = await prisma.document.create({ data });
|
||||
|
||||
// ✅ Use Promise.all for parallel operations
|
||||
const [document, recipients] = await Promise.all([
|
||||
getDocumentById({ documentId }),
|
||||
getRecipientsForDocument({ documentId }),
|
||||
]);
|
||||
```
|
||||
|
||||
### Void for Fire-and-Forget
|
||||
|
||||
```typescript
|
||||
// ✅ Use void for intentionally unwaited promises
|
||||
void handleAutoSave();
|
||||
|
||||
// ✅ Or in event handlers
|
||||
onClick={() => void onFormSubmit()}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Whitespace & Formatting
|
||||
|
||||
### Blank Lines Between Concepts
|
||||
|
||||
```typescript
|
||||
// ✅ Blank line after imports
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export const findDocuments = async () => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ✅ Blank line between logical sections
|
||||
const user = await prisma.user.findFirst({ where: { id: userId } });
|
||||
|
||||
let team = null;
|
||||
|
||||
if (teamId !== undefined) {
|
||||
team = await getTeamById({ userId, teamId });
|
||||
}
|
||||
|
||||
// ✅ Blank line before return statements
|
||||
const result = await someOperation();
|
||||
|
||||
return result;
|
||||
```
|
||||
|
||||
### Function/Method Spacing
|
||||
|
||||
```typescript
|
||||
// ✅ No blank lines between chained methods in same operation
|
||||
const documents = await prisma.document
|
||||
.findMany({ where: { userId } })
|
||||
.then((docs) => docs.map(maskTokens));
|
||||
|
||||
// ✅ Blank line between different operations
|
||||
const document = await createDocument({ userId });
|
||||
|
||||
await sendDocument({ documentId: document.id });
|
||||
|
||||
return document;
|
||||
```
|
||||
|
||||
### Object and Array Formatting
|
||||
|
||||
```typescript
|
||||
// ✅ Multi-line when complex
|
||||
const options = {
|
||||
userId,
|
||||
teamId,
|
||||
status: ExtendedDocumentStatus.ALL,
|
||||
page: 1,
|
||||
};
|
||||
|
||||
// ✅ Single line when simple
|
||||
const coords = { x: 0, y: 0 };
|
||||
|
||||
// ✅ Array items on separate lines when objects
|
||||
const recipients = [
|
||||
{
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
},
|
||||
{
|
||||
name: 'Jane',
|
||||
email: 'jane@example.com',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Variables
|
||||
|
||||
```typescript
|
||||
// ✅ camelCase for variables and functions
|
||||
const documentId = 123;
|
||||
const onSubmit = () => {};
|
||||
|
||||
// ✅ Descriptive names with auxiliary verbs for booleans
|
||||
const isLoading = false;
|
||||
const hasError = false;
|
||||
const canEdit = true;
|
||||
const shouldRender = true;
|
||||
|
||||
// ✅ Prefix with $ for DOM elements
|
||||
const $page = document.querySelector('.page');
|
||||
const $inputRef = useRef<HTMLInputElement>(null);
|
||||
```
|
||||
|
||||
### Types and Schemas
|
||||
|
||||
```typescript
|
||||
// ✅ PascalCase for types
|
||||
type CreateDocumentOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
// ✅ Prefix Zod schemas with Z
|
||||
const ZCreateDocumentSchema = z.object({
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
// ✅ Prefix type from Zod schema with T
|
||||
type TCreateDocumentSchema = z.infer<typeof ZCreateDocumentSchema>;
|
||||
```
|
||||
|
||||
### Constants
|
||||
|
||||
```typescript
|
||||
// ✅ UPPER_SNAKE_CASE for true constants
|
||||
const DEFAULT_DOCUMENT_DATE_FORMAT = 'dd/MM/yyyy';
|
||||
const MAX_FILE_SIZE = 1024 * 1024 * 5;
|
||||
|
||||
// ✅ camelCase for const variables that aren't "constants"
|
||||
const userId = await getUserId();
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
```typescript
|
||||
// ✅ Verb-based names for functions
|
||||
const createDocument = async () => {};
|
||||
const findDocuments = async () => {};
|
||||
const updateDocument = async () => {};
|
||||
const deleteDocument = async () => {};
|
||||
|
||||
// ✅ On prefix for event handlers
|
||||
const onSubmit = () => {};
|
||||
const onClick = () => {};
|
||||
const onFieldCopy = () => {}; // 'on' is also acceptable
|
||||
```
|
||||
|
||||
### Clarity Over Brevity
|
||||
|
||||
```typescript
|
||||
// ✅ Prefer descriptive names over abbreviations
|
||||
const superLongMethodThatIsCorrect = () => {};
|
||||
const recipientAuthenticationOptions = {};
|
||||
const documentMetadata = {};
|
||||
|
||||
// ❌ Avoid abbreviations that sacrifice clarity
|
||||
const supLongMethThatIsCorrect = () => {};
|
||||
const recipAuthOpts = {};
|
||||
const docMeta = {};
|
||||
|
||||
// ✅ Common abbreviations that are widely understood are acceptable
|
||||
const userId = 123;
|
||||
const htmlElement = document.querySelector('div');
|
||||
const apiResponse = await fetch('/api');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern Matching
|
||||
|
||||
### Using ts-pattern
|
||||
|
||||
```typescript
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
// ✅ Use match for complex conditionals
|
||||
const result = match(status)
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||
status: 'draft',
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.PENDING, () => ({
|
||||
status: 'pending',
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||
status: 'completed',
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
// ✅ Use .otherwise() for default case when not exhaustive
|
||||
const value = match(type)
|
||||
.with('text', () => 'Text field')
|
||||
.with('number', () => 'Number field')
|
||||
.otherwise(() => 'Unknown field');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database & Prisma
|
||||
|
||||
### Query Structure
|
||||
|
||||
```typescript
|
||||
// ✅ Destructure commonly used fields
|
||||
const { id, email, name } = user;
|
||||
|
||||
// ✅ Use select to limit returned fields
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ Use include for relations
|
||||
const document = await prisma.document.findFirst({
|
||||
where: { id: documentId },
|
||||
include: {
|
||||
recipients: true,
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Transactions
|
||||
|
||||
```typescript
|
||||
// ✅ Use transactions for related operations
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.create({ data });
|
||||
|
||||
await tx.field.createMany({ data: fieldsData });
|
||||
|
||||
await tx.documentAuditLog.create({ data: auditData });
|
||||
|
||||
return document;
|
||||
});
|
||||
```
|
||||
|
||||
### Where Clauses
|
||||
|
||||
```typescript
|
||||
// ✅ Build complex where clauses separately
|
||||
const whereClause: Prisma.DocumentWhereInput = {
|
||||
AND: [
|
||||
{ userId: user.id },
|
||||
{ deletedAt: null },
|
||||
{ status: { in: [DocumentStatus.DRAFT, DocumentStatus.PENDING] } },
|
||||
],
|
||||
};
|
||||
|
||||
const documents = await prisma.document.findMany({
|
||||
where: whereClause,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TRPC Patterns
|
||||
|
||||
### Router Structure
|
||||
|
||||
```typescript
|
||||
// ✅ Destructure context and input at start
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const { templateId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { templateId },
|
||||
});
|
||||
|
||||
return await getTemplateById({
|
||||
id: templateId,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Request/Response Schemas
|
||||
|
||||
```typescript
|
||||
// ✅ Name schemas clearly
|
||||
const ZCreateDocumentRequestSchema = z.object({
|
||||
title: z.string(),
|
||||
recipients: z.array(ZRecipientSchema),
|
||||
});
|
||||
|
||||
const ZCreateDocumentResponseSchema = z.object({
|
||||
documentId: z.number(),
|
||||
status: z.string(),
|
||||
});
|
||||
```
|
||||
|
||||
### Error Handling in TRPC
|
||||
|
||||
```typescript
|
||||
// ✅ Catch and transform errors appropriately
|
||||
try {
|
||||
const result = await createDocument({ userId, data });
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
return AppError.toRestAPIError(err);
|
||||
}
|
||||
|
||||
// ✅ Or throw AppError directly
|
||||
if (!template) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Template not found',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Patterns
|
||||
|
||||
### Optional Chaining
|
||||
|
||||
```typescript
|
||||
// ✅ Use optional chaining for potentially undefined values
|
||||
const email = user?.email;
|
||||
const recipientToken = recipient?.token ?? '';
|
||||
|
||||
// ✅ Use nullish coalescing for defaults
|
||||
const pageSize = perPage ?? 10;
|
||||
const status = documentStatus ?? DocumentStatus.DRAFT;
|
||||
```
|
||||
|
||||
### Array Operations
|
||||
|
||||
```typescript
|
||||
// ✅ Use functional array methods
|
||||
const activeRecipients = recipients.filter((r) => r.signingStatus === 'SIGNED');
|
||||
const recipientEmails = recipients.map((r) => r.email);
|
||||
const hasSignedRecipients = recipients.some((r) => r.signingStatus === 'SIGNED');
|
||||
|
||||
// ✅ Use find instead of filter + [0]
|
||||
const recipient = recipients.find((r) => r.id === recipientId);
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
```typescript
|
||||
// ✅ Use && for conditional rendering
|
||||
{isLoading && <Loader />}
|
||||
|
||||
// ✅ Use ternary for either/or
|
||||
{isLoading ? <Loader /> : <Content />}
|
||||
|
||||
// ✅ Extract complex conditions to variables
|
||||
const shouldShowAdvanced = isAdmin && hasPermission && !isDisabled;
|
||||
{shouldShowAdvanced && <AdvancedSettings />}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When in Doubt
|
||||
|
||||
- **Consistency**: Follow the patterns you see in similar files
|
||||
- **Readability**: Favor code that's easy to read over clever one-liners
|
||||
- **Explicitness**: Be explicit rather than implicit
|
||||
- **Whitespace**: Use blank lines to separate logical sections
|
||||
- **Early Returns**: Use guard clauses to reduce nesting
|
||||
- **Functional**: Prefer functional patterns over imperative ones
|
||||
@ -214,8 +214,6 @@ For detailed instructions on how to configure and run the Docker container, plea
|
||||
|
||||
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
|
||||
|
||||
> Please note that the below deployment methods are for v0.9, we will update these to v1.0 once it has been released.
|
||||
|
||||
### Fetch, configure, and build
|
||||
|
||||
First, clone the code from Github:
|
||||
@ -258,7 +256,7 @@ npm run start
|
||||
|
||||
This will start the server on `localhost:3000`. For now, any reverse proxy can then do the frontend and SSL termination.
|
||||
|
||||
> If you want to run with another port than 3000, you can start the application with `next -p <ANY PORT>` from the `apps/web` folder.
|
||||
> If you want to run with another port than 3000, you can start the application with `next -p <ANY PORT>` from the `apps/remix` folder.
|
||||
|
||||
### Run as a service
|
||||
|
||||
@ -308,7 +306,7 @@ The Web UI can be found at http://localhost:9000, while the SMTP port will be on
|
||||
|
||||
### Support IPv6
|
||||
|
||||
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Next.js start command
|
||||
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command
|
||||
|
||||
For local docker run
|
||||
|
||||
|
||||
21
SIGNING.md
21
SIGNING.md
@ -10,15 +10,26 @@ For the digital signature of your documents you need a signing certificate in .p
|
||||
|
||||
`openssl req -new -x509 -key private.key -out certificate.crt -days 365`
|
||||
|
||||
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The -days parameter sets the number of days for which the certificate is valid.
|
||||
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The `-days` parameter sets the number of days for which the certificate is valid.
|
||||
|
||||
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following command to do this:
|
||||
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following commands to do this:
|
||||
|
||||
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
||||
```bash
|
||||
# Set certificate password securely (won't appear in command history)
|
||||
read -s -p "Enter certificate password: " CERT_PASS
|
||||
echo
|
||||
|
||||
# Create the p12 certificate using the environment variable
|
||||
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt \
|
||||
-password env:CERT_PASS \
|
||||
-keypbe PBE-SHA1-3DES \
|
||||
-certpbe PBE-SHA1-3DES \
|
||||
-macalg sha1
|
||||
```
|
||||
|
||||
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
|
||||
4. **IMPORTANT**: A certificate password is required to prevent signing failures. Make sure to use a strong password (minimum 4 characters) when prompted. Certificates without passwords will cause "Failed to get private key bags" errors during document signing.
|
||||
|
||||
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||
5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ The translation files are organized into folders represented by their respective
|
||||
Each PO file contains translations which look like this:
|
||||
|
||||
```po
|
||||
#: apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx:61
|
||||
#: apps/remix/app/(signing)/sign/[token]/no-longer-available.tsx:61
|
||||
msgid "Want to send slick signing links like this one? <0>Check out Documenso.</0>"
|
||||
msgstr "Möchten Sie auffällige Signatur-Links wie diesen senden? <0>Überprüfen Sie Documenso.</0>"
|
||||
```
|
||||
|
||||
@ -54,7 +54,7 @@ Install the project dependencies as follows:
|
||||
|
||||
```bash
|
||||
npm i
|
||||
npm run build:web
|
||||
npm run build
|
||||
npm run prisma:migrate-deploy
|
||||
```
|
||||
|
||||
@ -69,7 +69,7 @@ npm run start
|
||||
This will start the server on `localhost:3000`. Any reverse proxy can handle the front end and SSL termination.
|
||||
|
||||
<Callout type="info">
|
||||
If you want to run with another port than `3000`, you can start the application with `next -p <ANY PORT>` from the `apps/web` folder.
|
||||
If you want to run with another port than `3000`, you can start the application with `next -p <ANY PORT>` from the `apps/remix` folder.
|
||||
</Callout>
|
||||
|
||||
</Steps>
|
||||
@ -119,16 +119,89 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
||||
```
|
||||
|
||||
### Update the Volume Binding
|
||||
### Set Up Your Signing Certificate
|
||||
|
||||
The `cert.p12` file is required to sign and encrypt documents, so you must provide your key file. Update the volume binding in the `compose.yml` file to point to your key file:
|
||||
<Callout type="warning">
|
||||
This is the most common source of issues for self-hosters. Please follow these steps carefully.
|
||||
</Callout>
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/your/keyfile.p12:/opt/documenso/cert.p12
|
||||
```
|
||||
The `cert.p12` file is required to sign and encrypt documents. You have three options:
|
||||
|
||||
After updating the volume binding, save the `compose.yml` file and run the following command to start the containers:
|
||||
#### Option A: Generate Certificate Inside Container (Recommended)
|
||||
|
||||
This method avoids file permission issues by creating the certificate directly inside the Docker container:
|
||||
|
||||
1. Start your containers:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. Set certificate password securely and generate certificate inside the container:
|
||||
|
||||
```bash
|
||||
# Set certificate password securely (won't appear in command history)
|
||||
read -s -p "Enter certificate password: " CERT_PASS
|
||||
echo
|
||||
|
||||
# Generate certificate inside container using environment variable
|
||||
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout /tmp/private.key \
|
||||
-out /tmp/certificate.crt \
|
||||
-subj '/C=US/ST=State/L=City/O=Organization/CN=localhost' && \
|
||||
openssl pkcs12 -export -out /app/certs/cert.p12 \
|
||||
-inkey /tmp/private.key -in /tmp/certificate.crt \
|
||||
-passout env:CERT_PASS && \
|
||||
rm /tmp/private.key /tmp/certificate.crt
|
||||
"
|
||||
```
|
||||
|
||||
3. Add the certificate passphrase to your `.env` file:
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE="your_password_here"
|
||||
```
|
||||
|
||||
4. Restart the container to apply changes:
|
||||
```bash
|
||||
docker-compose restart documenso
|
||||
```
|
||||
|
||||
#### Option B: Use an Existing Certificate File
|
||||
|
||||
If you have an existing `.p12` certificate file:
|
||||
|
||||
1. **Place your certificate file** in an accessible location on your host system
|
||||
2. **Set proper permissions:**
|
||||
|
||||
```bash
|
||||
# Make sure the certificate is readable
|
||||
chmod 644 /path/to/your/cert.p12
|
||||
|
||||
# For Docker, ensure proper ownership
|
||||
chown 1001:1001 /path/to/your/cert.p12
|
||||
```
|
||||
|
||||
3. **Update the volume binding** in the `compose.yml` file:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
|
||||
```
|
||||
|
||||
4. **Add certificate configuration** to your `.env` file:
|
||||
```bash
|
||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE=your_certificate_password
|
||||
```
|
||||
|
||||
<Callout type="warning">
|
||||
Your certificate MUST have a password. Certificates without passwords will cause "Failed to get
|
||||
private key bags" errors.
|
||||
</Callout>
|
||||
|
||||
After setting up your certificate, save the `compose.yml` file and run the following command to start the containers:
|
||||
|
||||
```bash
|
||||
docker-compose --env-file ./.env up -d
|
||||
@ -249,7 +322,7 @@ After=network.target
|
||||
Environment=PATH=/path/to/your/node/binaries
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/documenso/apps/web
|
||||
WorkingDirectory=/var/www/documenso/apps/remix
|
||||
ExecStart=/usr/bin/next start -p 3500
|
||||
TimeoutSec=15
|
||||
Restart=always
|
||||
|
||||
@ -27,3 +27,33 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=<your-client-secret>
|
||||
```
|
||||
|
||||
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
|
||||
|
||||
## Microsoft OAuth (Azure AD)
|
||||
|
||||
To use Microsoft OAuth, you will need to create an Azure AD application registration in the Microsoft Azure portal. This will allow users to sign in with their Microsoft accounts.
|
||||
|
||||
### Create and configure a new Azure AD application
|
||||
|
||||
1. Go to the [Azure Portal](https://portal.azure.com/)
|
||||
2. Navigate to **Azure Active Directory** (or **Microsoft Entra ID** in newer Azure portals)
|
||||
3. In the left sidebar, click **App registrations**
|
||||
4. Click **New registration**
|
||||
5. Enter a name for your application (e.g., "Documenso")
|
||||
6. Under **Supported account types**, select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow any Microsoft account to sign in
|
||||
7. Under **Redirect URI**, select **Web** and enter: `https://<documenso-domain>/api/auth/callback/microsoft`
|
||||
8. Click **Register**
|
||||
|
||||
### Configure the application
|
||||
|
||||
1. After registration, you'll be taken to the app's overview page
|
||||
2. Copy the **Application (client) ID** - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_ID`
|
||||
3. In the left sidebar, click **Certificates & secrets**
|
||||
4. Under **Client secrets**, click **New client secret**
|
||||
5. Add a description and select an expiration period
|
||||
6. Click **Add** and copy the **Value** (not the Secret ID) - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET`
|
||||
7. In the Documenso environment variables, set the following:
|
||||
|
||||
```
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=<your-application-client-id>
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=<your-client-secret-value>
|
||||
```
|
||||
|
||||
@ -19,13 +19,13 @@ device, and other FDA-regulated industries.
|
||||
- [x] User Access Management
|
||||
- [x] Quality Assurance Documentation
|
||||
|
||||
## SOC/ SOC II
|
||||
## SOC 2
|
||||
|
||||
<Callout type="warning" emoji="⏳">
|
||||
Status: [Planned](https://github.com/documenso/backlog/issues/24)
|
||||
<Callout type="info" emoji="✅">
|
||||
Status: [Compliant](https://documen.so/trust)
|
||||
</Callout>
|
||||
|
||||
SOC II is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
|
||||
SOC 2 is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
|
||||
and data privacy in cloud and IT service organizations, established by the American Institute of Certified
|
||||
Public Accountants (AICPA).
|
||||
|
||||
@ -34,9 +34,9 @@ Public Accountants (AICPA).
|
||||
<Callout type="warning" emoji="⏳">
|
||||
Status: [Planned](https://github.com/documenso/backlog/issues/26)
|
||||
</Callout>
|
||||
ISO 27001 is an international standard for managing information security, specifying requirements for
|
||||
establishing, implementing, maintaining, and continually improving an information security management
|
||||
system (ISMS).
|
||||
ISO 27001 is an international standard for managing information security, specifying requirements
|
||||
for establishing, implementing, maintaining, and continually improving an information security
|
||||
management system (ISMS).
|
||||
|
||||
### HIPAA
|
||||
|
||||
|
||||
@ -18,6 +18,11 @@ The guide assumes you have a Documenso account. If you don't, you can create a f
|
||||
|
||||
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
|
||||
|
||||
<Callout type="info">
|
||||
The maximum file size for uploaded documents is 150MB in production. In staging, the limit is
|
||||
50MB.
|
||||
</Callout>
|
||||
|
||||

|
||||
|
||||
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.
|
||||
|
||||
@ -3,5 +3,6 @@
|
||||
"members": "Members",
|
||||
"groups": "Groups",
|
||||
"teams": "Teams",
|
||||
"sso": "SSO",
|
||||
"billing": "Billing"
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"index": "Configuration",
|
||||
"microsoft-entra-id": "Microsoft Entra ID"
|
||||
}
|
||||
149
apps/documentation/pages/users/organisations/sso/index.mdx
Normal file
149
apps/documentation/pages/users/organisations/sso/index.mdx
Normal file
@ -0,0 +1,149 @@
|
||||
---
|
||||
title: SSO Portal
|
||||
description: Learn how to set up a custom SSO login portal for your organisation.
|
||||
---
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Organisation SSO Portal
|
||||
|
||||
The SSO Portal provides a dedicated login URL for your organisation that integrates with any OIDC compliant identity provider. This feature provides:
|
||||
|
||||
- **Single Sign-On**: Access Documenso using your own authentication system
|
||||
- **Automatic onboarding**: New users will be automatically added to your organisation when they sign in through the portal
|
||||
- **Delegated account management**: Your organisation has full control over the users who sign in through the portal
|
||||
|
||||
<Callout type="warning">
|
||||
Anyone who signs in through your portal will be added to your organisation as a member.
|
||||
</Callout>
|
||||
|
||||
## Getting Started
|
||||
|
||||
To set up the SSO Portal, you need to be an organisation owner, admin, or manager.
|
||||
|
||||
<Callout type="info">
|
||||
**Enterprise Only**: This feature is only available to Enterprise customers.
|
||||
</Callout>
|
||||
|
||||
<Steps>
|
||||
|
||||
### Access Organisation SSO Settings
|
||||
|
||||

|
||||
|
||||
### Configure SSO Portal
|
||||
|
||||
See the [Microsoft Entra ID](/users/organisations/sso/microsoft-entra-id) guide to find the values for the following fields.
|
||||
|
||||
#### Issuer URL
|
||||
|
||||
Enter the OpenID discovery endpoint URL for your provider. Here are some common examples:
|
||||
|
||||
- **Google Workspace**: `https://accounts.google.com/.well-known/openid-configuration`
|
||||
- **Microsoft Entra ID**: `https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration`
|
||||
- **Okta**: `https://{your-domain}.okta.com/.well-known/openid-configuration`
|
||||
- **Auth0**: `https://{your-domain}.auth0.com/.well-known/openid-configuration`
|
||||
|
||||
#### Client Credentials
|
||||
|
||||
Enter the client ID and client secret provided by your identity provider:
|
||||
|
||||
- **Client ID**: The unique identifier for your application
|
||||
- **Client Secret**: The secret key for authenticating your application
|
||||
|
||||
#### Default Organisation Role
|
||||
|
||||
Select the default Organisation role that new users will receive when they first sign in through the portal.
|
||||
|
||||
#### Allowed Email Domains
|
||||
|
||||
Specify which email domains are allowed to sign in through your SSO portal. Separate domains with spaces:
|
||||
|
||||
```
|
||||
your-domain.com another-domain.com
|
||||
```
|
||||
|
||||
Leave this field empty to allow all domains.
|
||||
|
||||
### Configure Your Identity Provider
|
||||
|
||||
You'll need to configure your identity provider with the following information:
|
||||
|
||||
- Redirect URI
|
||||
- Scopes
|
||||
|
||||
These values are found at the top of the page.
|
||||
|
||||
### Save Configuration
|
||||
|
||||
Toggle the "Enable SSO portal" switch to activate the feature for your organisation.
|
||||
|
||||
Click "Update" to save your SSO portal configuration. The portal will be activated once all required fields are completed.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Testing Your SSO Portal
|
||||
|
||||
Once configured, you can test your SSO portal by:
|
||||
|
||||
1. Navigating to your portal URL found at the top of the organisation SSO portal settings page
|
||||
2. Sign in with a test account from your configured domain
|
||||
3. Verifying that the user is properly provisioned with the correct organisation role
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Reduce Friction
|
||||
|
||||
Create a custom subdomain for your organisation's SSO portal. For example, you can create a subdomain like `documenso.your-organisation.com` which redirects to the portal link.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
Please note that anyone who signs in through your portal will be added to your organisation as a member.
|
||||
|
||||
- **Domain Restrictions**: Use allowed domains to prevent unauthorized access
|
||||
- **Role Assignment**: Carefully consider the default organisation role for new users
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**"Invalid issuer URL"**
|
||||
|
||||
- Verify the issuer URL is correct and accessible
|
||||
- Ensure the URL follows the OpenID Connect discovery format
|
||||
|
||||
**"Client authentication failed"**
|
||||
|
||||
- Check that your client ID and client secret are correct
|
||||
- Verify that your application is properly registered with your identity provider
|
||||
|
||||
**"User not provisioned"**
|
||||
|
||||
- Check that the user's email domain is in the allowed domains list
|
||||
- Verify the default organisation role is set correctly
|
||||
|
||||
**"Redirect URI mismatch"**
|
||||
|
||||
- Ensure the redirect URI in Documenso matches exactly what's configured in your identity provider
|
||||
- Check for any trailing slashes or protocol mismatches
|
||||
|
||||
### Getting Help
|
||||
|
||||
If you encounter issues with your SSO portal configuration:
|
||||
|
||||
1. Review your identity provider's documentation for OpenID Connect setup
|
||||
2. Check the Documenso logs for detailed error messages
|
||||
3. Contact your identity provider's support for provider-specific issues
|
||||
|
||||
<Callout type="info">
|
||||
For additional support for SSO Portal configuration, contact our support team at
|
||||
support@documenso.com.
|
||||
</Callout>
|
||||
|
||||
## Identity Provider Guides
|
||||
|
||||
For detailed setup instructions for specific identity providers:
|
||||
|
||||
- [Microsoft Entra ID](/users/organisations/sso/microsoft-entra-id) - Complete guide for Azure AD configuration
|
||||
@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Microsoft Entra ID
|
||||
description: Learn how to configure Microsoft Entra ID (Azure AD) for your organisation's SSO portal.
|
||||
---
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Microsoft Entra ID Configuration
|
||||
|
||||
Microsoft Entra ID (formerly Azure Active Directory) is a popular identity provider for enterprise SSO. This guide will walk you through creating an app registration and configuring it for use with your Documenso SSO portal.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Access to Microsoft Entra ID (Azure AD) admin center
|
||||
- Access to your Documenso organisation as an administrator or manager
|
||||
|
||||
<Callout type="warning">Each user in your Azure AD will need an email associated with it.</Callout>
|
||||
|
||||
## Creating an App Registration
|
||||
|
||||
<Steps>
|
||||
|
||||
### Access Azure Portal
|
||||
|
||||
1. Navigate to the Azure Portal
|
||||
2. Sign in with your Microsoft Entra ID administrator account
|
||||
3. Search for "Azure Active Directory" or "Microsoft Entra ID" in the search bar
|
||||
4. Click on "Microsoft Entra ID" from the results
|
||||
|
||||
### Create App Registration
|
||||
|
||||
1. In the left sidebar, click on "App registrations"
|
||||
2. Click the "New registration" button
|
||||
|
||||
### Configure App Registration
|
||||
|
||||
Fill in the registration form with the following details:
|
||||
|
||||
- **Name**: Your preferred name (e.g. `Documenso SSO Portal`)
|
||||
- **Supported account types**: Choose based on your needs
|
||||
- **Redirect URI (Web)**: Found in the Documenso SSO portal settings page
|
||||
|
||||
Click "Register" to create the app registration.
|
||||
|
||||
### Get Client ID
|
||||
|
||||
After registration, you'll be taken to the app's overview page. The **Application (client) ID** is displayed prominently - this is your Client ID for Documenso.
|
||||
|
||||
### Create Client Secret
|
||||
|
||||
1. In the left sidebar, click on "Certificates & secrets"
|
||||
2. Click "New client secret"
|
||||
3. Add a description (e.g., "Documenso SSO Secret")
|
||||
4. Choose an expiration period (recommended 12-24 months)
|
||||
5. Click "Add"
|
||||
|
||||
Make sure you copy the "Secret value", not the "Secret ID", you won't be able to access it again after you leave the page.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Getting Your OpenID Configuration URL
|
||||
|
||||
1. In the Azure portal, go to "Microsoft Entra ID"
|
||||
2. Click on "Overview" in the left sidebar
|
||||
3. Click the "Endpoints" in the horizontal tab
|
||||
4. Copy the "OpenID Connect metadata document" value
|
||||
|
||||
## Configure Documenso SSO Portal
|
||||
|
||||
Now you have all the information needed to configure your Documenso SSO portal:
|
||||
|
||||
- **Issuer URL**: The "OpenID Connect metadata document" value from the previous step
|
||||
- **Client ID**: The Application (client) ID from your app registration
|
||||
- **Client Secret**: The secret value you copied during creation
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
@ -1,4 +1,4 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
@ -7,18 +7,19 @@ import { addZeroMonth } from '../add-zero-month';
|
||||
|
||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('Document')
|
||||
.selectFrom('Envelope')
|
||||
.select(({ fn }) => [
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'),
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'),
|
||||
fn.count('id').as('count'),
|
||||
fn
|
||||
.sum(fn.count('id'))
|
||||
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any))
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any))
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
|
||||
.groupBy('month')
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3003",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "next start -p 3003",
|
||||
"lint:fix": "next lint --fix",
|
||||
"clean": "rimraf .next && rimraf node_modules"
|
||||
},
|
||||
|
||||
@ -27,9 +27,45 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/noto-sans.ttf') format('truetype-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Korean noto sans */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Korean';
|
||||
src: url('/fonts/noto-sans-korean.ttf') format('truetype-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Japanese noto sans */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Japanese';
|
||||
src: url('/fonts/noto-sans-japanese.ttf') format('truetype-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Chinese noto sans */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Chinese';
|
||||
src: url('/fonts/noto-sans-chinese.ttf') format('truetype-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--font-sans: 'Inter';
|
||||
--font-signature: 'Caveat';
|
||||
--font-noto: 'Noto Sans', 'Noto Sans Korean', 'Noto Sans Japanese', 'Noto Sans Chinese';
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Document } from '@prisma/client';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -22,10 +21,10 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminDocumentDeleteDialogProps = {
|
||||
document: Document;
|
||||
envelopeId: string;
|
||||
};
|
||||
|
||||
export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialogProps) => {
|
||||
export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -34,7 +33,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } =
|
||||
trpc.admin.deleteDocument.useMutation();
|
||||
trpc.admin.document.delete.useMutation();
|
||||
|
||||
const handleDeleteDocument = async () => {
|
||||
try {
|
||||
@ -42,7 +41,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteDocument({ id: document.id, reason });
|
||||
await deleteDocument({ id: envelopeId, reason });
|
||||
|
||||
toast({
|
||||
title: _(msg`Document deleted`),
|
||||
|
||||
@ -0,0 +1,218 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminOrganisationMemberUpdateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
organisationId: string;
|
||||
organisationMember: TGetAdminOrganisationResponse['members'][number];
|
||||
isOwner: boolean;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZUpdateOrganisationMemberFormSchema = z.object({
|
||||
role: z.enum(['OWNER', 'ADMIN', 'MANAGER', 'MEMBER']),
|
||||
});
|
||||
|
||||
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
|
||||
|
||||
export const AdminOrganisationMemberUpdateDialog = ({
|
||||
trigger,
|
||||
organisationId,
|
||||
organisationMember,
|
||||
isOwner,
|
||||
...props
|
||||
}: AdminOrganisationMemberUpdateDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Determine the current role value for the form
|
||||
const currentRoleValue = isOwner
|
||||
? 'OWNER'
|
||||
: getHighestOrganisationRoleInGroup(
|
||||
organisationMember.organisationGroupMembers.map((ogm) => ogm.group),
|
||||
);
|
||||
const organisationMemberName = organisationMember.user.name ?? organisationMember.user.email;
|
||||
|
||||
const form = useForm<ZUpdateOrganisationMemberSchema>({
|
||||
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
|
||||
defaultValues: {
|
||||
role: currentRoleValue,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisationMemberRole } =
|
||||
trpc.admin.organisationMember.updateRole.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
|
||||
try {
|
||||
await updateOrganisationMemberRole({
|
||||
organisationId,
|
||||
userId: organisationMember.userId,
|
||||
role,
|
||||
});
|
||||
|
||||
const roleLabel = match(role)
|
||||
.with('OWNER', () => t`Owner`)
|
||||
.with(OrganisationMemberRole.ADMIN, () => t`Admin`)
|
||||
.with(OrganisationMemberRole.MANAGER, () => t`Manager`)
|
||||
.with(OrganisationMemberRole.MEMBER, () => t`Member`)
|
||||
.exhaustive();
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description:
|
||||
role === 'OWNER'
|
||||
? t`Ownership transferred to ${organisationMemberName}.`
|
||||
: t`Updated ${organisationMemberName} to ${roleLabel}.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
// Refresh the page to show updated data
|
||||
await navigate(0);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to update this organisation member. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset({
|
||||
role: currentRoleValue,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentRoleValue, form]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
<Trans>Update role</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Update organisation member</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are currently updating{' '}
|
||||
<span className="font-bold">{organisationMemberName}.</span>
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel required>
|
||||
<Trans>Role</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent className="w-full" position="popper">
|
||||
<SelectItem value="OWNER">
|
||||
<Trans>Owner</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={OrganisationMemberRole.ADMIN}>
|
||||
<Trans>Admin</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={OrganisationMemberRole.MANAGER}>
|
||||
<Trans>Manager</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={OrganisationMemberRole.MEMBER}>
|
||||
<Trans>Member</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -3,12 +3,12 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { User } from '@prisma/client';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminUserDeleteDialogProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
user: TGetUserResponse;
|
||||
};
|
||||
|
||||
export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => {
|
||||
@ -35,7 +35,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
|
||||
trpc.admin.deleteUser.useMutation();
|
||||
trpc.admin.user.delete.useMutation();
|
||||
|
||||
const onDeleteAccount = async () => {
|
||||
try {
|
||||
|
||||
@ -3,11 +3,11 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { User } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminUserDisableDialogProps = {
|
||||
className?: string;
|
||||
userToDisable: User;
|
||||
userToDisable: TGetUserResponse;
|
||||
};
|
||||
|
||||
export const AdminUserDisableDialog = ({
|
||||
@ -37,7 +37,7 @@ export const AdminUserDisableDialog = ({
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const { mutateAsync: disableUser, isPending: isDisablingUser } =
|
||||
trpc.admin.disableUser.useMutation();
|
||||
trpc.admin.user.disable.useMutation();
|
||||
|
||||
const onDisableAccount = async () => {
|
||||
try {
|
||||
|
||||
@ -3,11 +3,11 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { User } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminUserEnableDialogProps = {
|
||||
className?: string;
|
||||
userToEnable: User;
|
||||
userToEnable: TGetUserResponse;
|
||||
};
|
||||
|
||||
export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => {
|
||||
@ -34,7 +34,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const { mutateAsync: enableUser, isPending: isEnablingUser } =
|
||||
trpc.admin.enableUser.useMutation();
|
||||
trpc.admin.user.enable.useMutation();
|
||||
|
||||
const onEnableAccount = async () => {
|
||||
try {
|
||||
|
||||
@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminUserResetTwoFactorDialogProps = {
|
||||
className?: string;
|
||||
user: TGetUserResponse;
|
||||
};
|
||||
|
||||
export const AdminUserResetTwoFactorDialog = ({
|
||||
className,
|
||||
user,
|
||||
}: AdminUserResetTwoFactorDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const [email, setEmail] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } =
|
||||
trpc.admin.user.resetTwoFactor.useMutation();
|
||||
|
||||
const onResetTwoFactor = async () => {
|
||||
try {
|
||||
await resetTwoFactor({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`2FA Reset`),
|
||||
description: _(msg`The user's two factor authentication has been reset successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
|
||||
.with(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
() => msg`You are not authorized to reset two factor authentcation for this user.`,
|
||||
)
|
||||
.otherwise(
|
||||
() => msg`An error occurred while resetting two factor authentication for the user.`,
|
||||
);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
|
||||
if (!newOpen) {
|
||||
setEmail('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>Reset Two Factor Authentication</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Reset the users two factor authentication. This action is irreversible and will
|
||||
disable two factor authentication for the user.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trans>Reset 2FA</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>
|
||||
<Trans>Reset Two Factor Authentication</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>
|
||||
This action is irreversible. Please ensure you have informed the user before
|
||||
proceeding.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
To confirm, please enter the accounts email address <br />({user.email}).
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={email !== user.email}
|
||||
onClick={onResetTwoFactor}
|
||||
loading={isResettingTwoFactor}
|
||||
>
|
||||
<Trans>Reset 2FA</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -49,7 +49,7 @@ export const DocumentDeleteDialog = ({
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||
|
||||
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({
|
||||
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
void refreshLimits();
|
||||
|
||||
|
||||
@ -19,13 +19,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type DocumentDuplicateDialogProps = {
|
||||
id: number;
|
||||
id: string;
|
||||
token?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DocumentDuplicateDialog = ({
|
||||
id,
|
||||
token,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DocumentDuplicateDialogProps) => {
|
||||
@ -36,41 +38,38 @@ export const DocumentDuplicateDialog = ({
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
|
||||
{
|
||||
documentId: id,
|
||||
},
|
||||
{
|
||||
enabled: open === true,
|
||||
},
|
||||
);
|
||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||
trpcReact.envelope.item.getManyByToken.useQuery(
|
||||
{
|
||||
envelopeId: id,
|
||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const documentData = document?.documentData
|
||||
? {
|
||||
...document.documentData,
|
||||
data: document.documentData.initialData,
|
||||
}
|
||||
: undefined;
|
||||
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
||||
trpcReact.document.duplicateDocument.useMutation({
|
||||
onSuccess: async ({ documentId }) => {
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
trpcReact.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: _(msg`Document Duplicated`),
|
||||
description: _(msg`Your document has been successfully duplicated.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(`${documentsPath}/${documentId}/edit`);
|
||||
await navigate(`${documentsPath}/${id}/edit`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateDocument({ documentId: id });
|
||||
await duplicateEnvelope({ envelopeId: id });
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
@ -82,14 +81,14 @@ export const DocumentDuplicateDialog = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!documentData || isLoading ? (
|
||||
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Loading Document...</Trans>
|
||||
@ -97,7 +96,12 @@ export const DocumentDuplicateDialog = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||
<PDFViewer key={document?.id} documentData={documentData} />
|
||||
<PDFViewer
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="original"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -114,8 +118,8 @@ export const DocumentDuplicateDialog = ({
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isDuplicateLoading || isLoading}
|
||||
loading={isDuplicateLoading}
|
||||
disabled={isDuplicating}
|
||||
loading={isDuplicating}
|
||||
onClick={onDuplicate}
|
||||
className="flex-1"
|
||||
>
|
||||
|
||||
@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
@ -81,7 +81,7 @@ export const DocumentMoveToFolderDialog = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
|
||||
const { mutateAsync: updateDocument } = trpc.document.update.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@ -94,9 +94,11 @@ export const DocumentMoveToFolderDialog = ({
|
||||
|
||||
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
||||
try {
|
||||
await moveDocumentToFolder({
|
||||
await updateDocument({
|
||||
documentId,
|
||||
folderId: data.folderId ?? null,
|
||||
data: {
|
||||
folderId: data.folderId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
@ -4,15 +4,15 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -43,7 +43,11 @@ import { StackAvatar } from '../general/stack-avatar';
|
||||
const FORM_ID = 'resend-email';
|
||||
|
||||
export type DocumentResendDialogProps = {
|
||||
document: TDocumentRow;
|
||||
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
@ -71,7 +75,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
document.status !== 'PENDING' ||
|
||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||
|
||||
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
|
||||
const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation();
|
||||
|
||||
const form = useForm<TResendDocumentFormSchema>({
|
||||
resolver: zodResolver(ZResendDocumentFormSchema),
|
||||
@ -85,6 +89,11 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const selectedRecipients = useWatch({
|
||||
control: form.control,
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||
try {
|
||||
await resendDocument({ documentId: document.id, recipients });
|
||||
@ -151,7 +160,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full"
|
||||
className="h-5 w-5 rounded-full border border-neutral-400"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
@ -182,7 +191,13 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||
<Button
|
||||
className="flex-1"
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
disabled={isSubmitting || selectedRecipients.length === 0}
|
||||
>
|
||||
<Trans>Send reminder</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
442
apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
Normal file
442
apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
Normal file
@ -0,0 +1,442 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
DocumentDistributionMethod,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
type Field,
|
||||
FieldType,
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
} from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopeDistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
fields: Pick<Field, 'type' | 'recipientId'>[];
|
||||
};
|
||||
onDistribute?: () => Promise<void>;
|
||||
documentRootPath: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ZEnvelopeDistributeFormSchema = z.object({
|
||||
meta: z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
z.string().email().optional(),
|
||||
),
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
distributionMethod: z
|
||||
.nativeEnum(DocumentDistributionMethod)
|
||||
.optional()
|
||||
.default(DocumentDistributionMethod.EMAIL),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
||||
|
||||
export const EnvelopeDistributeDialog = ({
|
||||
envelope,
|
||||
trigger,
|
||||
documentRootPath,
|
||||
onDistribute,
|
||||
}: EnvelopeDistributeDialogProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const recipients = envelope.recipients;
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation();
|
||||
|
||||
const form = useForm<TEnvelopeDistributeFormSchema>({
|
||||
defaultValues: {
|
||||
meta: {
|
||||
emailId: envelope.documentMeta?.emailId ?? null,
|
||||
emailReplyTo: envelope.documentMeta?.emailReplyTo || undefined,
|
||||
subject: envelope.documentMeta?.subject ?? '',
|
||||
message: envelope.documentMeta?.message ?? '',
|
||||
distributionMethod:
|
||||
envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
|
||||
},
|
||||
},
|
||||
resolver: zodResolver(ZEnvelopeDistributeFormSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const { data: emailData, isLoading: isLoadingEmails } =
|
||||
trpc.enterprise.organisation.email.find.useQuery({
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
const emails = emailData?.data || [];
|
||||
|
||||
const distributionMethod = watch('meta.distributionMethod');
|
||||
|
||||
const recipientsMissingSignatureFields = useMemo(
|
||||
() =>
|
||||
envelope.recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role === RecipientRole.SIGNER &&
|
||||
!envelope.fields.some(
|
||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||
),
|
||||
),
|
||||
[envelope.recipients, envelope.fields],
|
||||
);
|
||||
|
||||
const invalidEnvelopeCode = useMemo(() => {
|
||||
if (recipientsMissingSignatureFields.length > 0) {
|
||||
return 'MISSING_SIGNATURES';
|
||||
}
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
return 'MISSING_RECIPIENTS';
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
|
||||
|
||||
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
||||
try {
|
||||
await distributeEnvelope({ envelopeId: envelope.id, meta });
|
||||
|
||||
await onDistribute?.();
|
||||
|
||||
let redirectPath = `${documentRootPath}/${envelope.id}`;
|
||||
|
||||
if (meta.distributionMethod === DocumentDistributionMethod.NONE) {
|
||||
redirectPath += '?action=copy-links';
|
||||
}
|
||||
|
||||
await navigate(redirectPath);
|
||||
|
||||
toast({
|
||||
title: t`Envelope distributed`,
|
||||
description: t`Your envelope has been distributed successfully.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This envelope could not be distributed at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-md" hideClose>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Send Document</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Recipients will be able to sign the document once sent</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!invalidEnvelopeCode ? (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
<Tabs
|
||||
onValueChange={(value) =>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
setValue('meta.distributionMethod', value as DocumentDistributionMethod)
|
||||
}
|
||||
value={distributionMethod}
|
||||
className="mb-2"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
|
||||
Email
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
|
||||
None
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div
|
||||
className={cn('min-h-72', {
|
||||
'min-h-[23rem]': organisation.organisationClaim.flags.emailDomains,
|
||||
})}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
<motion.div
|
||||
key={'Emails'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
>
|
||||
<Form {...form}>
|
||||
<fieldset
|
||||
className="mt-2 flex flex-col gap-y-4 rounded-lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{organisation.organisationClaim.flags.emailDomains && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.emailId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Sender</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === '-1' ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
loading={isLoadingEmails}
|
||||
className="bg-background"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{emails.map((email) => (
|
||||
<SelectItem key={email.id} value={email.id}>
|
||||
{email.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<SelectItem value={'-1'}>Documenso</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.emailReplyTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply To Email</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} maxLength={254} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Subject</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} maxLength={255} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Message</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground p-4">
|
||||
<DocumentSendEmailMessageHelper />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="bg-background mt-2 h-16 resize-none"
|
||||
{...field}
|
||||
maxLength={5000}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</Form>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{distributionMethod === DocumentDistributionMethod.NONE && (
|
||||
<motion.div
|
||||
key={'Links'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
className="min-h-60 rounded-lg border"
|
||||
>
|
||||
<div className="text-muted-foreground py-24 text-center text-sm">
|
||||
<p>
|
||||
<Trans>We won't send anything to notify recipients.</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-2">
|
||||
<Trans>
|
||||
We will generate signing links for you, which you can send to the
|
||||
recipients through your method of choice.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isSubmitting}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<Trans>Send</Trans>
|
||||
) : (
|
||||
<Trans>Generate Links</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<>
|
||||
<Alert variant="warning">
|
||||
{match(invalidEnvelopeCode)
|
||||
.with('MISSING_RECIPIENTS', () => (
|
||||
<AlertDescription>
|
||||
<Trans>You need at least one recipient to send a document</Trans>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with('MISSING_SIGNATURES', () => (
|
||||
<AlertDescription>
|
||||
<Trans>The following signers are missing signature fields:</Trans>
|
||||
|
||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||
{recipientsMissingSignatureFields.map((recipient) => (
|
||||
<li key={recipient.id}>{recipient.email}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
204
apps/remix/app/components/dialogs/envelope-download-dialog.tsx
Normal file
204
apps/remix/app/components/dialogs/envelope-download-dialog.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
|
||||
|
||||
type EnvelopeDownloadDialogProps = {
|
||||
envelopeId: string;
|
||||
envelopeStatus: DocumentStatus;
|
||||
envelopeItems?: EnvelopeItemToDownload[];
|
||||
|
||||
/**
|
||||
* The recipient token to download the document.
|
||||
*
|
||||
* If not provided, it will be assumed that the current user can access the document.
|
||||
*/
|
||||
token?: string;
|
||||
trigger: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeDownloadDialog = ({
|
||||
envelopeId,
|
||||
envelopeStatus,
|
||||
envelopeItems: initialEnvelopeItems,
|
||||
token,
|
||||
trigger,
|
||||
}: EnvelopeDownloadDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [isDownloadingState, setIsDownloadingState] = useState<{
|
||||
[envelopeItemIdAndVersion: string]: boolean;
|
||||
}>({});
|
||||
|
||||
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
|
||||
`${envelopeItemId}-${version}`;
|
||||
|
||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||
trpc.envelope.item.getManyByToken.useQuery(
|
||||
{
|
||||
envelopeId,
|
||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||
},
|
||||
{
|
||||
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||
|
||||
const onDownload = async (
|
||||
envelopeItem: EnvelopeItemToDownload,
|
||||
version: 'original' | 'signed',
|
||||
) => {
|
||||
const { id: envelopeItemId } = envelopeItem;
|
||||
|
||||
if (isDownloadingState[generateDownloadKey(envelopeItemId, version)]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDownloadingState((prev) => ({
|
||||
...prev,
|
||||
[generateDownloadKey(envelopeItemId, version)]: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
await downloadPDF({
|
||||
envelopeItem,
|
||||
token,
|
||||
fileName: envelopeItem.title,
|
||||
version,
|
||||
});
|
||||
|
||||
setIsDownloadingState((prev) => ({
|
||||
...prev,
|
||||
[generateDownloadKey(envelopeItemId, version)]: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
setIsDownloadingState((prev) => ({
|
||||
...prev,
|
||||
[generateDownloadKey(envelopeItemId, version)]: false,
|
||||
}));
|
||||
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be downloaded at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Download Files</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Select the files you would like to download.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<>
|
||||
{Array.from({ length: 1 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
|
||||
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Skeleton className="h-4 w-28 rounded-lg" />
|
||||
<Skeleton className="h-4 w-20 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
envelopeItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<FileTextIcon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Todo: Envelopes - Fix overflow */}
|
||||
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
<Trans>PDF Document</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={async () => onDownload(item, 'original')}
|
||||
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
|
||||
>
|
||||
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans>Original</Trans>
|
||||
</Button>
|
||||
|
||||
{envelopeStatus === DocumentStatus.COMPLETED && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={async () => onDownload(item, 'signed')}
|
||||
loading={isDownloadingState[generateDownloadKey(item.id, 'signed')]}
|
||||
>
|
||||
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans>Signed</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
113
apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx
Normal file
113
apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type EnvelopeDuplicateDialogProps = {
|
||||
envelopeId: string;
|
||||
envelopeType: EnvelopeType;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeDuplicateDialog = ({
|
||||
envelopeId,
|
||||
envelopeType,
|
||||
trigger,
|
||||
}: EnvelopeDuplicateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
trpc.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: t`Envelope Duplicated`,
|
||||
description: t`Your envelope has been successfully duplicated.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
const path =
|
||||
envelopeType === EnvelopeType.DOCUMENT
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${path}/${id}/edit`);
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateEnvelope({ envelopeId });
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be duplicated at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDuplicating && setOpen(value)}>
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
|
||||
<DialogContent>
|
||||
{envelopeType === EnvelopeType.DOCUMENT ? (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate Document</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>This document will be duplicated.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
) : (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate Template</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>This template will be duplicated.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" disabled={isDuplicating}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="button" loading={isDuplicating} onClick={onDuplicate}>
|
||||
<Trans>Duplicate</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopeItemDeleteDialogProps = {
|
||||
canItemBeDeleted: boolean;
|
||||
envelopeId: string;
|
||||
envelopeItemId: string;
|
||||
envelopeItemTitle: string;
|
||||
onDelete?: (envelopeItemId: string) => void;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeItemDeleteDialog = ({
|
||||
trigger,
|
||||
canItemBeDeleted,
|
||||
envelopeId,
|
||||
envelopeItemId,
|
||||
envelopeItemTitle,
|
||||
onDelete,
|
||||
}: EnvelopeItemDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteEnvelopeItem, isPending: isDeleting } =
|
||||
trpc.envelope.item.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`You have successfully removed this envelope item.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onDelete?.(envelopeItemId);
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to remove this envelope item. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
{canItemBeDeleted ? (
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are about to remove the following document and all associated fields
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription className="text-center font-semibold">
|
||||
{envelopeItemTitle}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<fieldset disabled={isDeleting}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={async () =>
|
||||
deleteEnvelopeItem({
|
||||
envelopeId,
|
||||
envelopeItemId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
) : (
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>This item cannot be deleted</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You cannot delete this item because the document has been sent to recipients
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,187 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType, type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { StackAvatar } from '../general/stack-avatar';
|
||||
|
||||
export type EnvelopeRedistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
};
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ZEnvelopeRedistributeFormSchema = z.object({
|
||||
recipients: z.array(z.number()).min(1, {
|
||||
message: msg`You must select at least one item`.id,
|
||||
}),
|
||||
});
|
||||
|
||||
export type TEnvelopeRedistributeFormSchema = z.infer<typeof ZEnvelopeRedistributeFormSchema>;
|
||||
|
||||
export const EnvelopeRedistributeDialog = ({
|
||||
envelope,
|
||||
trigger,
|
||||
}: EnvelopeRedistributeDialogProps) => {
|
||||
const recipients = envelope.recipients;
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: redistributeEnvelope } = trpcReact.envelope.redistribute.useMutation();
|
||||
|
||||
const form = useForm<TEnvelopeRedistributeFormSchema>({
|
||||
defaultValues: {
|
||||
recipients: [],
|
||||
},
|
||||
resolver: zodResolver(ZEnvelopeRedistributeFormSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TEnvelopeRedistributeFormSchema) => {
|
||||
try {
|
||||
await redistributeEnvelope({ envelopeId: envelope.id, recipients });
|
||||
|
||||
toast({
|
||||
title: t`Envelope resent`,
|
||||
description: t`Your envelope has been resent successfully.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This envelope could not be resent at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING || envelope.type !== EnvelopeType.DOCUMENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-md" hideClose>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Resend Document</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Send reminders to the following recipients</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipients"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
{recipients
|
||||
.filter((recipient) => recipient.signingStatus === SigningStatus.NOT_SIGNED)
|
||||
.map((recipient) => (
|
||||
<FormItem
|
||||
key={recipient.id}
|
||||
className="flex flex-row items-center justify-between gap-x-3 px-3"
|
||||
>
|
||||
<FormLabel
|
||||
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||
'opacity-50': !value.includes(recipient.id),
|
||||
})}
|
||||
>
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
{recipient.email}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
checked
|
||||
? onChange([...value, recipient.id])
|
||||
: onChange(value.filter((v) => v !== recipient.id))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isSubmitting}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
<Trans>Send reminder</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
const onFormSubmit = async () => {
|
||||
try {
|
||||
await deleteFolder({
|
||||
id: folder.id,
|
||||
folderId: folder.id,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
@ -53,7 +53,7 @@ export const FolderMoveDialog = ({
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||
const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const form = useForm<TMoveFolderFormSchema>({
|
||||
resolver: zodResolver(ZMoveFolderFormSchema),
|
||||
@ -63,12 +63,16 @@ export const FolderMoveDialog = ({
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
||||
if (!folder) return;
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await moveFolder({
|
||||
id: folder.id,
|
||||
parentId: targetFolderId || null,
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
parentId: targetFolderId || null,
|
||||
},
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
@ -61,8 +61,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const isTeamContext = !!team;
|
||||
|
||||
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
||||
resolver: zodResolver(ZUpdateFolderFormSchema),
|
||||
defaultValues: {
|
||||
@ -87,11 +85,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
|
||||
try {
|
||||
await updateFolder({
|
||||
id: folder.id,
|
||||
name: data.name,
|
||||
visibility: isTeamContext
|
||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
||||
: DocumentVisibility.EVERYONE,
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
name: data.name,
|
||||
visibility: data.visibility,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -140,38 +138,36 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
)}
|
||||
/>
|
||||
|
||||
{isTeamContext && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
|
||||
@ -185,6 +185,10 @@ export const OrganisationMemberInviteDialog = ({
|
||||
return 'form';
|
||||
}
|
||||
|
||||
if (fullOrganisation.members.length < fullOrganisation.organisationClaim.memberCount) {
|
||||
return 'form';
|
||||
}
|
||||
|
||||
// This is probably going to screw us over in the future.
|
||||
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
||||
return 'alert';
|
||||
|
||||
@ -65,9 +65,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
||||
});
|
||||
|
||||
const { mutateAsync: createPasskeyRegistrationOptions, isPending } =
|
||||
trpc.auth.createPasskeyRegistrationOptions.useMutation();
|
||||
trpc.auth.passkey.createRegistrationOptions.useMutation();
|
||||
|
||||
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
|
||||
const { mutateAsync: createPasskey } = trpc.auth.passkey.create.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
|
||||
setFormError(null);
|
||||
|
||||
@ -4,14 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import type { Template, TemplateDirectLink } from '@prisma/client';
|
||||
import { TemplateType } from '@prisma/client';
|
||||
import { type TemplateDirectLink, TemplateType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type Template } from '@documenso/prisma/types/template-legacy-schema';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
|
||||
@ -52,7 +52,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type ManagePublicTemplateDialogProps = {
|
||||
directTemplates: (Template & {
|
||||
directTemplates: (Omit<Template, 'templateDocumentDataId'> & {
|
||||
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
||||
})[];
|
||||
initialTemplateId?: number | null;
|
||||
|
||||
186
apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
Normal file
186
apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
import { type TCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||
|
||||
export type SignFieldCheckboxDialogProps = {
|
||||
fieldMeta: TCheckboxFieldMeta;
|
||||
validationRule: '>=' | '=' | '<=';
|
||||
validationLength: number;
|
||||
preselectedIndices: number[];
|
||||
};
|
||||
|
||||
export const SignFieldCheckboxDialog = createCallable<
|
||||
SignFieldCheckboxDialogProps,
|
||||
number[] | null
|
||||
>(({ call, fieldMeta, validationRule, validationLength, preselectedIndices }) => {
|
||||
const ZSignFieldCheckboxFormSchema = z
|
||||
.object({
|
||||
values: z.array(
|
||||
z.object({
|
||||
checked: z.boolean(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Allow unselecting all options if the field is not required even if
|
||||
// validation is not met.
|
||||
if (!fieldMeta.required && data.values.every((value) => !value.checked)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numberOfSelectedValues = data.values.filter((value) => value.checked).length;
|
||||
|
||||
const isValid = validateCheckboxLength(
|
||||
numberOfSelectedValues,
|
||||
validationRule,
|
||||
validationLength,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: msg`Validation failed`.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof ZSignFieldCheckboxFormSchema>>({
|
||||
resolver: zodResolver(ZSignFieldCheckboxFormSchema),
|
||||
defaultValues: {
|
||||
values: (fieldMeta.values || []).map((value, index) => ({
|
||||
checked: preselectedIndices.includes(index) || false,
|
||||
value: value.value,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const formValues = useWatch({
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Checkbox Field</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription
|
||||
className={cn('mt-4', {
|
||||
'text-destructive': Object.keys(form.formState.errors).length > 0,
|
||||
})}
|
||||
>
|
||||
{match(validationRule)
|
||||
.with('>=', () => (
|
||||
<Plural
|
||||
value={validationLength}
|
||||
one="Select at least # option"
|
||||
other="Select at least # options"
|
||||
/>
|
||||
))
|
||||
.with('=', () => (
|
||||
<Plural
|
||||
value={validationLength}
|
||||
one="Select exactly # option"
|
||||
other="Select exactly # options"
|
||||
/>
|
||||
))
|
||||
.with('<=', () => (
|
||||
<Plural
|
||||
value={validationLength}
|
||||
one="Select at most # option"
|
||||
other="Select at most # options"
|
||||
/>
|
||||
))
|
||||
.exhaustive()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
call.end(
|
||||
data.values
|
||||
.map((value, i) => (value.checked ? i : null))
|
||||
.filter((value) => value !== null),
|
||||
),
|
||||
)}
|
||||
>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<ul className="space-y-3">
|
||||
{(formValues.values || []).map((value, index) => (
|
||||
<li key={`checkbox-${index}`}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`values.${index}`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id={`checkbox-value-${index}`}
|
||||
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
|
||||
checked={field.value.checked}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange({
|
||||
...field.value,
|
||||
checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 w-full text-sm"
|
||||
htmlFor={`checkbox-value-${index}`}
|
||||
>
|
||||
{value.value}
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,45 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
|
||||
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
|
||||
export type SignFieldDropdownDialogProps = {
|
||||
fieldMeta: TDropdownFieldMeta;
|
||||
};
|
||||
|
||||
export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogProps, string | null>(
|
||||
({ call, fieldMeta }) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const values = fieldMeta.values?.map((value) => value.value) ?? [];
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
position="start"
|
||||
dialogContentClassName="mt-4"
|
||||
open={true}
|
||||
onOpenChange={(value) => (!value ? call.end(null) : null)}
|
||||
>
|
||||
<CommandInput placeholder={t`Select an option`} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading={t`Options`}>
|
||||
{values.map((value, i) => (
|
||||
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
|
||||
{value}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,93 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const ZSignFieldEmailFormSchema = z.object({
|
||||
email: z.string().min(1, { message: msg`Email is required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
||||
|
||||
export type SignFieldEmailDialogProps = {
|
||||
placeholderEmail: string | null;
|
||||
};
|
||||
|
||||
export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, string | null>(
|
||||
({ call, placeholderEmail }) => {
|
||||
const form = useForm<TSignFieldEmailFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldEmailFormSchema),
|
||||
defaultValues: {
|
||||
email: placeholderEmail || '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Email</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Sign your email into the field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => call.end(data.email))}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,97 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const ZSignFieldInitialsFormSchema = z.object({
|
||||
initials: z.string().min(1, { message: msg`Initials are required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldInitialsFormSchema = z.infer<typeof ZSignFieldInitialsFormSchema>;
|
||||
|
||||
export type SignFieldInitialsDialogProps = {
|
||||
//
|
||||
};
|
||||
|
||||
export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogProps, string | null>(
|
||||
({ call }) => {
|
||||
const form = useForm<TSignFieldInitialsFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldInitialsFormSchema),
|
||||
defaultValues: {
|
||||
initials: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Initials</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Sign your initials into the field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => call.end(data.initials))}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="initials"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Initials</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
93
apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
Normal file
93
apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const ZSignFieldNameFormSchema = z.object({
|
||||
name: z.string().min(1, { message: msg`Name is required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldNameFormSchema = z.infer<typeof ZSignFieldNameFormSchema>;
|
||||
|
||||
export type SignFieldNameDialogProps = {
|
||||
//
|
||||
};
|
||||
|
||||
export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, string | null>(
|
||||
({ call }) => {
|
||||
const form = useForm<TSignFieldNameFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldNameFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Name</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Sign your full name into the field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => call.end(data.name))}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
142
apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
Normal file
142
apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TNumberFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
||||
let schema = z.coerce.number({
|
||||
invalid_type_error: msg`Please enter a valid number`.id,
|
||||
});
|
||||
|
||||
const { numberFormat, minValue, maxValue } = fieldMeta;
|
||||
|
||||
if (typeof minValue === 'number') {
|
||||
schema = schema.min(minValue);
|
||||
}
|
||||
|
||||
if (typeof maxValue === 'number') {
|
||||
schema = schema.max(maxValue);
|
||||
}
|
||||
|
||||
if (numberFormat) {
|
||||
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
|
||||
|
||||
if (!foundRegex) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
return schema.refine(
|
||||
(value) => {
|
||||
return foundRegex.test(value.toString());
|
||||
},
|
||||
{
|
||||
message: msg`Number needs to be formatted as ${numberFormat}`.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
export type SignFieldNumberDialogProps = {
|
||||
fieldMeta: TNumberFieldMeta;
|
||||
};
|
||||
|
||||
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, number | null>(
|
||||
({ call, fieldMeta }) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const ZSignFieldNumberFormSchema = z.object({
|
||||
number: createNumberFieldSchema(fieldMeta),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof ZSignFieldNumberFormSchema>>({
|
||||
resolver: zodResolver(ZSignFieldNumberFormSchema),
|
||||
defaultValues: {
|
||||
number: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Number Field</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Insert a value into the number field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => call.end(data.number))}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="number"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
{fieldMeta.label && <FormLabel>{fieldMeta.label}</FormLabel>}
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={fieldMeta.placeholder ?? t`Enter your number here`}
|
||||
className={cn('w-full rounded-md', {
|
||||
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
|
||||
fieldState.error,
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
|
||||
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
||||
|
||||
export type SignFieldSignatureDialogProps = {
|
||||
initialSignature?: string;
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const SignFieldSignatureDialog = createCallable<
|
||||
SignFieldSignatureDialogProps,
|
||||
string | null
|
||||
>(
|
||||
({
|
||||
call,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
initialSignature,
|
||||
}) => {
|
||||
const [localSignature, setLocalSignature] = useState(initialSignature);
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<div>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Signature Field</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<SignaturePad
|
||||
value={localSignature ?? ''}
|
||||
onChange={({ value }) => setLocalSignature(value)}
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||
drawSignatureEnabled={drawSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DocumentSigningDisclosure />
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!localSignature}
|
||||
onClick={() => call.end(localSignature || null)}
|
||||
>
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
120
apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
Normal file
120
apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Plural, useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TTextFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
const ZSignFieldTextFormSchema = z.object({
|
||||
text: z.string().min(1, { message: msg`Text is required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldTextFormSchema = z.infer<typeof ZSignFieldTextFormSchema>;
|
||||
|
||||
export type SignFieldTextDialogProps = {
|
||||
fieldMeta?: TTextFieldMeta;
|
||||
};
|
||||
|
||||
export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, string | null>(
|
||||
({ call, fieldMeta }) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TSignFieldTextFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldTextFormSchema),
|
||||
defaultValues: {
|
||||
text: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Text Field</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Insert a value into the text field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
{fieldMeta?.label && <FormLabel>{fieldMeta?.label}</FormLabel>}
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
id="custom-text"
|
||||
placeholder={fieldMeta?.placeholder ?? t`Enter your text here`}
|
||||
className={cn('w-full rounded-md', {
|
||||
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
|
||||
fieldState.error,
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{fieldMeta?.characterLimit !== undefined &&
|
||||
fieldMeta?.characterLimit > 0 &&
|
||||
!fieldState.error && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Plural
|
||||
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
|
||||
one="# character remaining"
|
||||
other="# characters remaining"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -7,9 +7,9 @@ import { FilePlus, Loader } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -44,7 +44,9 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
||||
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
|
||||
if (isUploadingFile) {
|
||||
return;
|
||||
}
|
||||
@ -52,13 +54,17 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
||||
setIsUploadingFile(true);
|
||||
|
||||
try {
|
||||
const response = await putPdfFile(file);
|
||||
|
||||
const { id } = await createTemplate({
|
||||
const payload = {
|
||||
title: file.name,
|
||||
templateDocumentDataId: response.id,
|
||||
folderId: folderId,
|
||||
});
|
||||
} satisfies TCreateTemplatePayloadSchema;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('file', file);
|
||||
|
||||
const { envelopeId: id } = await createTemplate(formData);
|
||||
|
||||
toast({
|
||||
title: _(msg`Template document uploaded`),
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
|
||||
import { LinkIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||
|
||||
export type TemplateDirectLinkDialogWrapperProps = {
|
||||
template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] };
|
||||
};
|
||||
|
||||
export const TemplateDirectLinkDialogWrapper = ({
|
||||
template,
|
||||
}: TemplateDirectLinkDialogWrapperProps) => {
|
||||
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-3"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setTemplateDirectLinkOpen(true);
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
||||
{template.directLink ? (
|
||||
<Trans>Manage Direct Link</Trans>
|
||||
) : (
|
||||
<Trans>Create Direct Link</Trans>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<TemplateDirectLinkDialog
|
||||
template={template}
|
||||
open={isTemplateDirectLinkOpen}
|
||||
onOpenChange={setTemplateDirectLinkOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -3,13 +3,15 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client';
|
||||
import {
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
type Template,
|
||||
type TemplateDirectLink,
|
||||
} from '@prisma/client';
|
||||
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
|
||||
CircleDotIcon,
|
||||
CircleIcon,
|
||||
ClipboardCopyIcon,
|
||||
InfoIcon,
|
||||
LinkIcon,
|
||||
LoaderIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link, useRevalidator } from 'react-router';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
@ -31,6 +33,7 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
@ -47,20 +50,19 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type TemplateDirectLinkDialogProps = {
|
||||
template: Template & {
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
templateId: number;
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
recipients: Recipient[];
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
|
||||
|
||||
export const TemplateDirectLinkDialog = ({
|
||||
template,
|
||||
open,
|
||||
onOpenChange,
|
||||
templateId,
|
||||
directLink,
|
||||
recipients,
|
||||
trigger,
|
||||
}: TemplateDirectLinkDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { quota, remaining } = useLimits();
|
||||
@ -69,8 +71,9 @@ export const TemplateDirectLinkDialog = ({
|
||||
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
|
||||
const [token, setToken] = useState(template.directLink?.token ?? null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(directLink?.enabled ?? false);
|
||||
const [token, setToken] = useState(directLink?.token ?? null);
|
||||
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
|
||||
token ? 'MANAGE' : 'ONBOARD',
|
||||
@ -80,11 +83,11 @@ export const TemplateDirectLinkDialog = ({
|
||||
|
||||
const validDirectTemplateRecipients = useMemo(
|
||||
() =>
|
||||
template.recipients.filter(
|
||||
recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
||||
),
|
||||
[template.recipients],
|
||||
[recipients],
|
||||
);
|
||||
|
||||
const {
|
||||
@ -140,7 +143,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
onSuccess: async () => {
|
||||
await revalidate();
|
||||
|
||||
onOpenChange(false);
|
||||
setOpen(false);
|
||||
setToken(null);
|
||||
|
||||
toast({
|
||||
@ -178,7 +181,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
setSelectedRecipientId(recipientId);
|
||||
|
||||
await createTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
templateId,
|
||||
directRecipientId: recipientId,
|
||||
});
|
||||
};
|
||||
@ -195,300 +198,311 @@ export const TemplateDirectLinkDialog = ({
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<fieldset disabled={isLoading} className="relative">
|
||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||
{match({ token, currentStep })
|
||||
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create Direct Signing Link</Trans>
|
||||
</DialogTitle>
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" className="px-3">
|
||||
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Here's how it works:</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{directLink ? <Trans>Manage Direct Link</Trans> : <Trans>Create Direct Link</Trans>}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent hideClose>
|
||||
<fieldset disabled={isLoading} className="relative">
|
||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||
{match({ token, currentStep })
|
||||
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create Direct Signing Link</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<ul className="mt-4 space-y-4 pl-12">
|
||||
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
||||
<li className="relative" key={index}>
|
||||
<div className="absolute -left-12">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
|
||||
{index + 1}
|
||||
<DialogDescription>
|
||||
<Trans>Here's how it works:</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="mt-4 space-y-4 pl-12">
|
||||
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
||||
<li className="relative" key={index}>
|
||||
<div className="absolute -left-12">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold">{_(step.title)}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3 className="font-semibold">{_(step.title)}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{remaining.directTemplates === 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
<Trans>
|
||||
Direct template link usage exceeded ({quota.directTemplates}/
|
||||
{quota.directTemplates})
|
||||
</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
You have reached the maximum limit of {quota.directTemplates} direct
|
||||
templates.{' '}
|
||||
<Link
|
||||
className="mt-1 block underline underline-offset-4"
|
||||
to={`/o/${organisation.url}/settings/billing`}
|
||||
>
|
||||
Upgrade your account to continue!
|
||||
</Link>
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{remaining.directTemplates === 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
<Trans>
|
||||
Direct template link usage exceeded ({quota.directTemplates}/
|
||||
{quota.directTemplates})
|
||||
</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
You have reached the maximum limit of {quota.directTemplates} direct
|
||||
templates.{' '}
|
||||
<Link
|
||||
className="mt-1 block underline underline-offset-4"
|
||||
to={`/o/${organisation.url}/settings/billing`}
|
||||
>
|
||||
Upgrade your account to continue!
|
||||
</Link>
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{remaining.directTemplates !== 0 && (
|
||||
<DialogFooter className="mx-auto mt-4">
|
||||
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
||||
<Trans> Enable direct link signing</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
|
||||
<DialogContent className="relative">
|
||||
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Choose Direct Link Recipient</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Choose an existing recipient from below to continue</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Trans>Recipient</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Role</Trans>
|
||||
</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{validDirectTemplateRecipients.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-16 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>No valid recipients found</Trans>
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{validDirectTemplateRecipients.map((row) => (
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
key={row.id}
|
||||
onClick={async () => onRecipientTableRowClick(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<p>{row.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{selectedRecipientId === row.id ? (
|
||||
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
|
||||
) : (
|
||||
<CircleIcon className="h-5 w-5 text-neutral-300" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
|
||||
{!template.recipients.some(
|
||||
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||
) && (
|
||||
<DialogFooter className="mx-auto">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{validDirectTemplateRecipients.length !== 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Or</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-2"
|
||||
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
|
||||
onClick={async () =>
|
||||
createTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Create one automatically</Trans>
|
||||
{remaining.directTemplates !== 0 && (
|
||||
<DialogFooter className="mx-auto mt-4">
|
||||
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
||||
<Trans> Enable direct link signing</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
|
||||
<DialogContent className="relative">
|
||||
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Direct Link Signing</Trans>
|
||||
</DialogTitle>
|
||||
)}
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Manage the direct link signing for this template</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Choose Direct Link Recipient</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Label className="flex flex-row">
|
||||
<Trans>Enable Direct Link Signing</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger tabIndex={-1} className="ml-2">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<Trans>
|
||||
Disabling direct link signing will prevent anyone from accessing the
|
||||
link.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<DialogDescription>
|
||||
<Trans>Choose an existing recipient from below to continue</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Switch
|
||||
className="mt-2"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(value) => setIsEnabled(value)}
|
||||
/>
|
||||
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Trans>Recipient</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Role</Trans>
|
||||
</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{validDirectTemplateRecipients.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-16 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>No valid recipients found</Trans>
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{validDirectTemplateRecipients.map((row) => (
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
key={row.id}
|
||||
onClick={async () => onRecipientTableRowClick(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<p>{row.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{selectedRecipientId === row.id ? (
|
||||
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
|
||||
) : (
|
||||
<CircleIcon className="h-5 w-5 text-neutral-300" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="copy-direct-link">
|
||||
<Trans>Copy Shareable Link</Trans>
|
||||
</Label>
|
||||
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
|
||||
{!recipients.some(
|
||||
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||
) && (
|
||||
<DialogFooter className="mx-auto">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{validDirectTemplateRecipients.length !== 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Or</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
id="copy-direct-link"
|
||||
disabled
|
||||
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
|
||||
readOnly
|
||||
className="pr-12"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
|
||||
<Button
|
||||
variant="none"
|
||||
type="button"
|
||||
className="h-8 w-8"
|
||||
onClick={() => void onCopyClick(token)}
|
||||
className="mt-2"
|
||||
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
|
||||
onClick={async () =>
|
||||
createTemplateDirectLink({
|
||||
templateId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<Trans>Create one automatically</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Direct Link Signing</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Manage the direct link signing for this template</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Label className="flex flex-row">
|
||||
<Trans>Enable Direct Link Signing</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger tabIndex={-1} className="ml-2">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<Trans>
|
||||
Disabling direct link signing will prevent anyone from accessing the
|
||||
link.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
|
||||
<Switch
|
||||
className="mt-2"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(value) => setIsEnabled(value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="copy-direct-link">
|
||||
<Trans>Copy Shareable Link</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
id="copy-direct-link"
|
||||
disabled
|
||||
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
|
||||
readOnly
|
||||
className="pr-12"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
|
||||
<Button
|
||||
variant="none"
|
||||
type="button"
|
||||
className="h-8 w-8"
|
||||
onClick={() => void onCopyClick(token)}
|
||||
>
|
||||
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto w-full sm:w-auto"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => setCurrentStep('CONFIRM_DELETE')}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto w-full sm:w-auto"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => setCurrentStep('CONFIRM_DELETE')}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isTogglingTemplateAccess}
|
||||
onClick={async () => {
|
||||
await toggleTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
enabled: isEnabled,
|
||||
}).catch(() => null);
|
||||
<Button
|
||||
type="button"
|
||||
loading={isTogglingTemplateAccess}
|
||||
onClick={async () => {
|
||||
await toggleTemplateDirectLink({
|
||||
templateId,
|
||||
enabled: isEnabled,
|
||||
}).catch(() => null);
|
||||
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Please note that proceeding will remove direct linking recipient and turn it
|
||||
into a placeholder.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Please note that proceeding will remove direct linking recipient and turn it
|
||||
into a placeholder.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setCurrentStep('MANAGE')}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setCurrentStep('MANAGE')}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => void deleteTemplateDirectLink({ templateId: template.id })}
|
||||
>
|
||||
<Trans>Confirm</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</fieldset>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => void deleteTemplateDirectLink({ templateId })}
|
||||
>
|
||||
<Trans>Confirm</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@ -73,7 +73,7 @@ export function TemplateMoveToFolderDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId ?? null,
|
||||
type: FolderType.TEMPLATE,
|
||||
@ -83,7 +83,7 @@ export function TemplateMoveToFolderDialog({
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
|
||||
const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@ -96,9 +96,11 @@ export function TemplateMoveToFolderDialog({
|
||||
|
||||
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
||||
try {
|
||||
await moveTemplateToFolder({
|
||||
await updateTemplate({
|
||||
templateId,
|
||||
folderId: data.folderId ?? null,
|
||||
data: {
|
||||
folderId: data.folderId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
|
||||
@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
||||
import { InfoIcon, Plus, Upload, X } from 'lucide-react';
|
||||
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import * as z from 'zod';
|
||||
@ -15,8 +15,11 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||
isTemplateRecipientEmailPlaceholder,
|
||||
} from '@documenso/lib/constants/template';
|
||||
import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -42,58 +45,37 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.any()
|
||||
.refine((data) => data instanceof File || data === undefined)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
title: z.string(),
|
||||
data: z.instanceof(File).optional(),
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
// Display exactly which rows are duplicates.
|
||||
.superRefine((items, ctx) => {
|
||||
const uniqueEmails = new Map<string, number>();
|
||||
|
||||
for (const [index, recipients] of items.recipients.entries()) {
|
||||
const email = recipients.email.toLowerCase();
|
||||
|
||||
const firstFoundIndex = uniqueEmails.get(email);
|
||||
|
||||
if (firstFoundIndex === undefined) {
|
||||
uniqueEmails.set(email, index);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['recipients', index, 'email'],
|
||||
});
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['recipients', firstFoundIndex, 'email'],
|
||||
});
|
||||
}
|
||||
});
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||
|
||||
export type TemplateUseDialogProps = {
|
||||
envelopeId: string;
|
||||
templateId: number;
|
||||
templateSigningOrder?: DocumentSigningOrder | null;
|
||||
recipients: Recipient[];
|
||||
@ -106,6 +88,7 @@ export function TemplateUseDialog({
|
||||
recipients,
|
||||
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||
documentRootPath,
|
||||
envelopeId,
|
||||
templateId,
|
||||
templateSigningOrder,
|
||||
trigger,
|
||||
@ -122,7 +105,7 @@ export function TemplateUseDialog({
|
||||
defaultValues: {
|
||||
distributeDocument: false,
|
||||
useCustomDocument: false,
|
||||
customDocumentData: undefined,
|
||||
customDocumentData: [],
|
||||
recipients: recipients
|
||||
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
||||
.map((recipient) => {
|
||||
@ -144,23 +127,50 @@ export function TemplateUseDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const { replace, fields: localCustomDocumentData } = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'customDocumentData',
|
||||
});
|
||||
|
||||
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
||||
{
|
||||
envelopeId,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = response?.data ?? [];
|
||||
|
||||
const { mutateAsync: createDocumentFromTemplate } =
|
||||
trpc.template.createDocumentFromTemplate.useMutation();
|
||||
|
||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||
try {
|
||||
let customDocumentDataId: string | undefined = undefined;
|
||||
const customFilesToUpload = (data.customDocumentData || []).filter(
|
||||
(item): item is { data: File; envelopeItemId: string; title: string } =>
|
||||
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
|
||||
);
|
||||
|
||||
if (data.useCustomDocument && data.customDocumentData) {
|
||||
const customDocumentData = await putPdfFile(data.customDocumentData);
|
||||
customDocumentDataId = customDocumentData.id;
|
||||
}
|
||||
const customDocumentData = await Promise.all(
|
||||
customFilesToUpload.map(async (item) => {
|
||||
const customDocumentData = await putPdfFile(item.data);
|
||||
|
||||
const { id } = await createDocumentFromTemplate({
|
||||
return {
|
||||
documentDataId: customDocumentData.id,
|
||||
envelopeItemId: item.envelopeItemId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const { envelopeId } = await createDocumentFromTemplate({
|
||||
templateId,
|
||||
recipients: data.recipients,
|
||||
distributeDocument: data.distributeDocument,
|
||||
customDocumentDataId,
|
||||
customDocumentData,
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -169,7 +179,7 @@ export function TemplateUseDialog({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
let documentPath = `${documentRootPath}/${id}`;
|
||||
let documentPath = `${documentRootPath}/${envelopeId}`;
|
||||
|
||||
if (
|
||||
data.distributeDocument &&
|
||||
@ -209,6 +219,18 @@ export function TemplateUseDialog({
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (envelopeItems.length > 0 && localCustomDocumentData.length === 0) {
|
||||
replace(
|
||||
envelopeItems.map((item) => ({
|
||||
title: item.title,
|
||||
data: undefined,
|
||||
envelopeItemId: item.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [envelopeItems, form, open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
@ -278,14 +300,7 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={
|
||||
isTemplateRecipientEmailPlaceholder(field.value)
|
||||
? ''
|
||||
: _(msg`Email`)
|
||||
}
|
||||
/>
|
||||
<Input {...field} aria-label="Email" placeholder={_(msg`Email`)} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -306,6 +321,7 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
aria-label="Name"
|
||||
placeholder={recipients[index].name || _(msg`Name`)}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -440,116 +456,133 @@ export function TemplateUseDialog({
|
||||
/>
|
||||
|
||||
{form.watch('useCustomDocument') && (
|
||||
<div className="my-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customDocumentData"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="w-full space-y-4">
|
||||
<label
|
||||
className={cn(
|
||||
'text-muted-foreground hover:border-muted-foreground/50 group relative flex min-h-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 px-6 py-10 transition-colors',
|
||||
{
|
||||
'border-destructive hover:border-destructive':
|
||||
form.formState.errors.customDocumentData,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
{!field.value && (
|
||||
<>
|
||||
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
|
||||
<div className="mt-4 flex text-sm leading-6">
|
||||
<span className="text-muted-foreground relative">
|
||||
<Trans>
|
||||
<span className="text-primary font-semibold">
|
||||
Click to upload
|
||||
</span>{' '}
|
||||
or drag and drop
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground/80 text-xs">
|
||||
PDF files only
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{field.value && (
|
||||
<div className="text-muted-foreground space-y-1">
|
||||
<p className="text-sm font-medium">{field.value.name}</p>
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{(field.value.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
<div className="my-4 space-y-2">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<SpinnerBox className="py-16" />
|
||||
) : (
|
||||
localCustomDocumentData.map((item, i) => (
|
||||
<FormField
|
||||
key={item.id}
|
||||
control={form.control}
|
||||
name={`customDocumentData.${i}.data`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<FileTextIcon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
data-testid="template-use-dialog-file-input"
|
||||
className="absolute h-full w-full opacity-0"
|
||||
accept=".pdf,application/pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
field.onChange(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(msg`Please select a PDF file`),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(
|
||||
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
||||
),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
{field.value && (
|
||||
<div className="absolute right-2 top-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
field.onChange(undefined);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<div className="sr-only">
|
||||
<Trans>Clear file</Trans>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-foreground truncate text-sm font-medium">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{field.value ? (
|
||||
<div>
|
||||
<Trans>
|
||||
Custom {(field.value.size / (1024 * 1024)).toFixed(2)}{' '}
|
||||
MB file
|
||||
</Trans>
|
||||
</div>
|
||||
) : (
|
||||
<Trans>Default file</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{field.value ? (
|
||||
<div className="">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
field.onChange(undefined);
|
||||
}}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
const fileInput = document.getElementById(
|
||||
`template-use-dialog-file-input-${item.envelopeItemId}`,
|
||||
);
|
||||
|
||||
if (fileInput instanceof HTMLInputElement) {
|
||||
fileInput.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UploadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Upload</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id={`template-use-dialog-file-input-${item.envelopeItemId}`}
|
||||
className="hidden"
|
||||
accept=".pdf,application/pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
field.onChange(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(msg`Please select a PDF file`),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
file.size >
|
||||
APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024
|
||||
) {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(
|
||||
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
||||
),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -56,7 +56,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
||||
|
||||
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
|
||||
|
||||
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
|
||||
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.delete.useMutation({
|
||||
onSuccess() {
|
||||
onDelete?.();
|
||||
},
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||
import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
} from '@documenso/trpc/server/document-router/schema';
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||
|
||||
// Define the schema for configuration
|
||||
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
|
||||
|
||||
@ -4,6 +4,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentData, FieldType } from '@prisma/client';
|
||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
@ -12,7 +13,6 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||
@ -83,21 +83,14 @@ export const ConfigureFieldsView = ({
|
||||
|
||||
const normalizedDocumentData = useMemo(() => {
|
||||
if (documentData) {
|
||||
return documentData;
|
||||
return documentData.data;
|
||||
}
|
||||
|
||||
if (!configData.documentData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = base64.encode(configData.documentData?.data);
|
||||
|
||||
return {
|
||||
id: 'preview',
|
||||
type: 'BYTES_64',
|
||||
data,
|
||||
initialData: data,
|
||||
} satisfies DocumentData;
|
||||
return base64.encode(configData.documentData.data);
|
||||
}, [configData.documentData]);
|
||||
|
||||
const recipients = useMemo(() => {
|
||||
@ -118,6 +111,7 @@ export const ConfigureFieldsView = ({
|
||||
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
|
||||
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||
envelopeId: '',
|
||||
}));
|
||||
}, [configData.signers]);
|
||||
|
||||
@ -172,6 +166,8 @@ export const ConfigureFieldsView = ({
|
||||
name: 'fields',
|
||||
});
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
@ -538,9 +534,19 @@ export const ConfigureFieldsView = ({
|
||||
<Form {...form}>
|
||||
{normalizedDocumentData && (
|
||||
<div>
|
||||
<PDFViewer documentData={normalizedDocumentData} />
|
||||
<PDFViewer
|
||||
overrideData={normalizedDocumentData}
|
||||
envelopeItem={{
|
||||
id: '',
|
||||
envelopeId: '',
|
||||
}}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
/>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex(
|
||||
(r) => r.id === field.recipientId,
|
||||
|
||||
@ -9,6 +9,7 @@ export type EmbedAuthenticationRequiredProps = {
|
||||
email?: string;
|
||||
returnTo: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
oidcProviderLabel?: string;
|
||||
};
|
||||
@ -17,6 +18,7 @@ export const EmbedAuthenticationRequired = ({
|
||||
email,
|
||||
returnTo,
|
||||
// isGoogleSSOEnabled,
|
||||
// isMicrosoftSSOEnabled,
|
||||
// isOIDCSSOEnabled,
|
||||
// oidcProviderLabel,
|
||||
}: EmbedAuthenticationRequiredProps) => {
|
||||
@ -37,6 +39,7 @@ export const EmbedAuthenticationRequired = ({
|
||||
<SignInForm
|
||||
// Embed currently not supported.
|
||||
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
// oidcProviderLabel={oidcProviderLabel}
|
||||
className="mt-4"
|
||||
|
||||
@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useSearchParams } from 'react-router';
|
||||
@ -37,6 +37,7 @@ import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-sc
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
import { EmbedClientLoading } from './embed-client-loading';
|
||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||
@ -44,19 +45,21 @@ import { EmbedDocumentFields } from './embed-document-fields';
|
||||
|
||||
export type EmbedDirectTemplateClientPageProps = {
|
||||
token: string;
|
||||
envelopeId: string;
|
||||
updatedAt: Date;
|
||||
documentData: DocumentData;
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
metadata?: DocumentMeta | null;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhiteLabelling?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedDirectTemplateClientPage = ({
|
||||
token,
|
||||
envelopeId,
|
||||
updatedAt,
|
||||
documentData,
|
||||
envelopeItems,
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
@ -91,8 +94,12 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
localFields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
|
||||
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
|
||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||
|
||||
@ -317,14 +324,20 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={recipient.token} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<PDFViewer
|
||||
documentData={documentData}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
@ -343,19 +356,34 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -442,7 +470,9 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -33,7 +33,7 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
|
||||
export type EmbedDocumentFieldsProps = {
|
||||
fields: Field[];
|
||||
metadata?: Pick<
|
||||
DocumentMeta | TemplateMeta,
|
||||
DocumentMeta,
|
||||
| 'timezone'
|
||||
| 'dateFormat'
|
||||
| 'typedSignatureEnabled'
|
||||
@ -50,8 +50,10 @@ export const EmbedDocumentFields = ({
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: EmbedDocumentFieldsProps) => {
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
return (
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
|
||||
@ -3,24 +3,20 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import {
|
||||
type DocumentData,
|
||||
type Field,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
|
||||
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import {
|
||||
type DocumentField,
|
||||
DocumentReadOnlyFields,
|
||||
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
@ -35,6 +31,7 @@ import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
||||
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
||||
@ -43,24 +40,26 @@ import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||
import { EmbedDocumentFields } from './embed-document-fields';
|
||||
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||
|
||||
export type EmbedSignDocumentClientPageProps = {
|
||||
export type EmbedSignDocumentV1ClientPageProps = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
documentData: DocumentData;
|
||||
envelopeId: string;
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: DocumentField[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
metadata?: DocumentMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhitelabelling?: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
};
|
||||
|
||||
export const EmbedSignDocumentClientPage = ({
|
||||
export const EmbedSignDocumentV1ClientPage = ({
|
||||
token,
|
||||
documentId,
|
||||
documentData,
|
||||
envelopeId,
|
||||
envelopeItems,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
@ -69,7 +68,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
hidePoweredBy = false,
|
||||
allowWhitelabelling = false,
|
||||
allRecipients = [],
|
||||
}: EmbedSignDocumentClientPageProps) => {
|
||||
}: EmbedSignDocumentV1ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -89,7 +88,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||
useState(false);
|
||||
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
@ -106,6 +105,8 @@ export const EmbedSignDocumentClientPage = ({
|
||||
fields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
@ -116,6 +117,8 @@ export const EmbedSignDocumentClientPage = ({
|
||||
|
||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
|
||||
const assistantSignersId = useId();
|
||||
|
||||
const onNextFieldClick = () => {
|
||||
@ -268,21 +271,25 @@ export const EmbedSignDocumentClientPage = ({
|
||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
{allowDocumentRejection && (
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
|
||||
|
||||
{allowDocumentRejection && (
|
||||
<DocumentSigningRejectDialog
|
||||
document={{ id: documentId }}
|
||||
documentId={documentId}
|
||||
token={token}
|
||||
onRejected={onDocumentRejected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="embed--DocumentViewer flex-1">
|
||||
<PDFViewer
|
||||
documentData={documentData}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
@ -305,19 +312,36 @@ export const EmbedSignDocumentClientPage = ({
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
disabled={
|
||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||
}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -465,7 +489,9 @@ export const EmbedSignDocumentClientPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
@ -0,0 +1,232 @@
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
|
||||
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2';
|
||||
import { useRequiredEnvelopeSigningContext } from '../general/document-signing/envelope-signing-provider';
|
||||
import { EmbedClientLoading } from './embed-client-loading';
|
||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||
import { EmbedSigningProvider } from './embed-signing-context';
|
||||
|
||||
export type EmbedSignDocumentV2ClientPageProps = {
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhitelabelling?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedSignDocumentV2ClientPage = ({
|
||||
hidePoweredBy = false,
|
||||
allowWhitelabelling = false,
|
||||
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { envelope, recipient, envelopeData, setFullName, fullName } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
|
||||
const { isCompleted, isRejected, recipientSignature } = envelopeData;
|
||||
|
||||
// !: Not used at the moment, may be removed in the future.
|
||||
// const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
|
||||
const onDocumentCompleted = (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
}) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-completed',
|
||||
data,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentError = () => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-error',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentReady = () => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-ready',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onFieldSigned = (data: { fieldId?: number; value?: string; isBase64?: boolean }) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'field-signed',
|
||||
data,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onFieldUnsigned = (data: { fieldId?: number }) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'field-unsigned',
|
||||
data,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentRejected = (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
reason?: string;
|
||||
}) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-rejected',
|
||||
data,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
try {
|
||||
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||
|
||||
if (!isCompleted && data.name) {
|
||||
setFullName(data.name);
|
||||
}
|
||||
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (allowWhitelabelling) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
setHasFinishedInit(true);
|
||||
|
||||
// !: While the setters are stable we still want to ensure we're avoiding
|
||||
// !: re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allowWhitelabelling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFinishedInit) {
|
||||
onDocumentReady();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasFinishedInit]);
|
||||
|
||||
// Listen for document completion events from the envelope signing context
|
||||
useEffect(() => {
|
||||
if (isCompleted) {
|
||||
onDocumentCompleted({
|
||||
token: recipient.token,
|
||||
envelopeId: envelope.id,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
}
|
||||
}, [isCompleted, envelope.id, recipient.id, recipient.token]);
|
||||
|
||||
// Listen for document rejection events
|
||||
useEffect(() => {
|
||||
if (isRejected) {
|
||||
onDocumentRejected({
|
||||
token: recipient.token,
|
||||
envelopeId: envelope.id,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
}
|
||||
}, [isRejected, envelope.id, recipient.id, recipient.token]);
|
||||
|
||||
if (isRejected) {
|
||||
return <EmbedDocumentRejected />;
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<EmbedDocumentCompleted
|
||||
name={fullName}
|
||||
signature={
|
||||
recipientSignature
|
||||
? {
|
||||
id: 1,
|
||||
fieldId: 1,
|
||||
recipientId: recipient.id,
|
||||
created: new Date(),
|
||||
signatureImageAsBase64: recipientSignature.signatureImageAsBase64,
|
||||
typedSignature: recipientSignature.typedSignature,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmbedSigningProvider
|
||||
isNameLocked={isNameLocked}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowDocumentRejection={allowDocumentRejection}
|
||||
onDocumentCompleted={onDocumentCompleted}
|
||||
onDocumentError={onDocumentError}
|
||||
onDocumentRejected={onDocumentRejected}
|
||||
onDocumentReady={onDocumentReady}
|
||||
onFieldSigned={onFieldSigned}
|
||||
onFieldUnsigned={onFieldUnsigned}
|
||||
>
|
||||
<div className="embed--Root relative">
|
||||
{!hasFinishedInit && <EmbedClientLoading />}
|
||||
|
||||
<DocumentSigningPageViewV2 />
|
||||
</div>
|
||||
</EmbedSigningProvider>
|
||||
);
|
||||
};
|
||||
101
apps/remix/app/components/embed/embed-signing-context.tsx
Normal file
101
apps/remix/app/components/embed/embed-signing-context.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type EmbedSigningContextValue = {
|
||||
isEmbed: true;
|
||||
allowDocumentRejection: boolean;
|
||||
isNameLocked: boolean;
|
||||
isEmailLocked: boolean;
|
||||
hidePoweredBy: boolean;
|
||||
onDocumentCompleted: (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
}) => void;
|
||||
onDocumentError: () => void;
|
||||
onDocumentRejected: (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
reason?: string;
|
||||
}) => void;
|
||||
onDocumentReady: () => void;
|
||||
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
|
||||
onFieldUnsigned: (data: { fieldId?: number }) => void;
|
||||
};
|
||||
|
||||
const EmbedSigningContext = createContext<EmbedSigningContextValue | null>(null);
|
||||
|
||||
export const useEmbedSigningContext = () => {
|
||||
return useContext(EmbedSigningContext);
|
||||
};
|
||||
|
||||
export const useRequiredEmbedSigningContext = () => {
|
||||
const context = useEmbedSigningContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useRequiredEmbedSigningContext must be used within EmbedSigningProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type EmbedSigningProviderProps = {
|
||||
allowDocumentRejection?: boolean;
|
||||
isNameLocked?: boolean;
|
||||
isEmailLocked?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
onDocumentCompleted: (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
}) => void;
|
||||
onDocumentError: () => void;
|
||||
onDocumentRejected: (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
reason?: string;
|
||||
}) => void;
|
||||
onDocumentReady: () => void;
|
||||
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
|
||||
onFieldUnsigned: (data: { fieldId?: number }) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EmbedSigningProvider = ({
|
||||
allowDocumentRejection = false,
|
||||
isNameLocked = false,
|
||||
isEmailLocked = true,
|
||||
hidePoweredBy = false,
|
||||
onDocumentCompleted,
|
||||
onDocumentError,
|
||||
onDocumentRejected,
|
||||
onDocumentReady,
|
||||
onFieldSigned,
|
||||
onFieldUnsigned,
|
||||
children,
|
||||
}: EmbedSigningProviderProps) => {
|
||||
return (
|
||||
<EmbedSigningContext.Provider
|
||||
value={{
|
||||
isEmbed: true,
|
||||
allowDocumentRejection,
|
||||
isNameLocked,
|
||||
isEmailLocked,
|
||||
hidePoweredBy,
|
||||
onDocumentCompleted,
|
||||
onDocumentError,
|
||||
onDocumentRejected,
|
||||
onDocumentReady,
|
||||
onFieldSigned,
|
||||
onFieldUnsigned,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EmbedSigningContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -92,6 +92,8 @@ export const MultiSignDocumentSigningView = ({
|
||||
[],
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
||||
|
||||
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
||||
@ -210,7 +212,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
{allowDocumentRejection && (
|
||||
<div className="embed--Actions mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningRejectDialog
|
||||
document={document}
|
||||
documentId={document.id}
|
||||
token={token}
|
||||
onRejected={onRejected}
|
||||
/>
|
||||
@ -224,7 +226,9 @@ export const MultiSignDocumentSigningView = ({
|
||||
})}
|
||||
>
|
||||
<PDFViewer
|
||||
documentData={document.documentData}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
onDocumentLoad={() => {
|
||||
setHasDocumentLoaded(true);
|
||||
onDocumentReady?.();
|
||||
@ -357,7 +361,9 @@ export const MultiSignDocumentSigningView = ({
|
||||
</div>
|
||||
|
||||
{hasDocumentLoaded && (
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip
|
||||
key={pendingFields[0].id}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -29,6 +29,8 @@ import {
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
@ -68,6 +70,9 @@ export function BrandingPreferencesForm({
|
||||
}: BrandingPreferencesFormProps) {
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||
|
||||
@ -88,14 +93,13 @@ export function BrandingPreferencesForm({
|
||||
const file = JSON.parse(settings.brandingLogo);
|
||||
|
||||
if ('type' in file && 'data' in file) {
|
||||
void getFile(file).then((binaryData) => {
|
||||
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
|
||||
const logoUrl =
|
||||
context === 'Team'
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
||||
|
||||
setPreviewUrl(objectUrl);
|
||||
setHasLoadedPreview(true);
|
||||
});
|
||||
|
||||
return;
|
||||
setPreviewUrl(logoUrl);
|
||||
setHasLoadedPreview(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,10 +3,11 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationType } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
@ -16,12 +17,12 @@ import {
|
||||
isValidLanguageCode,
|
||||
} from '@documenso/lib/constants/i18n';
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import {
|
||||
type TDocumentMetaDateFormat,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
} from '@documenso/trpc/server/document-router/schema';
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -86,8 +87,10 @@ export const DocumentPreferencesForm = ({
|
||||
}: DocumentPreferencesFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { user, organisations } = useSession();
|
||||
const currentOrganisation = useCurrentOrganisation();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
|
||||
|
||||
const placeholderEmail = user.email ?? 'user@example.com';
|
||||
|
||||
@ -331,7 +334,7 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isPersonalLayoutMode && (
|
||||
{!isPersonalLayoutMode && !isPersonalOrganisation && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
|
||||
@ -0,0 +1,359 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
import {
|
||||
type TCheckboxFieldMeta as CheckboxFieldMeta,
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
ZCheckboxFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
checkboxValidationLength,
|
||||
checkboxValidationRules,
|
||||
checkboxValidationSigns,
|
||||
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZCheckboxFieldFormSchema = ZCheckboxFieldMeta.pick({
|
||||
label: true,
|
||||
direction: true,
|
||||
validationRule: true,
|
||||
validationLength: true,
|
||||
required: true,
|
||||
values: true,
|
||||
readOnly: true,
|
||||
fontSize: true,
|
||||
})
|
||||
.extend({
|
||||
validationLength: z.coerce.number().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// You need to specify both validation rule and length together
|
||||
if (data.validationRule && !data.validationLength) {
|
||||
return false;
|
||||
}
|
||||
if (data.validationLength && !data.validationRule) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'You need to specify both the validation rule and the number of options',
|
||||
path: ['validationRule'],
|
||||
},
|
||||
);
|
||||
|
||||
type TCheckboxFieldFormSchema = z.infer<typeof ZCheckboxFieldFormSchema>;
|
||||
|
||||
type EditorFieldCheckboxFormProps = {
|
||||
value: CheckboxFieldMeta | undefined;
|
||||
onValueChange: (value: CheckboxFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldCheckboxForm = ({
|
||||
value = {
|
||||
type: 'checkbox',
|
||||
direction: 'vertical',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldCheckboxFormProps) => {
|
||||
const form = useForm<TCheckboxFieldFormSchema>({
|
||||
resolver: zodResolver(ZCheckboxFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
label: value.label || '',
|
||||
direction: value.direction || 'vertical',
|
||||
validationRule: value.validationRule || '',
|
||||
validationLength: value.validationLength || 0,
|
||||
values: value.values || [{ id: 1, checked: false, value: '' }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
const addValue = (numberOfValues: number = 1) => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
const currentMaxId = Math.max(...currentValues.map((val) => val.id));
|
||||
|
||||
const newValues = Array.from({ length: numberOfValues }, (_, index) => ({
|
||||
id: currentMaxId + index + 1,
|
||||
checked: false,
|
||||
value: '',
|
||||
}));
|
||||
|
||||
form.setValue('values', [...currentValues, ...newValues]);
|
||||
};
|
||||
|
||||
const removeValue = (index: number) => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
|
||||
if (currentValues.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValues = [...currentValues];
|
||||
newValues.splice(index, 1);
|
||||
|
||||
form.setValue('values', newValues);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZCheckboxFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
...value,
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
const isValidationRuleMetForPreselectedValues = useMemo(() => {
|
||||
const preselectedValues = (formValues.values || [])?.filter((value) => value.checked);
|
||||
|
||||
if (formValues.validationLength && formValues.validationRule && preselectedValues.length > 0) {
|
||||
const validationRule = checkboxValidationSigns.find(
|
||||
(sign) => sign.label === formValues.validationRule,
|
||||
);
|
||||
|
||||
if (!validationRule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return validateCheckboxLength(
|
||||
preselectedValues.length,
|
||||
validationRule.value,
|
||||
formValues.validationLength,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="direction"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Direction</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectValue placeholder={t`Select direction`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="vertical">
|
||||
<Trans>Vertical</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="horizontal">
|
||||
<Trans>Horizontal</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row items-center justify-start gap-x-4">
|
||||
<div className="flex w-2/3 flex-col">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="validationRule"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Validation</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectValue placeholder={t`Select at least`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{checkboxValidationRules.map((item, index) => (
|
||||
<SelectItem key={index} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex w-1/3 flex-col">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="validationLength"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ? String(field.value) : ''}
|
||||
onValueChange={(value) => {
|
||||
const validationNumber = Number(value);
|
||||
|
||||
const currentValues = formValues.values || [];
|
||||
|
||||
const minimumNumberOfValuesRequired =
|
||||
validationNumber - currentValues.length;
|
||||
|
||||
if (!formValues.validationRule) {
|
||||
form.setValue('validationRule', checkboxValidationRules[0]);
|
||||
}
|
||||
|
||||
if (minimumNumberOfValuesRequired > 0) {
|
||||
addValue(minimumNumberOfValuesRequired);
|
||||
}
|
||||
|
||||
field.onChange(validationNumber);
|
||||
void form.trigger();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background mt-5 w-full">
|
||||
<SelectValue placeholder={t`Pick a number`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{checkboxValidationLength.map((item, index) => (
|
||||
<SelectItem key={index} value={String(item)}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="-mx-4 mb-4 mt-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>Checkbox values</Trans>
|
||||
</p>
|
||||
|
||||
<button type="button" onClick={() => addValue()}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{(formValues.values || []).map((value, index) => (
|
||||
<li key={`checkbox-value-${index}`} className="flex flex-row items-center gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`values.${index}.checked`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`values.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input className="w-full" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => removeValue(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!isValidationRuleMetForPreselectedValues && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
The preselected values will be ignored unless they meet the validation criteria.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TDateFieldMeta as DateFieldMeta,
|
||||
ZDateFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZDateFieldFormSchema = ZDateFieldMeta.pick({
|
||||
fontSize: true,
|
||||
textAlign: true,
|
||||
});
|
||||
|
||||
type TDateFieldFormSchema = z.infer<typeof ZDateFieldFormSchema>;
|
||||
|
||||
type EditorFieldDateFormProps = {
|
||||
value: DateFieldMeta | undefined;
|
||||
onValueChange: (value: DateFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldDateForm = ({
|
||||
value = {
|
||||
type: 'date',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldDateFormProps) => {
|
||||
const form = useForm<TDateFieldFormSchema>({
|
||||
resolver: zodResolver(ZDateFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: value.textAlign || 'left',
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZDateFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'date',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField formControl={form.control} />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,254 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TDropdownFieldMeta as DropdownFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZDropdownFieldFormSchema = z.object({
|
||||
defaultValue: z.string().optional(),
|
||||
values: z
|
||||
.object({
|
||||
value: z.string().min(1, {
|
||||
message: msg`Option value cannot be empty`.id,
|
||||
}),
|
||||
})
|
||||
.array()
|
||||
.min(1, {
|
||||
message: msg`Dropdown must have at least one option`.id,
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
const seen = new Map<string, number[]>(); // value → indices
|
||||
|
||||
values.forEach((item, index) => {
|
||||
const key = item.value;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, []);
|
||||
}
|
||||
seen.get(key)!.push(index);
|
||||
});
|
||||
|
||||
for (const [key, indices] of seen) {
|
||||
if (indices.length > 1 && key.trim() !== '') {
|
||||
for (const i of indices) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: msg`Duplicate values are not allowed`.id,
|
||||
path: [i, 'value'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
fontSize: z.number().optional(),
|
||||
});
|
||||
|
||||
type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>;
|
||||
|
||||
type EditorFieldDropdownFormProps = {
|
||||
value: DropdownFieldMeta | undefined;
|
||||
onValueChange: (value: DropdownFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldDropdownForm = ({
|
||||
value = {
|
||||
type: 'dropdown',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldDropdownFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TDropdownFieldFormSchema>({
|
||||
resolver: zodResolver(ZDropdownFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
defaultValue: value.defaultValue,
|
||||
values: value.values || [{ value: 'Option 1' }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
const formValues = useWatch({
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
const addValue = () => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
|
||||
let newValue = 'New option';
|
||||
|
||||
// Iterate to create a unique value
|
||||
for (let i = 0; i < currentValues.length; i++) {
|
||||
newValue = `New option ${i + 1}`;
|
||||
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
|
||||
newValue = `New option ${i + 1}`;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const newValues = [...currentValues, { value: newValue }];
|
||||
|
||||
form.setValue('values', newValues);
|
||||
};
|
||||
|
||||
const removeValue = (index: number) => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
|
||||
if (currentValues.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValues = [...currentValues];
|
||||
newValues.splice(index, 1);
|
||||
|
||||
form.setValue('values', newValues);
|
||||
|
||||
if (form.getValues('defaultValue') === newValues[index].value) {
|
||||
form.setValue('defaultValue', undefined);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZDropdownFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'dropdown',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
{/* Todo: Envelopes This is buggy. */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultValue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Select default option</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value ?? '-1'}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectValue placeholder={t`Default Value`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(formValues.values || [])
|
||||
.filter((item) => item.value)
|
||||
.map((item, index) => (
|
||||
<SelectItem key={index} value={item.value || ''}>
|
||||
{item.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Default Value</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-1">
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="-mx-4 mb-4 mt-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>Dropdown values</Trans>
|
||||
</p>
|
||||
|
||||
<button type="button" onClick={addValue}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{(formValues.values || []).map((value, index) => (
|
||||
<li key={`dropdown-value-${index}`} className="flex flex-row gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`values.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => removeValue(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TEmailFieldMeta as EmailFieldMeta,
|
||||
ZEmailFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZEmailFieldFormSchema = ZEmailFieldMeta.pick({
|
||||
fontSize: true,
|
||||
textAlign: true,
|
||||
});
|
||||
|
||||
type TEmailFieldFormSchema = z.infer<typeof ZEmailFieldFormSchema>;
|
||||
|
||||
type EditorFieldEmailFormProps = {
|
||||
value: EmailFieldMeta | undefined;
|
||||
onValueChange: (value: EmailFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldEmailForm = ({
|
||||
value = {
|
||||
type: 'email',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldEmailFormProps) => {
|
||||
const form = useForm<TEmailFieldFormSchema>({
|
||||
resolver: zodResolver(ZEmailFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: value.textAlign || 'left',
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZEmailFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'email',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField formControl={form.control} />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,222 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { type Control, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
// Can't seem to get the non-any type to work with correct types.
|
||||
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type FormControlType = Control<any>;
|
||||
|
||||
export const EditorGenericFontSizeField = ({
|
||||
formControl,
|
||||
className,
|
||||
}: {
|
||||
formControl: FormControlType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name="fontSize"
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
<FormLabel>
|
||||
<Trans>Font Size</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={8}
|
||||
max={96}
|
||||
className="bg-background"
|
||||
placeholder={t`Field font size`}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditorGenericTextAlignField = ({
|
||||
formControl,
|
||||
className,
|
||||
}: {
|
||||
formControl: FormControlType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name="textAlign"
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
<FormLabel>
|
||||
<Trans>Text Align</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select text align`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">
|
||||
<Trans>Left</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="center">
|
||||
<Trans>Center</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="right">
|
||||
<Trans>Right</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditorGenericRequiredField = ({
|
||||
formControl,
|
||||
className,
|
||||
}: {
|
||||
formControl: FormControlType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { watch, setValue } = useFormContext();
|
||||
|
||||
const readOnly = watch('readOnly');
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly) {
|
||||
setValue('required', false);
|
||||
}
|
||||
}, [readOnly]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name="required"
|
||||
render={({ field }) => (
|
||||
<FormItem className={cn('flex items-center space-x-2', className)}>
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="field-required"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-required">
|
||||
<Trans>Required Field</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditorGenericReadOnlyField = ({
|
||||
formControl,
|
||||
className,
|
||||
}: {
|
||||
formControl: FormControlType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { watch, setValue } = useFormContext();
|
||||
|
||||
const required = watch('required');
|
||||
|
||||
useEffect(() => {
|
||||
if (required) {
|
||||
setValue('readOnly', false);
|
||||
}
|
||||
}, [required]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name="readOnly"
|
||||
render={({ field }) => (
|
||||
<FormItem className={cn('flex items-center space-x-2', className)}>
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="field-read-only"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-read-only">
|
||||
<Trans>Read Only</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditorGenericLabelField = ({
|
||||
formControl,
|
||||
className,
|
||||
}: {
|
||||
formControl: FormControlType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
<FormLabel>
|
||||
<Trans>Label</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t`Field label`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TInitialsFieldMeta as InitialsFieldMeta,
|
||||
ZInitialsFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZInitialsFieldFormSchema = ZInitialsFieldMeta.pick({
|
||||
fontSize: true,
|
||||
textAlign: true,
|
||||
});
|
||||
|
||||
type TInitialsFieldFormSchema = z.infer<typeof ZInitialsFieldFormSchema>;
|
||||
|
||||
type EditorFieldInitialsFormProps = {
|
||||
value: InitialsFieldMeta | undefined;
|
||||
onValueChange: (value: InitialsFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldInitialsForm = ({
|
||||
value = {
|
||||
type: 'initials',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldInitialsFormProps) => {
|
||||
const form = useForm<TInitialsFieldFormSchema>({
|
||||
resolver: zodResolver(ZInitialsFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: value.textAlign || 'left',
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZInitialsFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'initials',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField formControl={form.control} />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TNameFieldMeta as NameFieldMeta,
|
||||
ZNameFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZNameFieldFormSchema = ZNameFieldMeta.pick({
|
||||
fontSize: true,
|
||||
textAlign: true,
|
||||
});
|
||||
|
||||
type TNameFieldFormSchema = z.infer<typeof ZNameFieldFormSchema>;
|
||||
|
||||
type EditorFieldNameFormProps = {
|
||||
value: NameFieldMeta | undefined;
|
||||
onValueChange: (value: NameFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldNameForm = ({
|
||||
value = {
|
||||
type: 'name',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldNameFormProps) => {
|
||||
const form = useForm<TNameFieldFormSchema>({
|
||||
resolver: zodResolver(ZNameFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: value.textAlign || 'left',
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZNameFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'name',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField formControl={form.control} />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,277 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
type TNumberFieldMeta as NumberFieldMeta,
|
||||
ZNumberFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericLabelField,
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
|
||||
label: true,
|
||||
placeholder: true,
|
||||
value: true,
|
||||
numberFormat: true,
|
||||
fontSize: true,
|
||||
textAlign: true,
|
||||
required: true,
|
||||
readOnly: true,
|
||||
minValue: true,
|
||||
maxValue: true,
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Minimum value cannot be greater than maximum value
|
||||
if (typeof data.minValue === 'number' && typeof data.maxValue === 'number') {
|
||||
return data.minValue <= data.maxValue;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Minimum value cannot be greater than maximum value',
|
||||
path: ['minValue'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// A read-only field must have a value greater than 0
|
||||
if (data.readOnly && data.value !== undefined && data.value !== '') {
|
||||
const numberValue = parseFloat(data.value);
|
||||
return !isNaN(numberValue) && numberValue > 0;
|
||||
}
|
||||
return !data.readOnly || (data.value !== undefined && data.value !== '');
|
||||
},
|
||||
{
|
||||
message: 'A read-only field must have a value greater than 0',
|
||||
path: ['value'],
|
||||
},
|
||||
);
|
||||
|
||||
type TNumberFieldFormSchema = z.infer<typeof ZNumberFieldFormSchema>;
|
||||
|
||||
type EditorFieldNumberFormProps = {
|
||||
value: NumberFieldMeta | undefined;
|
||||
onValueChange: (value: NumberFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldNumberForm = ({
|
||||
value = {
|
||||
type: 'number',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldNumberFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TNumberFieldFormSchema>({
|
||||
resolver: zodResolver(ZNumberFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
label: value.label || '',
|
||||
placeholder: value.placeholder || '',
|
||||
value: value.value || '',
|
||||
numberFormat: value.numberFormat || null,
|
||||
fontSize: value.fontSize || 14,
|
||||
textAlign: value.textAlign || 'left',
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
minValue: value.minValue,
|
||||
maxValue: value.maxValue,
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'number',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<div className="flex w-full flex-row gap-x-4">
|
||||
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<EditorGenericLabelField formControl={form.control} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="placeholder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Placeholder</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" placeholder={t`Placeholder`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Value</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" placeholder={t`Value`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="numberFormat"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Number format</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectValue placeholder={t`Field format`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{numberFormatValues.map((item, index) => (
|
||||
<SelectItem key={index} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>None</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-1">
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
|
||||
{/* Validation section */}
|
||||
<section className="space-y-2">
|
||||
<div className="-mx-4 mb-4 mt-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>Validation</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-row gap-x-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="minValue"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Min</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="bg-background"
|
||||
placeholder="E.g. 0"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value === '' ? null : e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxValue"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Max</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="bg-background"
|
||||
placeholder="E.g. 100"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value === '' ? null : e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,240 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TRadioFieldMeta as RadioFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({
|
||||
label: true,
|
||||
direction: true,
|
||||
values: true,
|
||||
required: true,
|
||||
readOnly: true,
|
||||
fontSize: true,
|
||||
}).refine(
|
||||
(data) => {
|
||||
// There cannot be more than one checked option
|
||||
if (data.values) {
|
||||
const checkedValues = data.values.filter((option) => option.checked);
|
||||
return checkedValues.length <= 1;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'There cannot be more than one checked option',
|
||||
path: ['values'],
|
||||
},
|
||||
);
|
||||
|
||||
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
||||
|
||||
export type EditorFieldRadioFormProps = {
|
||||
value: RadioFieldMeta | undefined;
|
||||
onValueChange: (value: RadioFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldRadioForm = ({
|
||||
value = {
|
||||
type: 'radio',
|
||||
direction: 'vertical',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldRadioFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TRadioFieldFormSchema>({
|
||||
resolver: zodResolver(ZRadioFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
label: value.label || '',
|
||||
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
direction: value.direction || 'vertical',
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
const formValues = useWatch({
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
const addValue = () => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
const newId =
|
||||
currentValues.length > 0 ? Math.max(...currentValues.map((val) => val.id)) + 1 : 1;
|
||||
|
||||
const newValues = [...currentValues, { id: newId, checked: false, value: '' }];
|
||||
form.setValue('values', newValues);
|
||||
};
|
||||
|
||||
const removeValue = (index: number) => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
|
||||
if (currentValues.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValues = [...currentValues];
|
||||
newValues.splice(index, 1);
|
||||
|
||||
form.setValue('values', newValues);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZRadioFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'radio',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="direction"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Direction</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectValue placeholder={t`Select direction`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="vertical">
|
||||
<Trans>Vertical</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="horizontal">
|
||||
<Trans>Horizontal</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="-mx-4 mb-4 mt-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>Radio values</Trans>
|
||||
</p>
|
||||
|
||||
<button type="button" onClick={addValue}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{(formValues.values || []).map((value, index) => (
|
||||
<li key={`radio-value-${index}`} className="flex flex-row items-center gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`values.${index}.checked`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
// Uncheck all other values.
|
||||
const currentValues = form.getValues('values') || [];
|
||||
|
||||
if (value) {
|
||||
const newValues = currentValues.map((val) => ({
|
||||
...val,
|
||||
checked: false,
|
||||
}));
|
||||
|
||||
form.setValue('values', newValues);
|
||||
}
|
||||
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`values.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input className="w-full" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => removeValue(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,74 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TSignatureFieldMeta,
|
||||
ZSignatureFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
|
||||
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
|
||||
|
||||
const ZSignatureFieldFormSchema = ZSignatureFieldMeta.pick({
|
||||
fontSize: true,
|
||||
});
|
||||
|
||||
type TSignatureFieldFormSchema = z.infer<typeof ZSignatureFieldFormSchema>;
|
||||
|
||||
type EditorFieldSignatureFormProps = {
|
||||
value: TSignatureFieldMeta | undefined;
|
||||
onValueChange: (value: TSignatureFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldSignatureForm = ({
|
||||
value = {
|
||||
type: 'signature',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldSignatureFormProps) => {
|
||||
const form = useForm<TSignatureFieldFormSchema>({
|
||||
resolver: zodResolver(ZSignatureFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZSignatureFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'signature',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<div>
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
<Trans>The typed signature font size</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,218 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TTextFieldMeta as TextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZTextFieldFormSchema = z
|
||||
.object({
|
||||
label: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
characterLimit: z.coerce.number().min(0).optional(),
|
||||
fontSize: z.coerce.number().min(8).max(96).optional(),
|
||||
textAlign: z.enum(['left', 'center', 'right']).optional(),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// A read-only field must have text
|
||||
return !data.readOnly || (data.text && data.text.length > 0);
|
||||
},
|
||||
{
|
||||
message: 'A read-only field must have text',
|
||||
path: ['text'],
|
||||
},
|
||||
);
|
||||
|
||||
type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>;
|
||||
|
||||
type EditorFieldTextFormProps = {
|
||||
value: TextFieldMeta | undefined;
|
||||
onValueChange: (value: TextFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldTextForm = ({
|
||||
value = {
|
||||
type: 'text',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldTextFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TTextFieldFormSchema>({
|
||||
resolver: zodResolver(ZTextFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
label: value.label || '',
|
||||
placeholder: value.placeholder || '',
|
||||
text: value.text || '',
|
||||
characterLimit: value.characterLimit || 0,
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: value.textAlign || 'left',
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'text',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<div className="flex w-full flex-row gap-x-4">
|
||||
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Label</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t`Field label`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="placeholder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Placeholder</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t`Field placeholder`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Add text</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-auto"
|
||||
placeholder={t`Add text to the field`}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const values = form.getValues();
|
||||
const characterLimit = values.characterLimit || 0;
|
||||
let textValue = e.target.value;
|
||||
|
||||
if (characterLimit > 0 && textValue.length > characterLimit) {
|
||||
textValue = textValue.slice(0, characterLimit);
|
||||
}
|
||||
|
||||
e.target.value = textValue;
|
||||
field.onChange(e);
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="characterLimit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Character Limit</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="bg-background"
|
||||
placeholder={t`Field character limit`}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
|
||||
const values = form.getValues();
|
||||
const characterLimit = parseInt(e.target.value, 10) || 0;
|
||||
|
||||
const textValue = values.text || '';
|
||||
|
||||
if (characterLimit > 0 && textValue.length > characterLimit) {
|
||||
form.setValue('text', textValue.slice(0, characterLimit));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-1">
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -70,6 +70,7 @@ export type SignInFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
oidcProviderLabel?: string;
|
||||
returnTo?: string;
|
||||
@ -79,6 +80,7 @@ export const SignInForm = ({
|
||||
className,
|
||||
initialEmail,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
@ -90,11 +92,14 @@ export const SignInForm = ({
|
||||
|
||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||
useState(false);
|
||||
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
||||
|
||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||
'totp' | 'backup'
|
||||
>('totp');
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
|
||||
const redirectPath = useMemo(() => {
|
||||
@ -114,7 +119,7 @@ export const SignInForm = ({
|
||||
}, [returnTo]);
|
||||
|
||||
const { mutateAsync: createPasskeySigninOptions } =
|
||||
trpc.auth.createPasskeySigninOptions.useMutation();
|
||||
trpc.auth.passkey.createSigninOptions.useMutation();
|
||||
|
||||
const form = useForm<TSignInFormSchema>({
|
||||
values: {
|
||||
@ -271,6 +276,22 @@ export const SignInForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onSignInWithMicrosoftClick = async () => {
|
||||
try {
|
||||
await authClient.microsoft.signIn({
|
||||
redirectPath,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to sign you In. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSignInWithOIDCClick = async () => {
|
||||
try {
|
||||
await authClient.oidc.signIn({
|
||||
@ -297,6 +318,8 @@ export const SignInForm = ({
|
||||
if (email) {
|
||||
form.setValue('email', email);
|
||||
}
|
||||
|
||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
@ -363,42 +386,64 @@ export const SignInForm = ({
|
||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||
</Button>
|
||||
|
||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
<Trans>Or continue with</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
)}
|
||||
{!isEmbeddedRedirect && (
|
||||
<>
|
||||
{hasSocialAuthEnabled && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
<Trans>Or continue with</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGoogleSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
)}
|
||||
{isGoogleSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
{oidcProviderLabel || 'OIDC'}
|
||||
</Button>
|
||||
{isMicrosoftSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithMicrosoftClick}
|
||||
>
|
||||
<img
|
||||
className="mr-2 h-4 w-4"
|
||||
alt="Microsoft Logo"
|
||||
src={'/static/microsoft.svg'}
|
||||
/>
|
||||
Microsoft
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
{oidcProviderLabel || 'OIDC'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
|
||||
@ -66,14 +66,18 @@ export type SignUpFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
returnTo?: string;
|
||||
};
|
||||
|
||||
export const SignUpForm = ({
|
||||
className,
|
||||
initialEmail,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
returnTo,
|
||||
}: SignUpFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -84,6 +88,8 @@ export const SignUpForm = ({
|
||||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
name: '',
|
||||
@ -106,7 +112,7 @@ export const SignUpForm = ({
|
||||
signature,
|
||||
});
|
||||
|
||||
await navigate(`/unverified-account`);
|
||||
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||
|
||||
toast({
|
||||
title: _(msg`Registration Successful`),
|
||||
@ -148,6 +154,20 @@ export const SignUpForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onSignUpWithMicrosoftClick = async () => {
|
||||
try {
|
||||
await authClient.microsoft.signIn();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSignUpWithOIDCClick = async () => {
|
||||
try {
|
||||
await authClient.oidc.signIn();
|
||||
@ -227,7 +247,7 @@ export const SignUpForm = ({
|
||||
<fieldset
|
||||
className={cn(
|
||||
'flex h-[550px] w-full flex-col gap-y-4',
|
||||
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
|
||||
hasSocialAuthEnabled && 'h-[650px]',
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
@ -302,7 +322,7 @@ export const SignUpForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
||||
{hasSocialAuthEnabled && (
|
||||
<>
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
@ -330,6 +350,26 @@ export const SignUpForm = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMicrosoftSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithMicrosoftClick}
|
||||
>
|
||||
<img
|
||||
className="mr-2 h-4 w-4"
|
||||
alt="Microsoft Logo"
|
||||
src={'/static/microsoft.svg'}
|
||||
/>
|
||||
<Trans>Sign Up with Microsoft</Trans>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@ -39,6 +39,7 @@ export const SubscriptionClaimForm = ({
|
||||
name: subscriptionClaim.name,
|
||||
teamCount: subscriptionClaim.teamCount,
|
||||
memberCount: subscriptionClaim.memberCount,
|
||||
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||
flags: subscriptionClaim.flags,
|
||||
},
|
||||
});
|
||||
@ -111,6 +112,30 @@ export const SubscriptionClaimForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="envelopeItemCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Envelope Item Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
<Trans>Feature Flags</Trans>
|
||||
|
||||
138
apps/remix/app/components/forms/support-ticket-form.tsx
Normal file
138
apps/remix/app/components/forms/support-ticket-form.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZSupportTicketSchema = z.object({
|
||||
subject: z.string().min(3, 'Subject is required'),
|
||||
message: z.string().min(10, 'Message must be at least 10 characters'),
|
||||
});
|
||||
|
||||
type TSupportTicket = z.infer<typeof ZSupportTicketSchema>;
|
||||
|
||||
export type SupportTicketFormProps = {
|
||||
organisationId: string;
|
||||
teamId?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const SupportTicketForm = ({
|
||||
organisationId,
|
||||
teamId,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: SupportTicketFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: submitSupportTicket, isPending } =
|
||||
trpc.profile.submitSupportTicket.useMutation();
|
||||
|
||||
const form = useForm<TSupportTicket>({
|
||||
resolver: zodResolver(ZSupportTicketSchema),
|
||||
defaultValues: {
|
||||
subject: '',
|
||||
message: '',
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = form.formState.isLoading || isPending;
|
||||
|
||||
const onSubmit = async (data: TSupportTicket) => {
|
||||
const { subject, message } = data;
|
||||
|
||||
try {
|
||||
await submitSupportTicket({
|
||||
subject,
|
||||
message,
|
||||
organisationId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Support ticket created`,
|
||||
description: t`Your support request has been submitted. We'll get back to you soon!`,
|
||||
});
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Failed to create support ticket`,
|
||||
description: t`An error occurred. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={isLoading} className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Subject</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Message</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={5} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex flex-row gap-2">
|
||||
<Button type="submit" size="sm" loading={isLoading}>
|
||||
<Trans>Submit</Trans>
|
||||
</Button>
|
||||
{onClose && (
|
||||
<Button variant="outline" size="sm" type="button" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -13,7 +13,7 @@ import type { z } from 'zod';
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
|
||||
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -47,7 +47,7 @@ export const EXPIRATION_DATES = {
|
||||
ONE_YEAR: msg`12 months`,
|
||||
} as const;
|
||||
|
||||
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.pick({
|
||||
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
|
||||
tokenName: true,
|
||||
expirationDate: true,
|
||||
});
|
||||
@ -75,7 +75,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
|
||||
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
||||
|
||||
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
|
||||
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
|
||||
onSuccess(data) {
|
||||
setNewlyCreatedToken(data);
|
||||
},
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Theme, useTheme } from 'remix-themes';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||
import {
|
||||
@ -63,10 +64,12 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
const debouncedSearch = useDebouncedValue(search, 200);
|
||||
|
||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||
trpcReact.document.searchDocuments.useQuery(
|
||||
trpcReact.document.search.useQuery(
|
||||
{
|
||||
query: search,
|
||||
query: debouncedSearch,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
@ -232,6 +235,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
)}
|
||||
|
||||
{!currentPage && (
|
||||
<>
|
||||
{documentPageLinks.length > 0 && (
|
||||
@ -239,14 +243,17 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
<Commands push={push} pages={documentPageLinks} />
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{templatePageLinks.length > 0 && (
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
|
||||
<Commands push={push} pages={templatePageLinks} />
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
|
||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
|
||||
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
|
||||
Change language
|
||||
@ -255,6 +262,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
Change theme
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
|
||||
<Commands push={push} pages={searchResults} />
|
||||
|
||||
@ -60,7 +60,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||
<Link
|
||||
to={`${getRootHref(params, { returnEmptyRootString: true })}`}
|
||||
to={getRootHref(params)}
|
||||
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
|
||||
>
|
||||
<BrandingLogo className="h-6 w-auto" />
|
||||
|
||||
17
apps/remix/app/components/general/branding-logo-icon.tsx
Normal file
17
apps/remix/app/components/general/branding-logo-icon.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import type { SVGAttributes } from 'react';
|
||||
|
||||
export type LogoProps = SVGAttributes<SVGSVGElement>;
|
||||
|
||||
export const BrandingLogoIcon = ({ ...props }: LogoProps) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 84" {...props}>
|
||||
<g fill="currentColor">
|
||||
<path d="M35.53 12.152c-.968.879-2.038 1.91-3.261 3.118a4.55 4.55 0 0 1-2.722.97l-4.098.079 1.194-1.194C33.883 7.885 37.502 4.265 42 4.265s8.118 3.62 15.357 10.86l1.192 1.192-3.957-.075a4.55 4.55 0 0 1-3.004-1.209l-2.373-2.194a69 69 0 0 0-.66-.61l-.128-.119h-.002a35 35 0 0 0-2.244-1.892C44.17 8.684 43 8.338 42 8.338s-2.17.346-4.18 1.88a35 35 0 0 0-2.275 1.92zM71.77 35.444a69 69 0 0 0-.608-.658l-2.196-2.374a4.55 4.55 0 0 1-1.208-3.002l-.077-3.961 1.194 1.194c7.24 7.24 10.86 10.859 10.86 15.357s-3.62 8.118-10.86 15.357l-1.194 1.194.077-3.961a4.55 4.55 0 0 1 1.209-3.002l2.195-2.373q.315-.338.609-.66l.119-.128v-.002a35 35 0 0 0 1.892-2.244c1.534-2.01 1.88-3.18 1.88-4.181s-.346-2.17-1.88-4.18a35 35 0 0 0-1.892-2.245v-.002zM48.51 71.813q.362-.33.747-.69l2.331-2.157a4.55 4.55 0 0 1 3.003-1.208l3.959-.076-1.193 1.193c-7.24 7.24-10.859 10.86-15.357 10.86s-8.118-3.62-15.357-10.86l-1.194-1.194 3.97.076a4.55 4.55 0 0 1 2.991 1.2l1.601 1.47c1.461 1.4 2.69 2.502 3.808 3.355 2.01 1.534 3.18 1.88 4.181 1.88s2.17-.346 4.18-1.88a35 35 0 0 0 2.275-1.92zM12.156 48.476q.364.4.763.825l2.115 2.287a4.55 4.55 0 0 1 1.209 3.002l.076 3.961-1.194-1.194C7.885 50.117 4.265 46.498 4.265 42s3.62-8.118 10.86-15.357l1.193-1.193-.075 3.959a4.55 4.55 0 0 1-1.21 3.004l-2.18 2.357q-.325.346-.626.676l-.117.127v.002a35 35 0 0 0-1.892 2.244C8.684 39.83 8.338 41 8.338 42s.346 2.17 1.88 4.18a35 35 0 0 0 1.92 2.275z" />
|
||||
<path d="m12.138 35.543 2.896-3.13a4.55 4.55 0 0 0 1.186-2.626c.012-1.61.038-3.013.096-4.254l.003-.17.006-.005c.053-1.072.131-2.021.246-2.875.337-2.506.92-3.578 1.627-4.286s1.78-1.29 4.285-1.626c.87-.117 1.838-.196 2.935-.25l.002-.002h.06c1.285-.062 2.746-.089 4.43-.1a4.55 4.55 0 0 0 2.711-1.257l2.923-2.825h-1.688c-10.238 0-15.357 0-18.538 3.18-3.18 3.181-3.18 8.3-3.18 18.539zM12.138 48.456v1.688c0 10.239 0 15.358 3.18 18.538s8.3 3.18 18.538 3.18h16.289c10.238 0 15.357 0 18.538-3.18 3.18-3.18 3.18-8.3 3.18-18.537v-1.69l-2.897 3.133a4.55 4.55 0 0 0-1.185 2.618c-.012 1.645-.039 3.075-.1 4.335v.04h-.001a35 35 0 0 1-.25 2.936c-.337 2.506-.92 3.578-1.627 4.286s-1.78 1.29-4.285 1.626c-.855.115-1.804.194-2.876.247l-.005.005-.149.003c-1.246.058-2.658.085-4.277.097-.976.1-1.897.515-2.623 1.185l-3.132 2.897H35.573l-3.163-2.906a4.55 4.55 0 0 0-2.61-1.176 110 110 0 0 1-4.324-.1h-.056l-.002-.002a35 35 0 0 1-2.935-.25c-2.505-.336-3.578-.919-4.285-1.626-.708-.708-1.29-1.78-1.627-4.286a35 35 0 0 1-.25-2.935l-.002-.002-.001-.075c-.06-1.251-.086-2.668-.098-4.296a4.55 4.55 0 0 0-1.186-2.621zM67.781 29.794a4.55 4.55 0 0 0 1.185 2.618l2.897 3.132v-1.688c0-10.239 0-15.358-3.18-18.538s-8.3-3.18-18.538-3.18h-1.689l3.132 2.895a4.55 4.55 0 0 0 2.627 1.186c1.6.012 2.997.038 4.232.096l.247.004.008.008a34 34 0 0 1 2.816.244c2.505.337 3.578.919 4.285 1.626.708.708 1.29 1.78 1.627 4.286.117.87.196 1.839.25 2.936l.001.04c.061 1.26.088 2.69.1 4.335M38.91 23.96l-2.747 2.33a2.9 2.9 0 0 1-1.747.689l-4.597.214 2.397-2.397c4.627-4.627 6.94-6.94 9.815-6.94s5.188 2.313 9.815 6.94l2.383 2.382-4.662-.202a2.9 2.9 0 0 1-1.773-.703l-2.074-1.789c-.728-.685-1.345-1.226-1.908-1.656-1.154-.88-1.592-.9-1.78-.9-.19 0-.627.02-1.781.9l-.055.042h-.003l-.027.023c-.387.3-.8.652-1.257 1.067" />
|
||||
<path d="M61.023 39.995c-.785-.992-1.911-2.163-3.542-3.803a2.9 2.9 0 0 1-.44-1.426l-.202-4.977 2.369 2.368c4.627 4.627 6.94 6.94 6.94 9.815s-2.313 5.188-6.94 9.815l-2.382 2.381.23-4.757a2.9 2.9 0 0 1 .727-1.787l1.742-1.968a28 28 0 0 0 1.387-1.569l.215-.242v-.03l.049-.062c.88-1.154.9-1.592.9-1.781 0-.19-.02-.627-.9-1.78l-.049-.064v-.024zM22.946 40.124l3.175-3.454c.45-.489.719-1.117.762-1.78l.175-2.71c.027-.86.071-1.584.144-2.216l.012-.192.013-.013.009-.065c.193-1.438.488-1.762.622-1.896s.457-.429 1.896-.622c.461-.062.974-.106 1.555-.138l3.9-.385a2.9 2.9 0 0 0 1.678-.75l3.296-3.017h-3.357c-6.543 0-9.815 0-11.847 2.033-1.732 1.732-1.988 4.363-2.026 9.15q-.009 1.246-.007 2.698v3.356" />
|
||||
<path d="M22.946 43.82v3.357c0 .97 0 1.866.006 2.698.038 4.787.295 7.418 2.027 9.15 1.731 1.732 4.362 1.988 9.15 2.026q1.246.009 2.697.007h10.411q1.45.002 2.697-.007c4.788-.038 7.419-.294 9.15-2.026 2.033-2.033 2.033-5.304 2.033-11.848V43.81l-3.384 3.67a2.9 2.9 0 0 0-.69 1.29c-.006 2.38-.038 4.033-.193 5.306l-.002.068-.008.008-.012.098c-.194 1.438-.489 1.762-.623 1.896-.133.133-.457.429-1.895.622l-.099.013-.008.008-.114.007c-.724.086-1.57.133-2.602.159l-2.32.141c-.661.04-1.288.305-1.778.75l-3.538 3.212h-3.697l-3.536-3.306a2.9 2.9 0 0 0-1.69-.769q-.41 0-.79-.004c-1.906-.016-3.288-.063-4.384-.21-1.439-.194-1.762-.49-1.896-.623-.134-.134-.429-.458-.622-1.896l-.009-.065-.012-.013-.002-.027-.004-.108c-.13-1.084-.171-2.442-.185-4.283l-.02-.472a2.9 2.9 0 0 0-.755-1.833zM57.01 32.35l.19 2.586c.049.652.315 1.27.757 1.751l3.16 3.447v-3.367c0-6.544 0-9.815-2.032-11.848s-5.305-2.033-11.848-2.033H43.85l3.391 3.09c.475.432 1.08.696 1.721.748l3.933.322q.562.033 1.045.085l.29.024.013.012.066.01c1.438.192 1.762.488 1.895.621.134.134.43.458.623 1.896.098.733.152 1.595.182 2.655" />
|
||||
<path d="m27.226 54.158-.013-.013.002.027.012.013zM29.849 56.78l4.289.199c-1.852-.015-3.208-.06-4.29-.198M27.044 49.476a3 3 0 0 0-.08-.57 3 3 0 0 1 .04.376l.02.472c.014 1.84.056 3.2.185 4.283l.004.108.013.013zM17.915 41.972c0 2.45 1.679 4.491 5.038 7.903q-.009-1.246-.007-2.698v-3.344l-.007-.008v-.005l-.052-.068c-.88-1.153-.9-1.59-.9-1.78s.02-.627.9-1.78l.059-.077v-3.348q-.001-1.452.006-2.698c-3.358 3.412-5.037 5.454-5.037 7.903M40.25 61.116l-.048-.037h-.01l-.022-.021h-3.344q-1.45.002-2.697-.007c3.412 3.358 5.453 5.038 7.902 5.038 2.45 0 4.491-1.68 7.903-5.038q-1.246.009-2.697.007h-3.35l-.075.058c-1.154.88-1.592.9-1.78.9-.19 0-.627-.02-1.781-.9" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@ -89,7 +89,10 @@ export const DirectTemplatePageView = ({
|
||||
setStep('sign');
|
||||
};
|
||||
|
||||
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
|
||||
const onSignDirectTemplateSubmit = async (
|
||||
fields: DirectTemplateLocalField[],
|
||||
nextSigner?: { name: string; email: string },
|
||||
) => {
|
||||
try {
|
||||
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
|
||||
|
||||
@ -98,6 +101,7 @@ export const DirectTemplatePageView = ({
|
||||
}
|
||||
|
||||
const { token } = await createDocumentFromDirectTemplate({
|
||||
nextSigner,
|
||||
directTemplateToken,
|
||||
directTemplateExternalId,
|
||||
directRecipientName: fullName,
|
||||
@ -149,7 +153,9 @@ export const DirectTemplatePageView = ({
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
key={template.id}
|
||||
documentData={template.templateDocumentData}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
token={directTemplateRecipient.token}
|
||||
version="signed"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@ -55,10 +55,13 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s
|
||||
|
||||
export type DirectTemplateSigningFormProps = {
|
||||
flowStep: DocumentFlowStep;
|
||||
directRecipient: Recipient;
|
||||
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'>;
|
||||
directRecipientFields: Field[];
|
||||
template: Omit<TTemplate, 'user'>;
|
||||
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
|
||||
onSubmit: (
|
||||
_data: DirectTemplateLocalField[],
|
||||
_nextSigner?: { name: string; email: string },
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DirectTemplateLocalField = Field & {
|
||||
@ -79,6 +82,8 @@ export const DirectTemplateSigningForm = ({
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
|
||||
|
||||
const fieldsRequiringValidation = useMemo(() => {
|
||||
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
|
||||
}, [localFields]);
|
||||
@ -147,7 +152,7 @@ export const DirectTemplateSigningForm = ({
|
||||
validateFieldsInserted(fieldsRequiringValidation);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (nextSigner?: { name: string; email: string }) => {
|
||||
setValidateUninsertedFields(true);
|
||||
|
||||
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
||||
@ -159,7 +164,7 @@ export const DirectTemplateSigningForm = ({
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await onSubmit(localFields);
|
||||
await onSubmit(localFields, nextSigner);
|
||||
} catch {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@ -216,12 +221,38 @@ export const DirectTemplateSigningForm = ({
|
||||
setLocalFields(updatedFields);
|
||||
}, []);
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (
|
||||
!template.templateMeta?.signingOrder ||
|
||||
template.templateMeta.signingOrder !== 'SEQUENTIAL' ||
|
||||
!template.templateMeta.allowDictateNextSigner
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sortedRecipients = template.recipients.sort((a, b) => {
|
||||
// Sort by signingOrder first (nulls last), then by id
|
||||
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
||||
if (a.signingOrder === null) return 1;
|
||||
if (b.signingOrder === null) return -1;
|
||||
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
||||
return a.signingOrder - b.signingOrder;
|
||||
});
|
||||
|
||||
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
|
||||
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||
? sortedRecipients[currentIndex + 1]
|
||||
: undefined;
|
||||
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
|
||||
|
||||
return (
|
||||
<DocumentSigningRecipientProvider recipient={directRecipient}>
|
||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
@ -413,11 +444,15 @@ export const DirectTemplateSigningForm = ({
|
||||
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit}
|
||||
onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)}
|
||||
documentTitle={template.title}
|
||||
fields={localFields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={directRecipient.role}
|
||||
recipient={directRecipient}
|
||||
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
|
||||
defaultNextSigner={
|
||||
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
|
||||
@ -0,0 +1,312 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ArrowLeftIcon, KeyIcon, MailIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Form, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
type FormStep = 'method-selection' | 'code-input';
|
||||
type TwoFactorMethod = 'email' | 'authenticator';
|
||||
|
||||
const ZAccessAuth2FAFormSchema = z.object({
|
||||
token: z.string().length(6, { message: 'Token must be 6 characters long' }),
|
||||
});
|
||||
|
||||
type TAccessAuth2FAFormSchema = z.infer<typeof ZAccessAuth2FAFormSchema>;
|
||||
|
||||
export type AccessAuth2FAFormProps = {
|
||||
onSubmit: (accessAuthOptions: TRecipientAccessAuth) => void;
|
||||
token: string;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormProps) => {
|
||||
const [step, setStep] = useState<FormStep>('method-selection');
|
||||
const [selectedMethod, setSelectedMethod] = useState<TwoFactorMethod | null>(null);
|
||||
|
||||
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
|
||||
const [millisecondsRemaining, setMillisecondsRemaining] = useState<number | null>(null);
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { user } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const { mutateAsync: request2FAEmail, isPending: isRequesting2FAEmail } =
|
||||
trpc.document.accessAuth.request2FAEmail.useMutation();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZAccessAuth2FAFormSchema),
|
||||
defaultValues: {
|
||||
token: '',
|
||||
},
|
||||
});
|
||||
|
||||
const hasAuthenticatorEnabled = user?.twoFactorEnabled === true;
|
||||
|
||||
const onMethodSelect = async (method: TwoFactorMethod) => {
|
||||
setSelectedMethod(method);
|
||||
|
||||
if (method === 'email') {
|
||||
try {
|
||||
const result = await request2FAEmail({
|
||||
token: token,
|
||||
});
|
||||
|
||||
setExpiresAt(result.expiresAt);
|
||||
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
|
||||
|
||||
setStep('code-input');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStep('code-input');
|
||||
};
|
||||
|
||||
const onFormSubmit = (data: TAccessAuth2FAFormSchema) => {
|
||||
if (!selectedMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare the auth options for the completion attempt
|
||||
const accessAuthOptions: TRecipientAccessAuth = {
|
||||
type: 'TWO_FACTOR_AUTH',
|
||||
token: data.token, // Just the user's code - backend will validate using method type
|
||||
method: selectedMethod,
|
||||
};
|
||||
|
||||
onSubmit(accessAuthOptions);
|
||||
};
|
||||
|
||||
const onGoBack = () => {
|
||||
setStep('method-selection');
|
||||
setSelectedMethod(null);
|
||||
setExpiresAt(null);
|
||||
setMillisecondsRemaining(null);
|
||||
};
|
||||
|
||||
const onResendEmail = async () => {
|
||||
if (selectedMethod !== 'email') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await request2FAEmail({
|
||||
token: token,
|
||||
});
|
||||
|
||||
setExpiresAt(result.expiresAt);
|
||||
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (expiresAt) {
|
||||
setMillisecondsRemaining(expiresAt.valueOf() - Date.now());
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [expiresAt]);
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
{step === 'method-selection' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Choose verification method</Trans>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Please select how you'd like to receive your verification code.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" padding="tight" className="text-sm">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex h-auto w-full justify-start gap-3 p-4"
|
||||
onClick={async () => onMethodSelect('email')}
|
||||
disabled={isRequesting2FAEmail}
|
||||
>
|
||||
<MailIcon className="h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
<Trans>Email verification</Trans>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Trans>We'll send a 6-digit code to your email</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{hasAuthenticatorEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex h-auto w-full justify-start gap-3 p-4"
|
||||
onClick={async () => onMethodSelect('authenticator')}
|
||||
disabled={isRequesting2FAEmail}
|
||||
>
|
||||
<KeyIcon className="h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
<Trans>Authenticator app</Trans>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Trans>Use your authenticator app to generate a code</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'code-input' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onGoBack}>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Enter verification code</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{selectedMethod === 'email' ? (
|
||||
<Trans>
|
||||
We've sent a 6-digit verification code to your email. Please enter it below to
|
||||
complete the document.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Please open your authenticator app and enter the 6-digit code for this document.
|
||||
</Trans>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="access-auth-2fa-form"
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset disabled={isRequesting2FAEmail || form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 items-center justify-center">
|
||||
<PinInput
|
||||
{...field}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
inputMode="numeric"
|
||||
pattern="^\d+$"
|
||||
aria-label="2FA code"
|
||||
containerClassName="h-12 justify-center"
|
||||
>
|
||||
<PinInputGroup>
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={0} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={1} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={2} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={3} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={4} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={5} />
|
||||
</PinInputGroup>
|
||||
</PinInput>
|
||||
|
||||
{expiresAt && millisecondsRemaining !== null && (
|
||||
<div
|
||||
className={cn('text-muted-foreground mt-2 text-center text-sm', {
|
||||
'text-destructive': millisecondsRemaining <= 0,
|
||||
})}
|
||||
>
|
||||
<Trans>
|
||||
Expires in{' '}
|
||||
{DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat(
|
||||
'mm:ss',
|
||||
)}
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<Button
|
||||
type="submit"
|
||||
form="access-auth-2fa-form"
|
||||
className="w-full"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={isRequesting2FAEmail || form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Verify & Complete</Trans>
|
||||
</Button>
|
||||
|
||||
{selectedMethod === 'email' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onResendEmail}
|
||||
loading={isRequesting2FAEmail}
|
||||
>
|
||||
<Trans>Resend code</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,83 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ExternalLink, PaperclipIcon } from 'lucide-react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
|
||||
export type DocumentSigningAttachmentsPopoverProps = {
|
||||
envelopeId: string;
|
||||
token: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DocumentSigningAttachmentsPopover = ({
|
||||
envelopeId,
|
||||
token,
|
||||
trigger,
|
||||
}: DocumentSigningAttachmentsPopoverProps) => {
|
||||
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||
envelopeId,
|
||||
token,
|
||||
});
|
||||
|
||||
if (!attachments || attachments.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" className="gap-2">
|
||||
<PaperclipIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Attachments</Trans>{' '}
|
||||
{attachments && attachments.data.length > 0 && (
|
||||
<span className="ml-1">({attachments.data.length})</span>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-96" align="start">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
<Trans>Attachments</Trans>
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Documents and resources related to this envelope.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{attachments?.data.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={attachment.data}
|
||||
title={attachment.data}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-border hover:bg-muted/50 group flex items-center justify-between rounded-md border px-3 py-2.5 transition duration-200"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2.5">
|
||||
<div className="bg-muted rounded p-2">
|
||||
<PaperclipIcon className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<span className="text-muted-foreground hover:text-foreground block truncate text-sm underline">
|
||||
{attachment.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ExternalLink className="h-4 w-4 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
|
||||
actionVerb = 'sign',
|
||||
onOpenChange,
|
||||
}: DocumentSigningAuthAccountProps) => {
|
||||
const { recipient } = useRequiredDocumentSigningAuthContext();
|
||||
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
@ -34,8 +34,10 @@ export const DocumentSigningAuthAccount = ({
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
await authClient.signOut({
|
||||
redirectPath: `/signin#email=${email}`,
|
||||
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||
});
|
||||
} catch {
|
||||
setIsSigningOut(false);
|
||||
@ -55,16 +57,28 @@ export const DocumentSigningAuthAccount = ({
|
||||
<AlertDescription>
|
||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||
<span>
|
||||
<Trans>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
{isDirectTemplate ? (
|
||||
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{/* Todo: Translate */}
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
||||
in as <strong>{recipient.email}</strong>
|
||||
{isDirectTemplate ? (
|
||||
<Trans>
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||
logged in.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||
logged in as <strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
|
||||
@ -47,7 +47,8 @@ export const DocumentSigningAuthDialog = ({
|
||||
onOpenChange,
|
||||
onReauthFormSubmit,
|
||||
}: DocumentSigningAuthDialogProps) => {
|
||||
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
||||
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
|
||||
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
||||
const validAuthTypes = availableAuthTypes.filter(
|
||||
@ -168,7 +169,11 @@ export const DocumentSigningAuthDialog = ({
|
||||
match({ documentAuthType: selectedAuthType, user })
|
||||
.with(
|
||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
||||
{
|
||||
user: P.when(
|
||||
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
|
||||
),
|
||||
}, // Assume all current auth methods requires them to be logged in.
|
||||
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
||||
)
|
||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||
|
||||
@ -9,7 +9,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentSigningAuthPageViewProps = {
|
||||
email: string;
|
||||
email?: string;
|
||||
emailHasAccount?: boolean;
|
||||
};
|
||||
|
||||
@ -22,12 +22,18 @@ export const DocumentSigningAuthPageView = ({
|
||||
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
const handleChangeAccount = async (email: string) => {
|
||||
const handleChangeAccount = async (email?: string) => {
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
let redirectPath = '/signin';
|
||||
|
||||
if (email) {
|
||||
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
|
||||
}
|
||||
|
||||
await authClient.signOut({
|
||||
redirectPath: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`,
|
||||
redirectPath,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
@ -49,9 +55,13 @@ export const DocumentSigningAuthPageView = ({
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>
|
||||
You need to be logged in as <strong>{email}</strong> to view this page.
|
||||
</Trans>
|
||||
{email ? (
|
||||
<Trans>
|
||||
You need to be logged in as <strong>{email}</strong> to view this page.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>You need to be logged in to view this page.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
|
||||
@ -77,7 +77,7 @@ export const DocumentSigningAuthPasskey = ({
|
||||
});
|
||||
|
||||
const { mutateAsync: createPasskeyAuthenticationOptions } =
|
||||
trpc.auth.createPasskeyAuthenticationOptions.useMutation();
|
||||
trpc.auth.passkey.createAuthenticationOptions.useMutation();
|
||||
|
||||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { type Document, FieldType, type Passkey, type Recipient } from '@prisma/client';
|
||||
import { type Envelope, FieldType, type Passkey, type Recipient } from '@prisma/client';
|
||||
|
||||
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||
@ -24,17 +24,23 @@ type PasskeyData = {
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
type SigningAuthRecipient = Pick<
|
||||
Recipient,
|
||||
'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'
|
||||
>;
|
||||
|
||||
export type DocumentSigningAuthContextValue = {
|
||||
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
||||
documentAuthOptions: Document['authOptions'];
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
documentAuthOption: TDocumentAuthOptions;
|
||||
setDocumentAuthOptions: (_value: Document['authOptions']) => void;
|
||||
recipient: Recipient;
|
||||
setDocumentAuthOptions: (_value: Envelope['authOptions']) => void;
|
||||
recipient: SigningAuthRecipient;
|
||||
recipientAuthOption: TRecipientAuthOptions;
|
||||
setRecipient: (_value: Recipient) => void;
|
||||
setRecipient: (_value: SigningAuthRecipient) => void;
|
||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
||||
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
||||
isAuthRedirectRequired: boolean;
|
||||
isDirectTemplate?: boolean;
|
||||
isCurrentlyAuthenticating: boolean;
|
||||
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||
passkeyData: PasskeyData;
|
||||
@ -61,8 +67,9 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
||||
};
|
||||
|
||||
export interface DocumentSigningAuthProviderProps {
|
||||
documentAuthOptions: Document['authOptions'];
|
||||
recipient: Recipient;
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
recipient: SigningAuthRecipient;
|
||||
isDirectTemplate?: boolean;
|
||||
user?: SessionUser | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@ -70,6 +77,7 @@ export interface DocumentSigningAuthProviderProps {
|
||||
export const DocumentSigningAuthProvider = ({
|
||||
documentAuthOptions: initialDocumentAuthOptions,
|
||||
recipient: initialRecipient,
|
||||
isDirectTemplate = false,
|
||||
user,
|
||||
children,
|
||||
}: DocumentSigningAuthProviderProps) => {
|
||||
@ -93,7 +101,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
[documentAuthOptions, recipient],
|
||||
);
|
||||
|
||||
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
|
||||
const passkeyQuery = trpc.auth.passkey.find.useQuery(
|
||||
{
|
||||
perPage: MAXIMUM_PASSKEYS,
|
||||
},
|
||||
@ -199,6 +207,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
derivedRecipientAccessAuth,
|
||||
derivedRecipientActionAuth,
|
||||
isAuthRedirectRequired,
|
||||
isDirectTemplate,
|
||||
isCurrentlyAuthenticating,
|
||||
setIsCurrentlyAuthenticating,
|
||||
passkeyData,
|
||||
|
||||
@ -1,19 +1,26 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import {
|
||||
type TRecipientAccessAuth,
|
||||
ZDocumentAccessAuthSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
@ -27,43 +34,76 @@ import {
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningCompleteDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
documentTitle: string;
|
||||
fields: Field[];
|
||||
fieldsValidated: () => void | Promise<void>;
|
||||
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
||||
role: RecipientRole;
|
||||
onSignatureComplete: (
|
||||
nextSigner?: { name: string; email: string },
|
||||
accessAuthOptions?: TRecipientAccessAuth,
|
||||
directRecipient?: { name: string; email: string },
|
||||
) => void | Promise<void>;
|
||||
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
|
||||
disabled?: boolean;
|
||||
allowDictateNextSigner?: boolean;
|
||||
defaultNextSigner?: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
directTemplatePayload?: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
buttonSize?: 'sm' | 'lg';
|
||||
position?: 'start' | 'end' | 'center';
|
||||
};
|
||||
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
|
||||
});
|
||||
|
||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||
|
||||
const ZDirectRecipientFormSchema = z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email('Invalid email address'),
|
||||
});
|
||||
|
||||
type TDirectRecipientFormSchema = z.infer<typeof ZDirectRecipientFormSchema>;
|
||||
|
||||
export const DocumentSigningCompleteDialog = ({
|
||||
isSubmitting,
|
||||
documentTitle,
|
||||
fields,
|
||||
fieldsValidated,
|
||||
onSignatureComplete,
|
||||
role,
|
||||
recipient,
|
||||
disabled = false,
|
||||
allowDictateNextSigner = false,
|
||||
directTemplatePayload,
|
||||
defaultNextSigner,
|
||||
buttonSize = 'lg',
|
||||
position,
|
||||
}: DocumentSigningCompleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||
|
||||
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
|
||||
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
|
||||
|
||||
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
|
||||
|
||||
const form = useForm<TNextSignerFormSchema>({
|
||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||
@ -73,8 +113,21 @@ export const DocumentSigningCompleteDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
|
||||
resolver: zodResolver(ZDirectRecipientFormSchema),
|
||||
defaultValues: {
|
||||
name: directTemplatePayload?.name ?? '',
|
||||
email: directTemplatePayload?.email ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||
|
||||
const completionRequires2FA = useMemo(
|
||||
() => derivedRecipientAccessAuth.includes('TWO_FACTOR_AUTH'),
|
||||
[derivedRecipientAccessAuth],
|
||||
);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (form.formState.isSubmitting || !isComplete) {
|
||||
return;
|
||||
@ -87,23 +140,59 @@ export const DocumentSigningCompleteDialog = ({
|
||||
});
|
||||
}
|
||||
|
||||
setIsEditingNextSigner(false);
|
||||
setShowDialog(open);
|
||||
};
|
||||
|
||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||
try {
|
||||
if (allowDictateNextSigner && data.name && data.email) {
|
||||
await onSignatureComplete({ name: data.name, email: data.email });
|
||||
} else {
|
||||
await onSignatureComplete();
|
||||
let directRecipient: { name: string; email: string } | undefined;
|
||||
|
||||
if (directTemplatePayload && !directTemplatePayload.email) {
|
||||
const isFormValid = await directRecipientForm.trigger();
|
||||
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
directRecipient = directRecipientForm.getValues();
|
||||
}
|
||||
|
||||
// Check if 2FA is required
|
||||
if (completionRequires2FA && !data.accessAuthOptions) {
|
||||
setShowTwoFactorForm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSigner =
|
||||
allowDictateNextSigner && data.name && data.email
|
||||
? { name: data.name, email: data.email }
|
||||
: undefined;
|
||||
|
||||
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
|
||||
} catch (error) {
|
||||
console.error('Error completing signature:', error);
|
||||
const err = AppError.parseError(error);
|
||||
|
||||
if (AppErrorCode.TWO_FACTOR_AUTH_FAILED === err.code) {
|
||||
// This was a 2FA validation failure - show the 2FA dialog again with error
|
||||
form.setValue('accessAuthOptions', undefined);
|
||||
|
||||
setTwoFactorValidationError('Invalid verification code. Please try again.');
|
||||
setShowTwoFactorForm(true);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||
const onTwoFactorFormSubmit = (validatedAuthOptions: TRecipientAccessAuth) => {
|
||||
form.setValue('accessAuthOptions', validatedAuthOptions);
|
||||
|
||||
setShowTwoFactorForm(false);
|
||||
setTwoFactorValidationError(null);
|
||||
|
||||
// Now trigger the form submission with auth options
|
||||
void form.handleSubmit(onFormSubmit)();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||
@ -111,13 +200,13 @@ export const DocumentSigningCompleteDialog = ({
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
size="lg"
|
||||
size={buttonSize}
|
||||
onClick={fieldsValidated}
|
||||
loading={isSubmitting}
|
||||
disabled={disabled}
|
||||
>
|
||||
{match({ isComplete, role })
|
||||
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
||||
{match({ isComplete, role: recipient.role })
|
||||
.with({ isComplete: false }, () => <Trans>Next Field</Trans>)
|
||||
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
||||
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
||||
<Trans>Mark as viewed</Trans>
|
||||
@ -127,119 +216,65 @@ export const DocumentSigningCompleteDialog = ({
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<DialogContent position={position}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete viewing the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete signing the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete approving the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete assisting the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.CC, () => null)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
|
||||
</div>
|
||||
|
||||
{!showTwoFactorForm && (
|
||||
<>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||
<DialogTitle>
|
||||
<div className="text-foreground text-xl font-semibold">
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete signing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete approving{' '}
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
"{documentTitle}"
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allowDictateNextSigner && (
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
{!isEditingNextSigner && (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The next recipient to sign this document will be{' '}
|
||||
<span className="font-semibold">{form.watch('name')}</span> (
|
||||
<span className="font-semibold">{form.watch('email')}</span>).
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||
>
|
||||
<Trans>Update Recipient</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingNextSigner && (
|
||||
{directTemplatePayload && !directTemplatePayload.email && (
|
||||
<Form {...directRecipientForm}>
|
||||
<div className="mb-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
control={directRecipientForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
<Trans>Your Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="mt-2"
|
||||
placeholder="Enter the next signer's name"
|
||||
placeholder={t`Enter your name`}
|
||||
disabled={isNameLocked}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -249,19 +284,20 @@ export const DocumentSigningCompleteDialog = ({
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
control={directRecipientForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
<Trans>Your Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
className="mt-2"
|
||||
placeholder="Enter the next signer's email"
|
||||
placeholder={t`Enter your email`}
|
||||
disabled={!!field.value && isEmailLocked}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@ -269,43 +305,99 @@ export const DocumentSigningCompleteDialog = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<DocumentSigningDisclosure className="mt-4" />
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
{allowDictateNextSigner && defaultNextSigner && (
|
||||
<div className="mb-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Next Recipient Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="mt-2"
|
||||
placeholder={t`Enter the next signer's name`}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
onClick={() => setShowDialog(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!isComplete || !isNextSignerValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Next Recipient Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
className="mt-2"
|
||||
placeholder={t`Enter the next signer's email`}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DocumentSigningDisclosure />
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setShowDialog(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isComplete}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTwoFactorForm && (
|
||||
<AccessAuth2FAForm
|
||||
token={recipient.token}
|
||||
error={twoFactorValidationError}
|
||||
onSubmit={onTwoFactorFormSubmit}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -7,14 +7,11 @@ import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/cl
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@ -34,29 +31,33 @@ export type DocumentSigningFormProps = {
|
||||
document: DocumentAndSender;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
redirectUrl?: string | null;
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
setSelectedSignerId?: (id: number | null) => void;
|
||||
completeDocument: (options: {
|
||||
accessAuthOptions?: TRecipientAccessAuth;
|
||||
nextSigner?: { email: string; name: string };
|
||||
}) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
fieldsValidated: () => void;
|
||||
nextRecipient?: RecipientWithFields;
|
||||
};
|
||||
|
||||
export const DocumentSigningForm = ({
|
||||
document,
|
||||
recipient,
|
||||
fields,
|
||||
redirectUrl,
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
setSelectedSignerId,
|
||||
completeDocument,
|
||||
isSubmitting,
|
||||
fieldsValidated,
|
||||
nextRecipient,
|
||||
}: DocumentSigningFormProps) => {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const assistantSignersId = useId();
|
||||
|
||||
@ -66,21 +67,12 @@ export const DocumentSigningForm = ({
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: completeDocumentWithToken,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
||||
defaultValues: {
|
||||
selectedSignerId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Keep the loading state going if successful since the redirect may take some time.
|
||||
const isSubmitting = isPending || isSuccess;
|
||||
|
||||
const fieldsRequiringValidation = useMemo(
|
||||
() => fields.filter(isFieldUnsignedAndRequired),
|
||||
[fields],
|
||||
@ -96,9 +88,9 @@ export const DocumentSigningForm = ({
|
||||
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
||||
}, [fieldsRequiringValidation, recipient]);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
const localFieldsValidated = () => {
|
||||
setValidateUninsertedFields(true);
|
||||
validateFieldsInserted(fieldsRequiringValidation);
|
||||
fieldsValidated();
|
||||
};
|
||||
|
||||
const onAssistantFormSubmit = () => {
|
||||
@ -113,7 +105,7 @@ export const DocumentSigningForm = ({
|
||||
setIsAssistantSubmitting(true);
|
||||
|
||||
try {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
await completeDocument({ nextSigner });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
@ -126,55 +118,6 @@ export const DocumentSigningForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const completeDocument = async (
|
||||
authOptions?: TRecipientActionAuth,
|
||||
nextSigner?: { email: string; name: string },
|
||||
) => {
|
||||
const payload = {
|
||||
token: recipient.token,
|
||||
documentId: document.id,
|
||||
authOptions,
|
||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||
};
|
||||
|
||||
await completeDocumentWithToken(payload);
|
||||
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: document.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${recipient.token}/complete`);
|
||||
}
|
||||
};
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (
|
||||
!document.documentMeta?.signingOrder ||
|
||||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sortedRecipients = allRecipients.sort((a, b) => {
|
||||
// Sort by signingOrder first (nulls last), then by id
|
||||
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
||||
if (a.signingOrder === null) return 1;
|
||||
if (b.signingOrder === null) return -1;
|
||||
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
||||
return a.signingOrder - b.signingOrder;
|
||||
});
|
||||
|
||||
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
||||
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||
? sortedRecipients[currentIndex + 1]
|
||||
: undefined;
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
@ -205,11 +148,11 @@ export const DocumentSigningForm = ({
|
||||
isSubmitting={isSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
role={recipient.role}
|
||||
fieldsValidated={localFieldsValidated}
|
||||
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||
completeDocument({ nextSigner, accessAuthOptions })
|
||||
}
|
||||
recipient={recipient}
|
||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||
defaultNextSigner={
|
||||
nextRecipient
|
||||
@ -364,12 +307,15 @@ export const DocumentSigningForm = ({
|
||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
fieldsValidated={localFieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
role={recipient.role}
|
||||
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||
completeDocument({
|
||||
accessAuthOptions,
|
||||
nextSigner,
|
||||
})
|
||||
}
|
||||
recipient={recipient}
|
||||
allowDictateNextSigner={
|
||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||
}
|
||||
|
||||
@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
|
||||
import { BrandingLogo } from '../branding-logo';
|
||||
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||
import { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog';
|
||||
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||
|
||||
export const DocumentSigningMobileWidget = () => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const { hidePoweredBy = true } = useEmbedSigningContext() || {};
|
||||
|
||||
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
|
||||
/**
|
||||
* Pre open the widget for assistants to let them know it's there.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
|
||||
<div className="pointer-events-auto w-full max-w-[760px]">
|
||||
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
|
||||
{/* Main Header Bar */}
|
||||
<div className="flex items-center justify-between gap-4 p-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{recipient.role !== RecipientRole.VIEWER && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex h-8 w-8 items-center justify-center"
|
||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||
) : (
|
||||
<LucideChevronUp className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.otherwise(() => null)}
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground -mt-0.5 text-sm">
|
||||
{recipientFieldsRemaining.length === 0 ? (
|
||||
match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<Trans>Please mark as viewed to complete</Trans>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<Trans>Please complete the document once reviewed</Trans>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<Trans>Please complete the document once reviewed</Trans>
|
||||
))
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<Trans>Please complete the document once reviewed</Trans>
|
||||
))
|
||||
.otherwise(() => null)
|
||||
) : (
|
||||
<Plural
|
||||
value={recipientFieldsRemaining.length}
|
||||
one="1 Field Remaining"
|
||||
other="# Fields Remaining"
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<EnvelopeSignerCompleteDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{recipient.role !== RecipientRole.VIEWER &&
|
||||
recipient.role !== RecipientRole.ASSISTANT && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="bg-muted relative h-[4px] rounded-md">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-signing-mobile-widget-progress-bar"
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
style={{
|
||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
|
||||
<EnvelopeSignerForm />
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground mt-2 inline-block rounded px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:hidden">
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,15 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
@ -18,14 +21,18 @@ import {
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
||||
@ -40,9 +47,11 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
|
||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||
|
||||
export type DocumentSigningPageViewProps = {
|
||||
export type DocumentSigningPageViewV1Props = {
|
||||
recipient: RecipientWithFields;
|
||||
document: DocumentAndSender;
|
||||
fields: Field[];
|
||||
@ -52,7 +61,7 @@ export type DocumentSigningPageViewProps = {
|
||||
includeSenderDetails: boolean;
|
||||
};
|
||||
|
||||
export const DocumentSigningPageView = ({
|
||||
export const DocumentSigningPageViewV1 = ({
|
||||
recipient,
|
||||
document,
|
||||
fields,
|
||||
@ -60,12 +69,67 @@ export const DocumentSigningPageView = ({
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
includeSenderDetails,
|
||||
}: DocumentSigningPageViewProps) => {
|
||||
}: DocumentSigningPageViewV1Props) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const { derivedRecipientAccessAuth, user: authUser } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const hasAuthenticator = authUser?.twoFactorEnabled
|
||||
? authUser.twoFactorEnabled && authUser.email === recipient.email
|
||||
: false;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: completeDocumentWithToken,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
// Keep the loading state going if successful since the redirect may take some time.
|
||||
const isSubmitting = isPending || isSuccess;
|
||||
|
||||
const fieldsRequiringValidation = useMemo(
|
||||
() => fields.filter(isFieldUnsignedAndRequired),
|
||||
[fields],
|
||||
);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
validateFieldsInserted(fieldsRequiringValidation);
|
||||
};
|
||||
|
||||
const completeDocument = async (options: {
|
||||
accessAuthOptions?: TRecipientAccessAuth;
|
||||
nextSigner?: { email: string; name: string };
|
||||
}) => {
|
||||
const { accessAuthOptions, nextSigner } = options;
|
||||
|
||||
const payload = {
|
||||
token: recipient.token,
|
||||
documentId: document.id,
|
||||
accessAuthOptions,
|
||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||
};
|
||||
|
||||
await completeDocumentWithToken(payload);
|
||||
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: document.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (documentMeta?.redirectUrl) {
|
||||
window.location.href = documentMeta.redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${recipient.token}/complete`);
|
||||
}
|
||||
};
|
||||
|
||||
let senderName = document.user.name ?? '';
|
||||
let senderEmail = `(${document.user.email})`;
|
||||
|
||||
@ -78,9 +142,42 @@ export const DocumentSigningPageView = ({
|
||||
const targetSigner =
|
||||
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sortedRecipients = allRecipients.sort((a, b) => {
|
||||
// Sort by signingOrder first (nulls last), then by id
|
||||
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
||||
if (a.signingOrder === null) return 1;
|
||||
if (b.signingOrder === null) return -1;
|
||||
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
||||
return a.signingOrder - b.signingOrder;
|
||||
});
|
||||
|
||||
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
||||
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||
? sortedRecipients[currentIndex + 1]
|
||||
: undefined;
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||
const hasPendingFields = pendingFields.length > 0;
|
||||
|
||||
return (
|
||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||
{document.team.teamGlobalSettings.brandingEnabled &&
|
||||
document.team.teamGlobalSettings.brandingLogo && (
|
||||
<img
|
||||
src={`/api/branding/logo/team/${document.teamId}`}
|
||||
alt={`${document.team.name}'s Logo`}
|
||||
className="mb-4 h-12 w-12 md:mb-2"
|
||||
/>
|
||||
)}
|
||||
<h1
|
||||
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
@ -135,14 +232,25 @@ export const DocumentSigningPageView = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DocumentSigningAttachmentsPopover
|
||||
envelopeId={document.envelopeId}
|
||||
token={recipient.token}
|
||||
/>
|
||||
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
||||
<div className="flex-1">
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
||||
<PDFViewer
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
version="signed"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@ -163,19 +271,55 @@ export const DocumentSigningPageView = ({
|
||||
.otherwise(() => null)}
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
{match({ hasPendingFields, isExpanded, role: recipient.role })
|
||||
.with(
|
||||
{
|
||||
hasPendingFields: false,
|
||||
role: P.not(RecipientRole.ASSISTANT),
|
||||
isExpanded: false,
|
||||
},
|
||||
() => (
|
||||
<div className="md:hidden">
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) =>
|
||||
completeDocument({ nextSigner })
|
||||
}
|
||||
recipient={recipient}
|
||||
allowDictateNextSigner={
|
||||
nextRecipient && documentMeta?.allowDictateNextSigner
|
||||
}
|
||||
defaultNextSigner={
|
||||
nextRecipient
|
||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
.with({ isExpanded: true }, () => (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
@ -204,10 +348,13 @@ export const DocumentSigningPageView = ({
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
setSelectedSignerId={setSelectedSignerId}
|
||||
completeDocument={completeDocument}
|
||||
isSubmitting={isSubmitting}
|
||||
fieldsValidated={fieldsValidated}
|
||||
nextRecipient={nextRecipient}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -224,7 +371,9 @@ export const DocumentSigningPageView = ({
|
||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||
)}
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
{fields
|
||||
.filter(
|
||||
(field) =>
|
||||
@ -0,0 +1,265 @@
|
||||
import { lazy, useMemo } from 'react';
|
||||
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { SignFieldCheckboxDialog } from '~/components/dialogs/sign-field-checkbox-dialog';
|
||||
import { SignFieldDropdownDialog } from '~/components/dialogs/sign-field-dropdown-dialog';
|
||||
import { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dialog';
|
||||
import { SignFieldInitialsDialog } from '~/components/dialogs/sign-field-initials-dialog';
|
||||
import { SignFieldNameDialog } from '~/components/dialogs/sign-field-name-dialog';
|
||||
import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog';
|
||||
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
||||
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
|
||||
import { BrandingLogo } from '../branding-logo';
|
||||
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
||||
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
|
||||
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
|
||||
import { DocumentSigningMobileWidget } from './document-signing-mobile-widget';
|
||||
import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
|
||||
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||
|
||||
const EnvelopeSignerPageRenderer = lazy(
|
||||
async () => import('../envelope-signing/envelope-signer-page-renderer'),
|
||||
);
|
||||
|
||||
export const DocumentSigningPageViewV2 = () => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const {
|
||||
isDirectTemplate,
|
||||
envelope,
|
||||
recipient,
|
||||
recipientFields,
|
||||
recipientFieldsRemaining,
|
||||
requiredRecipientFields,
|
||||
selectedAssistantRecipientFields,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
|
||||
const {
|
||||
isEmbed = false,
|
||||
allowDocumentRejection = true,
|
||||
hidePoweredBy = true,
|
||||
onDocumentRejected,
|
||||
} = useEmbedSigningContext() || {};
|
||||
|
||||
/**
|
||||
* The total remaining fields remaining for the current recipient or selected assistant recipient.
|
||||
*
|
||||
* Includes both optional and required fields.
|
||||
*/
|
||||
const remainingFields = useMemo(() => {
|
||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||
return selectedAssistantRecipientFields.filter((field) => !field.inserted);
|
||||
}
|
||||
|
||||
return recipientFields.filter((field) => !field.inserted);
|
||||
}, [recipientFieldsRemaining, selectedAssistantRecipientFields, currentEnvelopeItem]);
|
||||
|
||||
return (
|
||||
<div className="dark:bg-background min-h-screen w-screen bg-gray-50">
|
||||
<SignFieldEmailDialog.Root />
|
||||
<SignFieldTextDialog.Root />
|
||||
<SignFieldNumberDialog.Root />
|
||||
<SignFieldNameDialog.Root />
|
||||
<SignFieldInitialsDialog.Root />
|
||||
<SignFieldDropdownDialog.Root />
|
||||
<SignFieldSignatureDialog.Root />
|
||||
<SignFieldCheckboxDialog.Root />
|
||||
|
||||
<EnvelopeSignerHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||
{/* Left Section - Step Navigation */}
|
||||
<div className="embed--DocumentWidgetContainer bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex">
|
||||
<div className="px-4">
|
||||
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.otherwise(() => null)}
|
||||
|
||||
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
|
||||
<Plural
|
||||
value={recipientFieldsRemaining.length}
|
||||
one="1 Field Remaining"
|
||||
other="# Fields Remaining"
|
||||
/>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="bg-muted relative my-4 h-[4px] rounded-md">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
style={{
|
||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
|
||||
<EnvelopeSignerForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
{/* Quick Actions. */}
|
||||
{!isDirectTemplate && (
|
||||
<div className="embed--Actions space-y-3 px-4">
|
||||
<h4 className="text-foreground text-sm font-semibold">
|
||||
<Trans>Actions</Trans>
|
||||
</h4>
|
||||
|
||||
<DocumentSigningAttachmentsPopover
|
||||
envelopeId={envelope.id}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<PaperclipIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Attachments</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download PDF</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
|
||||
<DocumentSigningRejectDialog
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
token={recipient.token}
|
||||
onRejected={
|
||||
onDocumentRejected &&
|
||||
((reason) =>
|
||||
onDocumentRejected({
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
reason,
|
||||
}))
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover:text-destructive w-full justify-start"
|
||||
>
|
||||
<BanIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="embed--DocumentWidgetFooter">
|
||||
{/* Footer of left sidebar. */}
|
||||
{!isEmbed && (
|
||||
<div className="mt-auto px-4">
|
||||
<Button asChild variant="ghost" className="w-full justify-start">
|
||||
<Link to="/">
|
||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
{envelopeItems.length > 1 && (
|
||||
<div className="flex h-fit space-x-2 overflow-x-auto p-2 pt-4 sm:p-4">
|
||||
{envelopeItems.map((doc, i) => (
|
||||
<EnvelopeItemSelector
|
||||
key={doc.id}
|
||||
number={i + 1}
|
||||
primaryText={doc.title}
|
||||
secondaryText={
|
||||
<Plural
|
||||
one="1 Field"
|
||||
other="# Fields"
|
||||
value={
|
||||
remainingFields.filter((field) => field.envelopeItemId === doc.id).length
|
||||
}
|
||||
/>
|
||||
}
|
||||
isSelected={currentEnvelopeItem?.id === doc.id}
|
||||
buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document View */}
|
||||
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||
{currentEnvelopeItem ? (
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="signing"
|
||||
key={currentEnvelopeItem.id}
|
||||
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<p className="text-foreground text-sm">
|
||||
<Trans>No documents found</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile widget - Additional padding to allow users to scroll */}
|
||||
<div className="block pb-28 lg:hidden">
|
||||
<DocumentSigningMobileWidget />
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<a
|
||||
href="https://documenso.com"
|
||||
target="_blank"
|
||||
className="bg-primary text-primary-foreground fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:block"
|
||||
>
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -10,7 +10,10 @@ export interface DocumentSigningRecipientContextValue {
|
||||
* In regular mode, this is the actual signer.
|
||||
* In assistant mode, this is the recipient who is helping fill out the document.
|
||||
*/
|
||||
recipient: Recipient | RecipientWithFields;
|
||||
recipient: Pick<
|
||||
Recipient | RecipientWithFields,
|
||||
'name' | 'email' | 'token' | 'role' | 'authOptions'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Only present in assistant mode.
|
||||
@ -29,7 +32,10 @@ const DocumentSigningRecipientContext = createContext<DocumentSigningRecipientCo
|
||||
);
|
||||
|
||||
export interface DocumentSigningRecipientProviderProps extends PropsWithChildren {
|
||||
recipient: Recipient | RecipientWithFields;
|
||||
recipient: Pick<
|
||||
Recipient | RecipientWithFields,
|
||||
'name' | 'email' | 'token' | 'role' | 'authOptions'
|
||||
>;
|
||||
targetSigner?: RecipientWithFields | null;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user