mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 179902a96f | |||
| 77889270b8 | |||
| ecc99b8e4f | |||
| 89c804a635 | |||
| c0ea4c60e4 | |||
| 2cb4cc29ea | |||
| d9b5f01e21 | |||
| bc3acba72c | |||
| 247a0158bd | |||
| 9e0b567686 | |||
| 8f6be474a9 | |||
| 8f5bdef384 | |||
| 999942014e | |||
| 194b2134cc | |||
| b8df02750b |
@@ -9,6 +9,10 @@ If you plan to contribute to Documenso, please take a moment to feel awesome ✨
|
||||
- Consider the results from the discussion on the issue
|
||||
- Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
|
||||
|
||||
## English only PRs and Issues
|
||||
|
||||
Please write all issues, pull requests, and related comments in English so maintainers and the wider contributor community can follow the discussion.
|
||||
|
||||
## Taking issues
|
||||
|
||||
Before taking an issue, ensure that:
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
·
|
||||
<a href="https://documenso.com">Website</a>
|
||||
·
|
||||
<a href="https://docs.documenso.com">Documentation</a>
|
||||
·
|
||||
<a href="https://github.com/documenso/documenso/issues">Issues</a>
|
||||
·
|
||||
<a href="https://documen.so/live">Upcoming Releases</a>
|
||||
@@ -146,45 +148,7 @@ npm run d
|
||||
|
||||
### Manual Setup
|
||||
|
||||
Follow these steps to setup Documenso on your local machine:
|
||||
|
||||
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
|
||||
|
||||
After forking the repository, clone it to your local device by using the following command:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/<your-username>/documenso
|
||||
```
|
||||
|
||||
2. Run `npm i` in the root directory
|
||||
|
||||
3. Create your `.env` from the `.env.example`. You can use `cp .env.example .env` to get started with our handpicked defaults.
|
||||
|
||||
4. Set the following environment variables:
|
||||
|
||||
- NEXTAUTH_SECRET
|
||||
- NEXT_PUBLIC_WEBAPP_URL
|
||||
- NEXT_PRIVATE_DATABASE_URL
|
||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||
- NEXT_PRIVATE_SMTP_FROM_ADDRESS
|
||||
|
||||
5. Create the database schema by running `npm run prisma:migrate-dev`
|
||||
|
||||
6. Run `npm run translate:compile` in the root directory to compile lingui
|
||||
|
||||
7. Run `npm run dev` in the root directory to start
|
||||
|
||||
8. Register a new user at http://localhost:3000/signup
|
||||
|
||||
---
|
||||
|
||||
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document.
|
||||
- Optional: Create your own signing certificate.
|
||||
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL), see **[Create your own signing certificate](./SIGNING.md)**.
|
||||
- Optional: Configure job provider for document reminders.
|
||||
- The default local job provider does not support scheduled jobs required for document reminders.
|
||||
- To enable reminders, set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and provide `NEXT_PRIVATE_INNGEST_EVENT_KEY` in your `.env` file.
|
||||
Follow the [manual setup guide](https://docs.documenso.com/docs/developers/local-development/manual) to configure Documenso on your local machine.
|
||||
|
||||
### Run in Gitpod
|
||||
|
||||
@@ -204,138 +168,44 @@ If you're a visual learner and prefer to watch a video walkthrough of setting up
|
||||
|
||||
## Docker
|
||||
|
||||
We provide a Docker container for Documenso, which is published on both DockerHub and GitHub Container Registry.
|
||||
We provide official Docker images on [DockerHub](https://hub.docker.com/r/documenso/documenso) and [GitHub Container Registry](https://ghcr.io/documenso/documenso).
|
||||
|
||||
- DockerHub: [https://hub.docker.com/r/documenso/documenso](https://hub.docker.com/r/documenso/documenso)
|
||||
- GitHub Container Registry: [https://ghcr.io/documenso/documenso](https://ghcr.io/documenso/documenso)
|
||||
|
||||
You can pull the Docker image from either of these registries and run it with your preferred container hosting provider.
|
||||
|
||||
Please note that you will need to provide environment variables for connecting to the database, mailserver, and so forth.
|
||||
|
||||
For detailed instructions on how to configure and run the Docker container, please refer to the [Docker README](./docker/README.md) in the `docker` directory.
|
||||
For setup instructions, see the [Docker Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker) and [Docker Compose](https://docs.documenso.com/docs/self-hosting/deployment/docker-compose) guides.
|
||||
|
||||
## Self Hosting
|
||||
|
||||
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
|
||||
We support a variety of deployment methods including Docker, Docker Compose, Railway, Kubernetes, and manual deployment.
|
||||
|
||||
### Fetch, configure, and build
|
||||
For full instructions, requirements, and configuration details, see the [Self Hosting documentation](https://docs.documenso.com/docs/self-hosting).
|
||||
|
||||
First, clone the code from Github:
|
||||
### One-Click Deploys
|
||||
|
||||
```
|
||||
git clone https://github.com/documenso/documenso.git
|
||||
```
|
||||
|
||||
Then, inside the `documenso` folder, copy the example env file:
|
||||
|
||||
```
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
The following environment variables must be set:
|
||||
|
||||
- `NEXTAUTH_SECRET`
|
||||
- `NEXT_PUBLIC_WEBAPP_URL`
|
||||
- `NEXT_PRIVATE_DATABASE_URL`
|
||||
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
|
||||
- `NEXT_PRIVATE_SMTP_FROM_NAME`
|
||||
- `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
|
||||
|
||||
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for the `NEXT_PUBLIC_WEBAPP_URL` variable!
|
||||
|
||||
Now you can install the dependencies and build it:
|
||||
|
||||
```
|
||||
npm i
|
||||
npm run build
|
||||
npm run prisma:migrate-deploy
|
||||
```
|
||||
|
||||
Finally, you can start it with:
|
||||
|
||||
```
|
||||
cd apps/remix
|
||||
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/remix` folder.
|
||||
|
||||
### Run as a service
|
||||
|
||||
You can use a systemd service file to run the app. Here is a simple example of the service running on port 3500 (using 3000 by default):
|
||||
|
||||
```bash
|
||||
[Unit]
|
||||
Description=documenso
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Environment=PATH=/path/to/your/node/binaries
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/documenso/apps/remix
|
||||
ExecStart=/usr/bin/next start -p 3500
|
||||
TimeoutSec=15
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Railway
|
||||
#### Railway
|
||||
|
||||
[](https://railway.app/template/bG6D4p)
|
||||
|
||||
### Render
|
||||
#### Render
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||
|
||||
### Koyeb
|
||||
#### Koyeb
|
||||
|
||||
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
||||
|
||||
## Elestio
|
||||
#### Elestio
|
||||
|
||||
[](https://elest.io/open-source/documenso)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
For troubleshooting self-hosted deployments, see the [Troubleshooting guide](https://docs.documenso.com/docs/self-hosting/maintenance/troubleshooting) and [Tips & Common Pitfalls](https://docs.documenso.com/docs/self-hosting/getting-started/tips).
|
||||
|
||||
### I'm not receiving any emails when using the developer quickstart.
|
||||
|
||||
When using the developer quickstart, an [Inbucket](https://inbucket.org/) server will be spun up in a docker container that will store all outgoing emails locally for you to view.
|
||||
|
||||
The Web UI can be found at http://localhost:9000, while the SMTP port will be on localhost:2500.
|
||||
|
||||
### 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 Remix start command
|
||||
|
||||
For local docker run
|
||||
|
||||
```bash
|
||||
docker run -it documenso:latest npm run start -- -H ::
|
||||
```
|
||||
|
||||
For k8s or docker-compose
|
||||
|
||||
```yaml
|
||||
containers:
|
||||
- name: documenso
|
||||
image: documenso:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- npm
|
||||
args:
|
||||
- run
|
||||
- start
|
||||
- --
|
||||
- -H
|
||||
- '::'
|
||||
```
|
||||
|
||||
### I can't see environment variables in my package scripts.
|
||||
|
||||
Wrap your package script with the `with:env` script like such:
|
||||
|
||||
@@ -73,14 +73,14 @@ Include the token in the `Authorization` header of your HTTP requests.
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl https://app.documenso.com/api/v2/documents \
|
||||
curl https://app.documenso.com/api/v2/document \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
### JavaScript / TypeScript
|
||||
|
||||
```typescript
|
||||
const response = await fetch('https://app.documenso.com/api/v2/documents', {
|
||||
const response = await fetch('https://app.documenso.com/api/v2/document', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||
|
||||
@@ -83,6 +83,15 @@ npm run prisma:seed -w @documenso/prisma
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Optional: configure job provider
|
||||
|
||||
The default local job provider does not support scheduled jobs required for document reminders.
|
||||
|
||||
See the [Background Jobs](/docs/self-hosting/configuration/background-jobs) page for more information.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Start the application
|
||||
|
||||
@@ -105,6 +114,20 @@ Access the Documenso application by visiting `http://localhost:3000` in your web
|
||||
certificate](/docs/developers/local-development/signing-certificate)**.
|
||||
</Callout>
|
||||
|
||||
## Running Scripts with Environment Variables
|
||||
|
||||
If a package script does not automatically load your `.env` and `.env.local` files, wrap it with the `with:env` script:
|
||||
|
||||
```bash
|
||||
npm run with:env -- npm run myscript
|
||||
```
|
||||
|
||||
The same works for `npx` when running bin scripts:
|
||||
|
||||
```bash
|
||||
npm run with:env -- npx myscript
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Developer Quickstart](/docs/developers/local-development/quickstart) - Quick Docker-based setup
|
||||
|
||||
@@ -26,8 +26,14 @@ docker --version
|
||||
|
||||
## Pulling the Docker Image
|
||||
|
||||
The Documenso image is available on both DockerHub and GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
# DockerHub
|
||||
docker pull documenso/documenso:latest
|
||||
|
||||
# GitHub Container Registry
|
||||
docker pull ghcr.io/documenso/documenso:latest
|
||||
```
|
||||
|
||||
### Available Tags
|
||||
@@ -196,6 +202,14 @@ Documenso provides health check endpoints for monitoring:
|
||||
| `/api/health` | Checks database connectivity and certificate status |
|
||||
| `/api/certificate-status` | Returns whether a signing certificate is configured and usable |
|
||||
|
||||
Both endpoints return a JSON response with a `status` field:
|
||||
|
||||
| Status | Meaning |
|
||||
| ----------- | -------------------------------------------------------------------- |
|
||||
| `"ok"` | Everything is working properly |
|
||||
| `"warning"` | Application is running but there are certificate issues |
|
||||
| `"error"` | Critical issues (database unreachable, missing configuration, etc.) |
|
||||
|
||||
### Docker Health Check
|
||||
|
||||
Add a health check to your container:
|
||||
|
||||
@@ -3,6 +3,8 @@ title: Getting Started
|
||||
description: Requirements and quick start guide for self-hosting Documenso.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
title="Requirements"
|
||||
@@ -15,3 +17,11 @@ description: Requirements and quick start guide for self-hosting Documenso.
|
||||
href="/docs/self-hosting/getting-started/quick-start"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
<Callout type="error">
|
||||
**You must generate a signing certificate.** Documenso does not ship with one. Without a
|
||||
certificate, the application starts normally but document signing will fail on completion with
|
||||
errors.
|
||||
|
||||
Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding.
|
||||
</Callout>
|
||||
|
||||
@@ -7,14 +7,29 @@ import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
## What You Need
|
||||
|
||||
Documenso requires the following external services:
|
||||
Documenso requires the following items and external services:
|
||||
|
||||
| Service | Purpose | Minimum Version |
|
||||
| ------------- | ---------------------------- | --------------- |
|
||||
| Signing certificate | Digital signature for documents | N/A |
|
||||
| PostgreSQL | Primary database | 14+ |
|
||||
| SMTP server | Sending emails to recipients | Any |
|
||||
| Reverse proxy | SSL termination, routing | Any |
|
||||
|
||||
|
||||
### Signing Certificate
|
||||
|
||||
<Callout type="error">
|
||||
Documenso does not ship with a signing certificate. Without one, the application starts normally
|
||||
but all document signing will fail. You must generate or provide a `.p12` certificate before going
|
||||
to production.
|
||||
</Callout>
|
||||
|
||||
Every completed document is digitally signed using an X.509 certificate. You can generate a self-signed certificate for free or use one from a Certificate Authority (CA).
|
||||
|
||||
- [Generate a local certificate](/docs/self-hosting/configuration/signing-certificate/local) — step-by-step instructions to create a `.p12` certificate
|
||||
- [All certificate options](/docs/self-hosting/configuration/signing-certificate) — self-signed, CA-issued, and Google Cloud HSM
|
||||
|
||||
### PostgreSQL Database
|
||||
|
||||
Documenso uses PostgreSQL for all data storage including documents, users, and audit logs. You cannot use MySQL, SQLite, or other databases.
|
||||
|
||||
@@ -154,6 +154,34 @@ See [Background Jobs Configuration](/docs/self-hosting/configuration/background-
|
||||
|
||||
---
|
||||
|
||||
## IPv6-Only Deployments
|
||||
|
||||
If you are deploying to an environment that uses only IPv6, set the `HOST` environment variable to `::` so the application binds to all IPv6 addresses:
|
||||
|
||||
**Docker:**
|
||||
|
||||
```bash
|
||||
docker run -it -e HOST=:: documenso/documenso:latest npm run start
|
||||
```
|
||||
|
||||
**Kubernetes or Docker Compose:**
|
||||
|
||||
```yaml
|
||||
containers:
|
||||
- name: documenso
|
||||
image: documenso/documenso:latest
|
||||
command:
|
||||
- npm
|
||||
args:
|
||||
- run
|
||||
- start
|
||||
env:
|
||||
- name: HOST
|
||||
value: '::'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker File Permissions
|
||||
|
||||
The Documenso container runs as a non-root user (UID 1001). If you mount files into the container (certificates, configuration), ensure they're readable:
|
||||
|
||||
@@ -3,6 +3,8 @@ title: Self-Hosting
|
||||
description: Deploy and manage your own Documenso instance for complete control over your data, compliance, and customization.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
## Getting Started
|
||||
|
||||
<Cards>
|
||||
@@ -18,6 +20,13 @@ description: Deploy and manage your own Documenso instance for complete control
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
<Callout type="error">
|
||||
**You must generate a signing certificate.** Documenso does not ship with one. Without a
|
||||
certificate, the application starts normally but document signing will fail.
|
||||
|
||||
Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Deployment Options
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"fumadocs-ui": "16.5.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"next": "16.2.4",
|
||||
"next": "16.2.6",
|
||||
"next-plausible": "^3.12.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
@@ -29,7 +29,7 @@
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "16.2.4"
|
||||
"next": "16.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateUserRequestSchema } from '@documenso/trpc/server/admin-router/create-user.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 { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export type AdminUserCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZFormSchema = ZCreateUserRequestSchema;
|
||||
|
||||
type TFormSchema = z.infer<typeof ZFormSchema>;
|
||||
|
||||
export const AdminUserCreateDialog = ({ trigger, ...props }: AdminUserCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<TFormSchema>({
|
||||
resolver: zodResolver(ZFormSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createUser } = trpc.admin.user.create.useMutation();
|
||||
|
||||
const onFormSubmit = async (data: TFormSchema) => {
|
||||
try {
|
||||
const result = await createUser(data);
|
||||
|
||||
await navigate(`/admin/users/${result.userId}`);
|
||||
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`User created and welcome email sent`,
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`An error occurred`,
|
||||
description: error.message || t`We encountered an error while creating the user. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
<Trans>Create User</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create User</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Create a new user. A welcome email will be sent with a link to set their password.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="email" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" data-testid="dialog-create-user-button" loading={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -89,7 +89,6 @@ export const SignInForm = ({
|
||||
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
const twoFactorTurnstileRef = useRef<TurnstileInstance>(null);
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
|
||||
@@ -197,13 +196,31 @@ export const SignInForm = ({
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
||||
const $turnstile = isTwoFactorAuthenticationDialogOpen ? twoFactorTurnstileRef.current : turnstileRef.current;
|
||||
|
||||
try {
|
||||
let token: string | undefined;
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
token = await $turnstile?.getResponsePromise(3000).catch((_err) => undefined);
|
||||
|
||||
if (!token) {
|
||||
toast({
|
||||
title: _(msg`Human verification required`),
|
||||
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await authClient.emailPassword.signIn({
|
||||
email,
|
||||
password,
|
||||
totpCode,
|
||||
backupCode,
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
captchaToken: token ?? undefined,
|
||||
redirectPath,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -214,10 +231,6 @@ export const SignInForm = ({
|
||||
if (error.code === 'TWO_FACTOR_MISSING_CREDENTIALS') {
|
||||
setIsTwoFactorAuthenticationDialogOpen(true);
|
||||
|
||||
// Turnstile tokens are single-use. Clear the consumed one so the
|
||||
// dialog's fresh widget mounts cleanly and the dialog can't be
|
||||
// submitted with the stale token before a new one is issued.
|
||||
setCaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -247,8 +260,7 @@ export const SignInForm = ({
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken(null);
|
||||
$turnstile?.reset();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -358,8 +370,6 @@ export const SignInForm = ({
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
appearance: 'interaction-only',
|
||||
@@ -499,8 +509,6 @@ export const SignInForm = ({
|
||||
<Turnstile
|
||||
ref={twoFactorTurnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
appearance: 'interaction-only',
|
||||
@@ -518,7 +526,7 @@ export const SignInForm = ({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isSubmitting} disabled={Boolean(turnstileSiteKey) && !captchaToken}>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FaIdCardClip } from 'react-icons/fa6';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
@@ -86,8 +86,6 @@ export const SignUpForm = ({
|
||||
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled;
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
@@ -105,12 +103,28 @@ export const SignUpForm = ({
|
||||
|
||||
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
|
||||
try {
|
||||
let token: string | undefined;
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
token = await turnstileRef.current?.getResponsePromise(3000).catch((_err) => undefined);
|
||||
|
||||
if (!token) {
|
||||
toast({
|
||||
title: _(msg`Human verification required`),
|
||||
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await authClient.emailPassword.signUp({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
signature,
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
captchaToken: token ?? undefined,
|
||||
});
|
||||
|
||||
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||
@@ -140,7 +154,6 @@ export const SignUpForm = ({
|
||||
});
|
||||
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -316,8 +329,6 @@ export const SignUpForm = ({
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
appearance: 'interaction-only',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -12,6 +13,9 @@ 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 { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
@@ -50,6 +54,9 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
|
||||
const analytics = useAnalytics();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
|
||||
const form = useForm<TClaimAccountFormSchema>({
|
||||
values: {
|
||||
name: defaultName ?? '',
|
||||
@@ -61,7 +68,28 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
|
||||
|
||||
const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
|
||||
try {
|
||||
await authClient.emailPassword.signUp({ name, email, password });
|
||||
let token: string | undefined;
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
token = await turnstileRef.current?.getResponsePromise(3000).catch((_err) => undefined);
|
||||
|
||||
if (!token) {
|
||||
toast({
|
||||
title: _(msg`Human verification required`),
|
||||
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await authClient.emailPassword.signUp({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
captchaToken: token ?? undefined,
|
||||
});
|
||||
|
||||
await navigate(`/unverified-account`);
|
||||
|
||||
@@ -87,6 +115,8 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
turnstileRef.current?.reset();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,6 +171,19 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
|
||||
)}
|
||||
/>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div className="mt-4">
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
appearance: 'interaction-only',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
|
||||
<Trans>Claim account</Trans>
|
||||
</Button>
|
||||
|
||||
+41
-1
@@ -41,6 +41,7 @@ import { TemplateTypeSelect, TemplateTypeTooltip } from '@documenso/ui/component
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -50,7 +51,15 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
@@ -93,6 +102,7 @@ export const ZAddSettingsFormSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.default(DEFAULT_DOCUMENT_TIME_ZONE),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.default(DEFAULT_DOCUMENT_DATE_FORMAT),
|
||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional().default(DocumentDistributionMethod.EMAIL),
|
||||
includeAuditLog: z.boolean().default(false),
|
||||
redirectUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -181,6 +191,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
dateFormat: (envelope.documentMeta.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT) as TDocumentMetaDateFormat,
|
||||
distributionMethod: envelope.documentMeta.distributionMethod || DocumentDistributionMethod.EMAIL,
|
||||
includeAuditLog: envelope.documentMeta.includeAuditLog,
|
||||
redirectUrl: envelope.documentMeta.redirectUrl ?? '',
|
||||
language: envelope.documentMeta.language ?? 'en',
|
||||
emailId: envelope.documentMeta.emailId ?? null,
|
||||
@@ -236,6 +247,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
emailReplyTo,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
includeAuditLog,
|
||||
} = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z.array(ZDocumentAccessAuthTypesSchema).safeParse(data.globalAccessAuth);
|
||||
@@ -265,6 +277,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
includeAuditLog,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -892,6 +905,33 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.includeAuditLog"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="include-audit-log"
|
||||
checked={field.value}
|
||||
disabled={field.disabled}
|
||||
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormLabel className="ml-2 text-muted-foreground text-sm" htmlFor="include-audit-log">
|
||||
<Trans>Include audit logs in signed PDF</Trans>
|
||||
</FormLabel>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Audit logs can still be downloaded separately from the document logs page.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isEmbedded && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -25,7 +25,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="mb-2 flex items-end font-medium text-primary-forground text-sm leading-tight">{title}</h3>
|
||||
<h3 className="mb-2 flex items-end font-medium text-sm leading-tight">{title}</h3>
|
||||
</div>
|
||||
|
||||
{children || (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { AdminUserCreateDialog } from '~/components/dialogs/admin-user-create-dialog';
|
||||
import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table';
|
||||
|
||||
import type { Route } from './+types/users._index';
|
||||
@@ -27,9 +28,13 @@ export default function AdminManageUsersPage({ loaderData }: Route.ComponentProp
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Manage users</Trans>
|
||||
</h2>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Manage users</Trans>
|
||||
</h2>
|
||||
|
||||
<AdminUserCreateDialog />
|
||||
</div>
|
||||
|
||||
<AdminDashboardUsersTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
|
||||
</div>
|
||||
|
||||
+5
-258
@@ -1,261 +1,8 @@
|
||||
# Docker Setup for Documenso
|
||||
|
||||
The following guide will walk you through setting up Documenso using Docker. You can choose between a production setup using Docker Compose or a standalone container.
|
||||
For full instructions on running Documenso with Docker, see the official documentation:
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure that you have the following installed:
|
||||
|
||||
- Docker
|
||||
- Docker Compose (if using the Docker Compose setup)
|
||||
|
||||
## Option 1: Production Docker Compose Setup
|
||||
|
||||
This setup includes a PostgreSQL database and the Documenso application. You will need to provide your own SMTP details via environment variables.
|
||||
|
||||
1. Download the Docker Compose file from the Documenso repository: [compose.yml](https://raw.githubusercontent.com/documenso/documenso/release/docker/production/compose.yml)
|
||||
2. Navigate to the directory containing the `compose.yml` file.
|
||||
3. Create a `.env` file in the same directory and add your SMTP details as well as a few extra environment variables, following the example below:
|
||||
|
||||
```
|
||||
# Generate random secrets (you can use: openssl rand -hex 32)
|
||||
NEXTAUTH_SECRET="<your-secret>"
|
||||
NEXT_PRIVATE_ENCRYPTION_KEY="<your-key>"
|
||||
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-secondary-key>"
|
||||
|
||||
# Your application URL
|
||||
NEXT_PUBLIC_WEBAPP_URL="<your-url>"
|
||||
|
||||
# SMTP Configuration
|
||||
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
||||
NEXT_PRIVATE_SMTP_HOST="<your-host>"
|
||||
NEXT_PRIVATE_SMTP_PORT=<your-port>
|
||||
NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME="<your-from-name>"
|
||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-from-email>"
|
||||
|
||||
# Certificate passphrase (required)
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
|
||||
```
|
||||
|
||||
4. Set up your signing certificate. You have three options:
|
||||
|
||||
**Option A: Generate Certificate Inside Container (Recommended)**
|
||||
|
||||
Start your containers first, then generate a self-signed certificate:
|
||||
|
||||
```bash
|
||||
# Start containers
|
||||
docker-compose up -d
|
||||
|
||||
# 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
|
||||
"
|
||||
|
||||
# Restart container
|
||||
docker-compose restart documenso
|
||||
```
|
||||
|
||||
**Option B: Use Existing Certificate**
|
||||
|
||||
If you have an existing `.p12` certificate, update the volume binding in `compose.yml`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
|
||||
```
|
||||
|
||||
5. Run the following command to start the containers:
|
||||
|
||||
```
|
||||
docker-compose --env-file ./.env up -d
|
||||
```
|
||||
|
||||
This will start the PostgreSQL database and the Documenso application containers.
|
||||
|
||||
6. Access the Documenso application by visiting `http://localhost:3000` in your web browser.
|
||||
|
||||
## Option 2: Standalone Docker Container
|
||||
|
||||
If you prefer to host the Documenso application on your container provider of choice, you can use the pre-built Docker image from DockerHub or GitHub's Package Registry. Note that you will need to provide your own database and SMTP host.
|
||||
|
||||
1. Pull the Documenso Docker image:
|
||||
|
||||
```
|
||||
docker pull documenso/documenso
|
||||
```
|
||||
|
||||
Or, if using GitHub's Package Registry:
|
||||
|
||||
```
|
||||
docker pull ghcr.io/documenso/documenso
|
||||
```
|
||||
|
||||
2. Run the Docker container, providing the necessary environment variables for your database and SMTP host:
|
||||
|
||||
```
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e NEXTAUTH_SECRET="<your-nextauth-secret>" \
|
||||
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>" \
|
||||
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>" \
|
||||
-e NEXT_PUBLIC_WEBAPP_URL="<your-next-public-webapp-url>" \
|
||||
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
|
||||
-e NEXT_PRIVATE_DATABASE_URL="<your-next-private-database-url>" \
|
||||
-e NEXT_PRIVATE_DIRECT_DATABASE_URL="<your-next-private-database-url>" \
|
||||
-e NEXT_PRIVATE_SMTP_TRANSPORT="<your-next-private-smtp-transport>" \
|
||||
-e NEXT_PRIVATE_SMTP_FROM_NAME="<your-next-private-smtp-from-name>" \
|
||||
-e NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-next-private-smtp-from-address>" \
|
||||
-e NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>" \
|
||||
-v /path/to/your/cert.p12:/opt/documenso/cert.p12:ro \
|
||||
documenso/documenso
|
||||
```
|
||||
|
||||
Replace the placeholders with your actual database and SMTP details.
|
||||
|
||||
3. Access the Documenso application by visiting the URL you provided in the `NEXT_PUBLIC_WEBAPP_URL` environment variable in your web browser.
|
||||
|
||||
## Success
|
||||
|
||||
You have now successfully set up Documenso using Docker. You can start organizing and managing your documents efficiently.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate Permission Issues
|
||||
|
||||
If you encounter errors related to certificate access, here are common solutions:
|
||||
|
||||
#### Error: "Failed to read signing certificate"
|
||||
|
||||
1. **Check file exists:**
|
||||
|
||||
```bash
|
||||
ls -la /path/to/your/cert.p12
|
||||
```
|
||||
|
||||
2. **Fix permissions:**
|
||||
|
||||
```bash
|
||||
chmod 644 /path/to/your/cert.p12
|
||||
chown 1001:1001 /path/to/your/cert.p12
|
||||
```
|
||||
|
||||
3. **Verify Docker mount:**
|
||||
```bash
|
||||
docker exec -it <container_name> ls -la /opt/documenso/cert.p12
|
||||
```
|
||||
|
||||
### Container Logs
|
||||
|
||||
Check application logs for detailed error information:
|
||||
|
||||
```bash
|
||||
# For Docker Compose
|
||||
docker-compose logs -f documenso
|
||||
|
||||
# For standalone container
|
||||
docker logs -f <container_name>
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
Check the status of your Documenso instance:
|
||||
|
||||
```bash
|
||||
# Basic health check (database + certificate)
|
||||
curl http://localhost:3000/api/health
|
||||
|
||||
# Detailed certificate status
|
||||
curl http://localhost:3000/api/certificate-status
|
||||
```
|
||||
|
||||
The health endpoint will show:
|
||||
|
||||
- `status: "ok"` - Everything working properly
|
||||
- `status: "warning"` - App running but certificate issues
|
||||
- `status: "error"` - Critical issues (database down, etc.)
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Port already in use:** Change the port mapping in compose.yml or your docker run command
|
||||
2. **Database connection issues:** Ensure your database is running and accessible
|
||||
3. **SMTP errors:** Verify your email server settings in the .env file
|
||||
|
||||
If you encounter any issues or have further questions, please refer to the official Documenso documentation or seek assistance from the community.
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
The environment variables listed above are a subset of those that are available for configuring Documenso. For a complete list of environment variables and their descriptions, refer to the table below:
|
||||
|
||||
Here's a markdown table documenting all the provided environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | The port to run the Documenso application on, defaults to `3000`. |
|
||||
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
||||
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
||||
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
|
||||
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded Google Cloud HSM public certificate for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded certificate chain for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing (enables LTV). |
|
||||
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. |
|
||||
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
|
||||
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
|
||||
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
||||
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch. Set to `true` to disable all signup methods (incl. organisation OIDC portal). |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Set to `true` to disable email/password signup only. SSO signup is unaffected. |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Set to `true` to block new accounts via Google. Existing Google-linked users can still sign in. |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Set to `true` to block new accounts via Microsoft. Existing linked users can still sign in. |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Set to `true` to block new accounts via OIDC (incl. organisation portal). Existing users unaffected.|
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`). |
|
||||
- [Docker Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker) — Standalone container with an external database
|
||||
- [Docker Compose Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker-compose) — Production setup with PostgreSQL included
|
||||
- [Environment Variables](https://docs.documenso.com/docs/self-hosting/configuration/environment) — Full configuration reference
|
||||
- [Signing Certificate](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate) — Set up document signing
|
||||
|
||||
Generated
+1341
-1291
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -88,7 +88,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.3.3",
|
||||
"@libpdf/core": "^0.3.6",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
/**
|
||||
* Fill in the create-user dialog and submit it.
|
||||
* Assumes the dialog trigger is already visible on the page.
|
||||
*/
|
||||
const submitCreateUserDialog = async ({
|
||||
page,
|
||||
email,
|
||||
name,
|
||||
}: {
|
||||
page: import('@playwright/test').Page;
|
||||
email: string;
|
||||
name: string;
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Create User' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByLabel('Email').fill(email);
|
||||
await dialog.getByLabel('Name').fill(name);
|
||||
|
||||
await dialog.getByTestId('dialog-create-user-button').click();
|
||||
};
|
||||
|
||||
// ─── Happy path ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][CREATE_USER]: admin can create a new user via the dialog', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const newUserEmail = seedTestEmail();
|
||||
const newUserName = 'New Created User';
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/users',
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Manage users' })).toBeVisible();
|
||||
|
||||
await submitCreateUserDialog({ page, email: newUserEmail, name: newUserName });
|
||||
|
||||
// After success the dialog closes and we navigate to /admin/users/:id.
|
||||
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
|
||||
|
||||
// The user-detail page renders the user's name in the heading.
|
||||
await expect(page.getByRole('heading', { name: `Manage ${newUserName}'s profile` })).toBeVisible();
|
||||
|
||||
// The user exists in the database.
|
||||
const created = await prisma.user.findUnique({
|
||||
where: { email: newUserEmail.toLowerCase() },
|
||||
});
|
||||
|
||||
expect(created).not.toBeNull();
|
||||
expect(created?.name).toBe(newUserName);
|
||||
});
|
||||
|
||||
// ─── emailVerified is set + password is null for admin-created users ────────
|
||||
|
||||
test('[ADMIN][CREATE_USER]: a newly created user has emailVerified set and no password', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const newUserEmail = seedTestEmail();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/users',
|
||||
});
|
||||
|
||||
await submitCreateUserDialog({
|
||||
page,
|
||||
email: newUserEmail,
|
||||
name: 'Pending Password User',
|
||||
});
|
||||
|
||||
// Wait for redirect to confirm the request finished.
|
||||
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
|
||||
|
||||
// Admin-created users start with:
|
||||
// - emailVerified set (the admin vouches for the email)
|
||||
// - password null (user must set it via the welcome email reset link)
|
||||
// The "password=null" state hard-blocks login at email-password.ts:101,
|
||||
// forcing the user through the reset-link flow before they can sign in.
|
||||
const created = await prisma.user.findUnique({
|
||||
where: { email: newUserEmail.toLowerCase() },
|
||||
select: { id: true, emailVerified: true, password: true },
|
||||
});
|
||||
|
||||
expect(created, 'user should exist in the database').not.toBeNull();
|
||||
expect(
|
||||
created?.emailVerified,
|
||||
'admin-created user should have emailVerified set — admin vouches for the email',
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
created?.password,
|
||||
'admin-created user must have password=null — they must set one via the welcome reset link',
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Welcome email side effect: a PasswordResetToken is issued ───────────────
|
||||
|
||||
test('[ADMIN][CREATE_USER]: creating a user issues a PasswordResetToken valid for ~24 hours', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const newUserEmail = seedTestEmail();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/users',
|
||||
});
|
||||
|
||||
const beforeCreation = Date.now();
|
||||
|
||||
await submitCreateUserDialog({
|
||||
page,
|
||||
email: newUserEmail,
|
||||
name: 'Token Recipient',
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
|
||||
|
||||
const created = await prisma.user.findUniqueOrThrow({
|
||||
where: { email: newUserEmail.toLowerCase() },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// The PasswordResetToken is created by an async background job
|
||||
// (send.admin.user.created.email), so poll until it shows up.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const found = await prisma.passwordResetToken.findFirst({
|
||||
where: { userId: created.id },
|
||||
});
|
||||
return found === null ? null : 'found';
|
||||
},
|
||||
{
|
||||
message: `PasswordResetToken for user ${created.id} was not created by the welcome-email job in time`,
|
||||
timeout: 30_000,
|
||||
intervals: [250, 500, 1000],
|
||||
},
|
||||
)
|
||||
.toBe('found');
|
||||
|
||||
// Now that we know it exists, fetch it with strict types.
|
||||
const token = await prisma.passwordResetToken.findFirstOrThrow({
|
||||
where: { userId: created.id },
|
||||
});
|
||||
|
||||
// Token should be ~24h in the future (allow a generous fudge window).
|
||||
const expiry = token.expiry.getTime();
|
||||
const expectedExpiry = beforeCreation + 24 * 60 * 60 * 1000;
|
||||
const driftMs = Math.abs(expiry - expectedExpiry);
|
||||
|
||||
// Allow up to 5 minutes of drift (test setup, db round-trips, clock skew,
|
||||
// plus job scheduling delay).
|
||||
expect(driftMs, `token expiry should be ~24h from now, drift was ${driftMs}ms`).toBeLessThan(5 * 60 * 1000);
|
||||
|
||||
// The token value should be a non-trivial hex string.
|
||||
expect(token.token.length).toBeGreaterThanOrEqual(32);
|
||||
expect(token.token).toMatch(/^[a-f0-9]+$/);
|
||||
});
|
||||
|
||||
// ─── Duplicate email is rejected ─────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][CREATE_USER]: creating a user with an email that already exists is rejected', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
// Seed an existing user whose email we'll collide with.
|
||||
const { user: existingUser } = await seedUser({ isPersonalOrganisation: true });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/users',
|
||||
});
|
||||
|
||||
await submitCreateUserDialog({
|
||||
page,
|
||||
email: existingUser.email,
|
||||
name: 'Collision Attempt',
|
||||
});
|
||||
|
||||
// The dialog should stay open OR an error toast should surface. Either way
|
||||
// we must NOT navigate to a new user detail page.
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/);
|
||||
|
||||
// The existing user record must not have been mutated by the attempt.
|
||||
const stillExisting = await prisma.user.findUnique({
|
||||
where: { email: existingUser.email },
|
||||
select: { id: true, name: true, emailVerified: true },
|
||||
});
|
||||
|
||||
expect(stillExisting?.id).toBe(existingUser.id);
|
||||
expect(stillExisting?.name).toBe(existingUser.name);
|
||||
// The seeded user was verified — make sure the failed create didn't
|
||||
// somehow flip the flag.
|
||||
expect(stillExisting?.emailVerified).not.toBeNull();
|
||||
|
||||
// Count of users with this email must still be 1.
|
||||
const matching = await prisma.user.count({
|
||||
where: { email: existingUser.email },
|
||||
});
|
||||
expect(matching).toBe(1);
|
||||
});
|
||||
|
||||
// ─── Validation: empty form ──────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][CREATE_USER]: submitting an empty form shows validation errors and does not create a user', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/users',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Create User' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Submit without filling anything.
|
||||
await dialog.getByTestId('dialog-create-user-button').click();
|
||||
|
||||
// Validation errors are surfaced for both required fields. Their presence
|
||||
// proves react-hook-form's zodResolver blocked the submit before the
|
||||
// mutation ran, so no DB write could have happened.
|
||||
await expect(dialog.getByLabel('Email')).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(dialog.getByLabel('Name')).toHaveAttribute('aria-invalid', 'true');
|
||||
|
||||
// Dialog stays open and we must not have navigated to a user detail page.
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/);
|
||||
});
|
||||
|
||||
// ─── Validation: malformed email ─────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][CREATE_USER]: a malformed email is rejected client-side', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/users',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Create User' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const emailInput = dialog.getByLabel('Email');
|
||||
|
||||
await emailInput.fill('not-an-email');
|
||||
await dialog.getByLabel('Name').fill('Some Name');
|
||||
|
||||
// The Email input is rendered with type="email" and the form does not set
|
||||
// noValidate, so the browser's native HTML5 constraint validation rejects
|
||||
// the malformed value and blocks the submit event from ever firing. (As a
|
||||
// result react-hook-form's zodResolver never runs and `aria-invalid` is
|
||||
// not flipped to true — the browser is the layer doing the rejection.) We
|
||||
// assert directly on the input's ValidityState to prove the value is
|
||||
// recognised as invalid client-side.
|
||||
await expect(emailInput).toHaveJSProperty('validity.valid', false);
|
||||
|
||||
await dialog.getByTestId('dialog-create-user-button').click();
|
||||
|
||||
// Dialog stays open and we must not have navigated.
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/);
|
||||
|
||||
// The bogus email is definitely not present in the DB — a targeted check
|
||||
// on a specific row, not a global count, so it's safe to run in parallel.
|
||||
const bogus = await prisma.user.findFirst({
|
||||
where: { email: 'not-an-email' },
|
||||
});
|
||||
expect(bogus).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Cancel button closes dialog without creating ───────────────────────────
|
||||
|
||||
test('[ADMIN][CREATE_USER]: clicking Cancel closes the dialog and does not create a user', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/users',
|
||||
});
|
||||
|
||||
const newUserEmail = seedTestEmail();
|
||||
|
||||
await page.getByRole('button', { name: 'Create User' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Fill in valid data but cancel anyway.
|
||||
await dialog.getByLabel('Email').fill(newUserEmail);
|
||||
await dialog.getByLabel('Name').fill('Cancelled User');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// No user was created with that email.
|
||||
const created = await prisma.user.findUnique({
|
||||
where: { email: newUserEmail.toLowerCase() },
|
||||
});
|
||||
expect(created).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Email is lowercased when stored ─────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][CREATE_USER]: email entered with mixed case is normalised to lowercase', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
// Build a known mixed-case email.
|
||||
const rawEmail = seedTestEmail();
|
||||
const mixedCaseEmail = rawEmail.replace(/^./, (c) => c.toUpperCase());
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/users',
|
||||
});
|
||||
|
||||
await submitCreateUserDialog({
|
||||
page,
|
||||
email: mixedCaseEmail,
|
||||
name: 'Mixed Case Email User',
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
|
||||
|
||||
// Look up by lowercased form — that's the canonical storage.
|
||||
const created = await prisma.user.findUnique({
|
||||
where: { email: rawEmail.toLowerCase() },
|
||||
select: { id: true, email: true, emailVerified: true },
|
||||
});
|
||||
|
||||
expect(created).not.toBeNull();
|
||||
expect(created?.email).toBe(rawEmail.toLowerCase());
|
||||
// Verified — admin vouches for the email. Case normalisation must not
|
||||
// affect verification state.
|
||||
expect(created?.emailVerified).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Access control: non-admin cannot see the Create User affordance ────────
|
||||
|
||||
test('[ADMIN][CREATE_USER]: non-admin user redirected away from /admin/users and cannot see Create User button', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: nonAdminUser.email,
|
||||
redirectPath: '/admin/users',
|
||||
});
|
||||
|
||||
// Non-admins are redirected away from /admin/*; the admin heading must not
|
||||
// be visible.
|
||||
await expect(page.getByRole('heading', { name: 'Manage users' })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Create User' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN][CREATE_USER]: unauthenticated user cannot access /admin/users', async ({ page }) => {
|
||||
// No apiSignin — just navigate directly.
|
||||
await page.goto('/admin/users');
|
||||
|
||||
await expect(page).not.toHaveURL(/\/admin\/users$/);
|
||||
await expect(page.getByRole('button', { name: 'Create User' })).not.toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { apiCreateTestContext, apiSeedDraftDocument, apiSeedPendingDocument } from '../fixtures/api-seeds';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
type TeamDocumentSettings = {
|
||||
includeAuditLog?: boolean;
|
||||
includeSigningCertificate?: boolean;
|
||||
};
|
||||
|
||||
const updateTeamDocumentSettings = async (teamId: number, data: TeamDocumentSettings) => {
|
||||
const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({
|
||||
where: {
|
||||
team: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.teamGlobalSettings.update({
|
||||
where: {
|
||||
id: teamSettings.id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
const getFirstEnvelopeItemSignedPageCount = async (envelopeId: string, token: string) => {
|
||||
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem,
|
||||
token,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const response = await fetch(documentUrl);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const pdfData = await response.arrayBuffer();
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdfData));
|
||||
|
||||
return pdfDoc.getPageCount();
|
||||
};
|
||||
|
||||
const completeSigning = async ({
|
||||
page,
|
||||
signingUrl,
|
||||
recipientToken,
|
||||
fieldId,
|
||||
}: {
|
||||
page: Page;
|
||||
signingUrl: string;
|
||||
recipientToken: string;
|
||||
fieldId: number;
|
||||
}) => {
|
||||
await page.goto(signingUrl);
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
await page.locator(`#field-${fieldId}`).getByRole('button').click();
|
||||
await expect(page.locator(`#field-${fieldId}`)).toHaveAttribute('data-inserted', 'true');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click({ force: true });
|
||||
await page.waitForURL(/\/complete$/);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await getDocumentByToken({
|
||||
token: recipientToken,
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass();
|
||||
};
|
||||
|
||||
test.describe('Document audit log embedding', () => {
|
||||
test('new documents derive audit-log embedding from team settings', async ({ request }) => {
|
||||
const context = await apiCreateTestContext('e2e-audit-log-default');
|
||||
|
||||
await updateTeamDocumentSettings(context.team.id, {
|
||||
includeAuditLog: true,
|
||||
});
|
||||
|
||||
const { envelope } = await apiSeedDraftDocument(request, {
|
||||
context,
|
||||
});
|
||||
|
||||
expect(envelope.documentMeta.includeAuditLog).toBe(true);
|
||||
|
||||
await updateTeamDocumentSettings(context.team.id, {
|
||||
includeAuditLog: false,
|
||||
});
|
||||
|
||||
const documentMeta = await prisma.documentMeta.findFirstOrThrow({
|
||||
where: {
|
||||
id: envelope.documentMeta.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(documentMeta.includeAuditLog).toBe(true);
|
||||
});
|
||||
|
||||
test('meta.includeAuditLog true appends audit-log pages when team default is false', async ({ page, request }) => {
|
||||
const context = await apiCreateTestContext('e2e-audit-log-override-true');
|
||||
|
||||
await updateTeamDocumentSettings(context.team.id, {
|
||||
includeAuditLog: false,
|
||||
includeSigningCertificate: false,
|
||||
});
|
||||
|
||||
const { distributeResult, envelope } = await apiSeedPendingDocument(request, {
|
||||
context,
|
||||
meta: {
|
||||
includeAuditLog: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.documentMeta.includeAuditLog).toBe(true);
|
||||
|
||||
const signer = distributeResult.recipients[0];
|
||||
const signatureField = envelope.fields.find((field) => field.recipientId === signer.id);
|
||||
|
||||
if (!signatureField) {
|
||||
throw new Error('Signature field not found');
|
||||
}
|
||||
|
||||
const baselinePageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
|
||||
|
||||
await completeSigning({
|
||||
page,
|
||||
signingUrl: signer.signingUrl,
|
||||
recipientToken: signer.token,
|
||||
fieldId: signatureField.id,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
const signedPageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
|
||||
|
||||
expect(signedPageCount).toBe(baselinePageCount + 1);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('meta.includeAuditLog false skips embedded audit logs when team default is true', async ({ page, request }) => {
|
||||
const context = await apiCreateTestContext('e2e-audit-log-override-false');
|
||||
|
||||
await updateTeamDocumentSettings(context.team.id, {
|
||||
includeAuditLog: true,
|
||||
includeSigningCertificate: false,
|
||||
});
|
||||
|
||||
const { distributeResult, envelope, team, user } = await apiSeedPendingDocument(request, {
|
||||
context,
|
||||
meta: {
|
||||
includeAuditLog: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.documentMeta.includeAuditLog).toBe(false);
|
||||
|
||||
const signer = distributeResult.recipients[0];
|
||||
const signatureField = envelope.fields.find((field) => field.recipientId === signer.id);
|
||||
|
||||
if (!signatureField) {
|
||||
throw new Error('Signature field not found');
|
||||
}
|
||||
|
||||
const baselinePageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
|
||||
|
||||
await completeSigning({
|
||||
page,
|
||||
signingUrl: signer.signingUrl,
|
||||
recipientToken: signer.token,
|
||||
fieldId: signatureField.id,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
const signedPageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
|
||||
|
||||
expect(signedPageCount).toBe(baselinePageCount);
|
||||
}).toPass();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${envelope.id}/logs`,
|
||||
});
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
|
||||
await page.getByRole('button', { name: 'Download Audit Logs' }).click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toContain('Audit Logs.pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Button, Link, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export type TemplateAdminUserCreatedProps = {
|
||||
resetPasswordLink: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: TemplateAdminUserCreatedProps) => {
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
|
||||
<Trans>Welcome to Documenso!</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>An administrator has created a Documenso account for you.</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>To get started, please set your password by clicking the button below:</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
href={resetPasswordLink}
|
||||
>
|
||||
<Trans>Set Password</Trans>
|
||||
</Button>
|
||||
<Text className="mt-8 text-center text-slate-400 text-sm italic">
|
||||
<Trans>
|
||||
You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="mt-8">
|
||||
<Text className="text-center text-slate-400 text-sm">
|
||||
<Trans>
|
||||
If you didn't expect this account or have any questions, please{' '}
|
||||
<Link href="mailto:support@documenso.com" className="text-documenso-500">
|
||||
contact support
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||
import type { TemplateAdminUserCreatedProps } from '../template-components/template-admin-user-created';
|
||||
import { TemplateAdminUserCreated } from '../template-components/template-admin-user-created';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export const AdminUserCreatedTemplate = ({
|
||||
resetPasswordLink,
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: TemplateAdminUserCreatedProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const previewText = msg`Set your password for Documenso`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
|
||||
|
||||
<TemplateAdminUserCreated resetPasswordLink={resetPasswordLink} assetBaseUrl={assetBaseUrl} />
|
||||
</Section>
|
||||
</Container>
|
||||
<div className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUserCreatedTemplate;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { JobClient } from './client/client';
|
||||
import { SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-admin-user-created-email';
|
||||
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
|
||||
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
|
||||
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
|
||||
@@ -29,6 +30,7 @@ import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-e
|
||||
* triggering jobs.
|
||||
*/
|
||||
export const jobsClient = new JobClient([
|
||||
SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION,
|
||||
SEND_SIGNING_EMAIL_JOB_DEFINITION,
|
||||
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
|
||||
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { AdminUserCreatedTemplate } from '@documenso/email/templates/admin-user-created';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import crypto from 'crypto';
|
||||
import { createElement } from 'react';
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
|
||||
import { ONE_DAY } from '../../../constants/time';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendAdminUserCreatedEmailJobDefinition } from './send-admin-user-created-email';
|
||||
|
||||
/**
|
||||
* Send notification email for admin-created users with password reset link.
|
||||
*
|
||||
* Creates a password reset token and sends an email explaining:
|
||||
* - An administrator created their account
|
||||
* - They need to set their password
|
||||
* - Support contact if they didn't expect this
|
||||
*/
|
||||
export const run = async ({ payload, io }: { payload: TSendAdminUserCreatedEmailJobDefinition; io: JobRunIO }) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: payload.userId,
|
||||
},
|
||||
});
|
||||
|
||||
const token = await io.runTask(`create-password-reset-token`, async () => {
|
||||
const passwordResetToken = await prisma.passwordResetToken.create({
|
||||
data: {
|
||||
token: crypto.randomBytes(18).toString('hex'),
|
||||
expiry: new Date(Date.now() + ONE_DAY),
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return passwordResetToken.token;
|
||||
});
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const resetPasswordLink = `${assetBaseUrl}/reset-password/${token}`;
|
||||
|
||||
const emailTemplate = createElement(AdminUserCreatedTemplate, {
|
||||
assetBaseUrl,
|
||||
resetPasswordLink,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(emailTemplate),
|
||||
renderEmailWithI18N(emailTemplate, { plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance();
|
||||
|
||||
return mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||
subject: i18n._(msg`Welcome to Documenso`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID = 'send.admin.user.created.email';
|
||||
|
||||
const SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
userId: z.number(),
|
||||
});
|
||||
|
||||
export type TSendAdminUserCreatedEmailJobDefinition = z.infer<
|
||||
typeof SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Admin User Created Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-admin-user-created-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendAdminUserCreatedEmailJobDefinition
|
||||
>;
|
||||
@@ -177,7 +177,7 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
|
||||
const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF();
|
||||
|
||||
const needsCertificate = settings.includeSigningCertificate;
|
||||
const needsAuditLog = settings.includeAuditLog;
|
||||
const needsAuditLog = envelope.documentMeta.includeAuditLog;
|
||||
|
||||
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"ioredis": "^5.10.1",
|
||||
"jose": "^6.1.2",
|
||||
"konva": "^10.0.9",
|
||||
"kysely": "0.28.16",
|
||||
"kysely": "0.29.2",
|
||||
"luxon": "^3.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"oslo": "^0.17.0",
|
||||
@@ -57,8 +57,8 @@
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"playwright": "1.56.1",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss-selector-parser": "^7.1.1",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
|
||||
@@ -25,6 +25,7 @@ export type CreateDocumentMetaOptions = {
|
||||
signingOrder?: DocumentSigningOrder;
|
||||
allowDictateNextSigner?: boolean;
|
||||
distributionMethod?: DocumentDistributionMethod;
|
||||
includeAuditLog?: boolean;
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
@@ -47,6 +48,7 @@ export const updateDocumentMeta = async ({
|
||||
emailReplyTo,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
@@ -108,6 +110,7 @@ export const updateDocumentMeta = async ({
|
||||
emailReplyTo,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
|
||||
@@ -40,6 +40,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
timezone: true,
|
||||
dateFormat: true,
|
||||
redirectUrl: true,
|
||||
|
||||
@@ -105,6 +105,7 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
language?: SupportedLanguageCodes;
|
||||
distributionMethod?: DocumentDistributionMethod;
|
||||
allowDictateNextSigner?: boolean;
|
||||
includeAuditLog?: boolean;
|
||||
emailSettings?: TDocumentEmailSettings;
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
@@ -513,6 +514,7 @@ export const createDocumentFromTemplate = async ({
|
||||
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
|
||||
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
|
||||
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
|
||||
includeAuditLog: override?.includeAuditLog ?? template.documentMeta?.includeAuditLog,
|
||||
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
|
||||
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
|
||||
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
|
||||
@@ -666,7 +668,7 @@ export const createDocumentFromTemplate = async ({
|
||||
|
||||
const date = new Date(selector.value);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid date value for field ${field.id}: ${selector.value}`,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export interface CreateAdminUserOptions {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a user for admin-initiated flows.
|
||||
*
|
||||
* Unlike normal signup, this function:
|
||||
* - Leaves the password unset (`null`); the user must set it later via a password reset/onboarding link
|
||||
* - Marks the email as verified immediately because this route is only called by admins
|
||||
* - Does NOT create a personal organisation (user will be added to real org)
|
||||
* - Returns the user immediately without side effects
|
||||
*/
|
||||
export const createAdminUser = async ({ name, email }: CreateAdminUserOptions) => {
|
||||
const userExists = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
if (userExists) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'User with this email already exists',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password: null,
|
||||
// Verifying the email here instead of the password reset flow to reduce the
|
||||
// attack surface. This route is only called by admins.
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
};
|
||||
@@ -26,30 +26,41 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword, // Todo: (RR7) Drop password.
|
||||
signature,
|
||||
},
|
||||
});
|
||||
|
||||
// Todo: (RR7) Migrate to use this after RR7.
|
||||
// await tx.account.create({
|
||||
// data: {
|
||||
// userId: user.id,
|
||||
// type: 'emailPassword', // Todo: (RR7)
|
||||
// provider: 'DOCUMENSO', // Todo: (RR7) Enums
|
||||
// providerAccountId: user.id.toString(),
|
||||
// password: hashedPassword,
|
||||
// },
|
||||
// });
|
||||
|
||||
return user;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword, // Todo: (RR7) Drop password.
|
||||
signature,
|
||||
},
|
||||
});
|
||||
|
||||
// Todo: (RR7) Migrate to use this after RR7.
|
||||
// Note: If we actually ever proceed with this, there are multiple
|
||||
// locations where we will need to update this.
|
||||
// const user = await prisma.$transaction(async (tx) => {
|
||||
// const user = await tx.user.create({
|
||||
// data: {
|
||||
// name,
|
||||
// email: email.toLowerCase(),
|
||||
// password: hashedPassword, // Todo: (RR7) Drop password.
|
||||
// signature,
|
||||
// },
|
||||
// });
|
||||
|
||||
// await tx.account.create({
|
||||
// data: {
|
||||
// userId: user.id,
|
||||
// type: 'emailPassword', // Todo: (RR7)
|
||||
// provider: 'DOCUMENSO', // Todo: (RR7) Enums
|
||||
// providerAccountId: user.id.toString(),
|
||||
// password: hashedPassword,
|
||||
// },
|
||||
// });
|
||||
|
||||
// return user;
|
||||
// });
|
||||
|
||||
// Not used at the moment, uncomment if required.
|
||||
await onCreateUserHook(user).catch((err) => {
|
||||
// Todo: (RR7) Add logging.
|
||||
|
||||
@@ -47,6 +47,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
|
||||
drawSignatureEnabled: true,
|
||||
language: 'en',
|
||||
distributionMethod: DocumentDistributionMethod.EMAIL,
|
||||
includeAuditLog: false,
|
||||
emailSettings: null,
|
||||
},
|
||||
recipients: [
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: de\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"PO-Revision-Date: 2026-05-19 05:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -5835,6 +5835,12 @@ msgstr "Horizontal"
|
||||
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
|
||||
msgstr "Wie lange Empfänger Zeit haben, dieses Dokument nach dem Senden zu vervollständigen. Verwendet die Team-Standardeinstellung, wenn \"Vererben\" ausgewählt ist."
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Human verification required"
|
||||
msgstr "Menschliche Überprüfung erforderlich"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
|
||||
msgid "I agree to link my account with this organization"
|
||||
msgstr "Ich bin einverstanden, mein Konto mit dieser Organisation zu verknüpfen."
|
||||
@@ -7797,6 +7803,12 @@ msgstr "Bitte überprüfe deine E-Mail auf Updates."
|
||||
msgid "Please choose your new password"
|
||||
msgstr "Bitte wählen Sie Ihr neues Passwort"
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Please complete the CAPTCHA challenge before signing in."
|
||||
msgstr "Bitte schließen Sie die CAPTCHA‑Prüfung ab, bevor Sie sich anmelden."
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: es\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"PO-Revision-Date: 2026-05-19 05:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -5835,6 +5835,12 @@ msgstr "Horizontal"
|
||||
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
|
||||
msgstr "Tiempo que tienen los destinatarios para completar este documento después de que se envía. Usa el valor predeterminado del equipo cuando está configurado como heredado."
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Human verification required"
|
||||
msgstr "Se requiere verificación humana"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
|
||||
msgid "I agree to link my account with this organization"
|
||||
msgstr "Acepto vincular mi cuenta con esta organización"
|
||||
@@ -7797,6 +7803,12 @@ msgstr "Por favor, revisa tu correo electrónico para actualizaciones."
|
||||
msgid "Please choose your new password"
|
||||
msgstr "Por favor, elige tu nueva contraseña"
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Please complete the CAPTCHA challenge before signing in."
|
||||
msgstr "Complete el desafío CAPTCHA antes de iniciar sesión."
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: fr\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"PO-Revision-Date: 2026-05-19 05:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
@@ -5835,6 +5835,12 @@ msgstr "Horizontal"
|
||||
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
|
||||
msgstr "Durée pendant laquelle les destinataires disposent pour compléter ce document après son envoi. Utilise la valeur par défaut de l’équipe lorsque le mode hérité est sélectionné."
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Human verification required"
|
||||
msgstr "Vérification humaine requise"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
|
||||
msgid "I agree to link my account with this organization"
|
||||
msgstr "J'accepte de lier mon compte avec cette organisation"
|
||||
@@ -7797,6 +7803,12 @@ msgstr "Veuillez vérifier votre e-mail pour des mises à jour."
|
||||
msgid "Please choose your new password"
|
||||
msgstr "Veuillez choisir votre nouveau mot de passe"
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Please complete the CAPTCHA challenge before signing in."
|
||||
msgstr "Veuillez compléter le test CAPTCHA avant de vous connecter."
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: it\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"PO-Revision-Date: 2026-05-19 05:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -5835,6 +5835,12 @@ msgstr "Orizzontale"
|
||||
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
|
||||
msgstr "Per quanto tempo i destinatari hanno a disposizione per completare questo documento dopo che è stato inviato. Viene utilizzato il valore predefinito del team quando è impostato su eredita."
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Human verification required"
|
||||
msgstr "Verifica umana richiesta"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
|
||||
msgid "I agree to link my account with this organization"
|
||||
msgstr "Accetto di collegare il mio account con questa organizzazione"
|
||||
@@ -7797,6 +7803,12 @@ msgstr "Per favore controlla la tua email per aggiornamenti."
|
||||
msgid "Please choose your new password"
|
||||
msgstr "Per favore scegli la tua nuova password"
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Please complete the CAPTCHA challenge before signing in."
|
||||
msgstr "Completa la verifica CAPTCHA prima di accedere."
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ja\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"PO-Revision-Date: 2026-05-19 05:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Japanese\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -5835,6 +5835,12 @@ msgstr "横"
|
||||
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
|
||||
msgstr "この文書が送信されてから、受信者が完了するまでの猶予期間です。「継承」に設定すると、チームのデフォルト値が使用されます。"
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Human verification required"
|
||||
msgstr "本人確認が必要です"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
|
||||
msgid "I agree to link my account with this organization"
|
||||
msgstr "この組織と自分のアカウントをリンクすることに同意します"
|
||||
@@ -7797,6 +7803,12 @@ msgstr "更新についてはメールをご確認ください。"
|
||||
msgid "Please choose your new password"
|
||||
msgstr "新しいパスワードを選択してください"
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Please complete the CAPTCHA challenge before signing in."
|
||||
msgstr "サインインする前に CAPTCHA 認証を完了してください。"
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ko\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"PO-Revision-Date: 2026-05-19 05:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Korean\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -5835,6 +5835,12 @@ msgstr "가로"
|
||||
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
|
||||
msgstr "이 문서가 전송된 후 수신자가 이를 완료할 수 있는 기간입니다. 상속으로 설정된 경우 팀 기본값을 사용합니다."
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Human verification required"
|
||||
msgstr "사람 인증이 필요합니다"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
|
||||
msgid "I agree to link my account with this organization"
|
||||
msgstr "이 조직과 계정을 연결하는 데 동의합니다"
|
||||
@@ -7797,6 +7803,12 @@ msgstr "업데이트 내용을 이메일로 확인해 주세요."
|
||||
msgid "Please choose your new password"
|
||||
msgstr "새 비밀번호를 선택해 주세요"
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Please complete the CAPTCHA challenge before signing in."
|
||||
msgstr "로그인하기 전에 CAPTCHA 인증을 완료해 주세요."
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: nl\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"PO-Revision-Date: 2026-05-19 05:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -5835,6 +5835,12 @@ msgstr "Horizontaal"
|
||||
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
|
||||
msgstr "Hoe lang ontvangers de tijd hebben om dit document te voltooien nadat het is verzonden. Gebruikt de standaardinstelling van het team wanneer overnemen is geselecteerd."
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Human verification required"
|
||||
msgstr "Menselijke verificatie vereist"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
|
||||
msgid "I agree to link my account with this organization"
|
||||
msgstr "Ik ga akkoord met het koppelen van mijn account aan deze organisatie"
|
||||
@@ -7797,6 +7803,12 @@ msgstr "Controleer je e‑mail voor updates."
|
||||
msgid "Please choose your new password"
|
||||
msgstr "Kies je nieuwe wachtwoord"
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Please complete the CAPTCHA challenge before signing in."
|
||||
msgstr "Voltooi de CAPTCHA-uitdaging voordat je je aanmeldt."
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: pl\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"PO-Revision-Date: 2026-05-19 05:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Polish\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
|
||||
@@ -5835,6 +5835,12 @@ msgstr "Poziomo"
|
||||
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
|
||||
msgstr "Czas, w którym odbiorcy muszą zakończyć dokument. Opcja dziedziczenia korzysta z domyślnych ustawień zespołu."
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Human verification required"
|
||||
msgstr "Wymagana weryfikacja za pomocą CAPTCHA"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
|
||||
msgid "I agree to link my account with this organization"
|
||||
msgstr "Zgadzam się połączyć moje konto z organizacją"
|
||||
@@ -7797,6 +7803,12 @@ msgstr "Sprawdź pocztę e-mail."
|
||||
msgid "Please choose your new password"
|
||||
msgstr "Wybierz nowe hasło"
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Please complete the CAPTCHA challenge before signing in."
|
||||
msgstr "Przed zalogowaniem się dokończ wyzwanie CAPTCHA."
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: zh\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"PO-Revision-Date: 2026-05-19 05:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -5835,6 +5835,12 @@ msgstr "水平"
|
||||
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
|
||||
msgstr "设置此文档在发送后,收件人可完成签署的时间长度。当设置为继承时,将使用团队默认值。"
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Human verification required"
|
||||
msgstr "需要进行人工验证"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
|
||||
msgid "I agree to link my account with this organization"
|
||||
msgstr "我同意将我的账户与此组织关联"
|
||||
@@ -7797,6 +7803,12 @@ msgstr "请留意邮箱更新。"
|
||||
msgid "Please choose your new password"
|
||||
msgstr "请选择你的新密码"
|
||||
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
msgid "Please complete the CAPTCHA challenge before signing in."
|
||||
msgstr "在登录之前,请先完成 CAPTCHA 验证。"
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
|
||||
|
||||
@@ -26,4 +26,9 @@ export type BaseApiLog = Partial<RootApiLog> & {
|
||||
export type TrpcApiLog = BaseApiLog & {
|
||||
trpcMiddleware: string;
|
||||
unverifiedTeamId?: number | null;
|
||||
/**
|
||||
* Used to differentiate between batched TRPC requests sharing the same
|
||||
* underlying HTTP `requestId`.
|
||||
*/
|
||||
nonBatchedRequestId?: string;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ZDocumentEmailSettingsSchema } from './document-email';
|
||||
export const ZDocumentMetaSchema = DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
@@ -93,6 +94,10 @@ export const ZDocumentMetaDistributionMethodSchema = z
|
||||
.nativeEnum(DocumentDistributionMethod)
|
||||
.describe('The distribution method to use when sending the document to the recipients.');
|
||||
|
||||
export const ZDocumentMetaIncludeAuditLogSchema = z
|
||||
.boolean()
|
||||
.describe('Whether to include the audit logs in the sealed document PDF.');
|
||||
|
||||
export const ZDocumentMetaTypedSignatureEnabledSchema = z
|
||||
.boolean()
|
||||
.describe('Whether to allow recipients to sign using a typed signature.');
|
||||
@@ -116,6 +121,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
|
||||
@@ -53,6 +53,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
|
||||
@@ -270,6 +270,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
|
||||
@@ -40,6 +40,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
|
||||
@@ -56,6 +56,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
redirectUrl: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
@@ -149,6 +150,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
|
||||
templateMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
}).nullable(),
|
||||
directLink: LegacyTemplateDirectLinkSchema.pick({
|
||||
token: true,
|
||||
|
||||
@@ -56,6 +56,7 @@ export const ZWebhookDocumentMetaSchema = z.object({
|
||||
drawSignatureEnabled: z.boolean(),
|
||||
language: z.string(),
|
||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||
includeAuditLog: z.boolean().default(false),
|
||||
emailSettings: z.any().nullable(),
|
||||
});
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export const extractDerivedDocumentMeta = (
|
||||
signingOrder: meta.signingOrder || DocumentSigningOrder.PARALLEL,
|
||||
allowDictateNextSigner: meta.allowDictateNextSigner ?? false,
|
||||
distributionMethod: meta.distributionMethod || DocumentDistributionMethod.EMAIL, // Todo: Make this a setting.
|
||||
includeAuditLog: meta.includeAuditLog ?? settings.includeAuditLog,
|
||||
|
||||
// Signature settings.
|
||||
typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled,
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentMeta" ADD COLUMN "includeAuditLog" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Backfill includeAuditLog from the effective team/organisation setting
|
||||
UPDATE "DocumentMeta" AS dm
|
||||
SET "includeAuditLog" = COALESCE(tgs."includeAuditLog", ogs."includeAuditLog")
|
||||
FROM "Envelope" e
|
||||
JOIN "Team" t ON t."id" = e."teamId"
|
||||
JOIN "TeamGlobalSettings" tgs ON tgs."id" = t."teamGlobalSettingsId"
|
||||
JOIN "Organisation" o ON o."id" = t."organisationId"
|
||||
JOIN "OrganisationGlobalSettings" ogs ON ogs."id" = o."organisationGlobalSettingsId"
|
||||
WHERE e."documentMetaId" = dm."id";
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"kysely": "0.28.16",
|
||||
"kysely": "0.29.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"prisma": "^6.19.0",
|
||||
"prisma-extension-kysely": "^3.0.0",
|
||||
|
||||
@@ -524,6 +524,7 @@ model DocumentMeta {
|
||||
|
||||
language String @default("en")
|
||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||
includeAuditLog Boolean @default(false)
|
||||
|
||||
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
|
||||
emailReplyTo String?
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
import { createAdminUser } from '@documenso/lib/server-only/user/create-admin-user';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import { ZCreateUserRequestSchema, ZCreateUserResponseSchema } from './create-user.types';
|
||||
|
||||
export const createUserRoute = adminProcedure
|
||||
.input(ZCreateUserRequestSchema)
|
||||
.output(ZCreateUserResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email, name } = input;
|
||||
|
||||
const user = await createAdminUser({
|
||||
name,
|
||||
email,
|
||||
});
|
||||
|
||||
ctx.logger.info({
|
||||
createdUserId: user.id,
|
||||
});
|
||||
|
||||
await jobsClient.triggerJob({
|
||||
name: 'send.admin.user.created.email',
|
||||
payload: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateUserRequestSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
name: ZNameSchema,
|
||||
});
|
||||
|
||||
export type TCreateUserRequest = z.infer<typeof ZCreateUserRequestSchema>;
|
||||
|
||||
export const ZCreateUserResponseSchema = z.object({
|
||||
userId: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateUserResponse = z.infer<typeof ZCreateUserResponseSchema>;
|
||||
@@ -2,6 +2,7 @@ import { router } from '../trpc';
|
||||
import { createAdminOrganisationRoute } from './create-admin-organisation';
|
||||
import { createStripeCustomerRoute } from './create-stripe-customer';
|
||||
import { createSubscriptionClaimRoute } from './create-subscription-claim';
|
||||
import { createUserRoute } from './create-user';
|
||||
import { deleteDocumentRoute } from './delete-document';
|
||||
import { deleteOrganisationRoute } from './delete-organisation';
|
||||
import { deleteAdminOrganisationMemberRoute } from './delete-organisation-member';
|
||||
@@ -64,6 +65,7 @@ export const adminRouter = router({
|
||||
},
|
||||
user: {
|
||||
get: getUserRoute,
|
||||
create: createUserRoute,
|
||||
update: updateUserRoute,
|
||||
delete: deleteUserRoute,
|
||||
enable: enableUserRoute,
|
||||
|
||||
@@ -37,6 +37,7 @@ export const distributeDocumentRoute = authenticatedProcedure
|
||||
timezone: meta.timezone,
|
||||
redirectUrl: meta.redirectUrl,
|
||||
distributionMethod: meta.distributionMethod,
|
||||
includeAuditLog: meta.includeAuditLog,
|
||||
emailSettings: meta.emailSettings ?? undefined,
|
||||
language: meta.language,
|
||||
emailId: meta.emailId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-emai
|
||||
import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -33,6 +34,7 @@ export const ZDistributeDocumentRequestSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
emailId: z.string().nullish(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -59,6 +60,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -56,6 +57,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
|
||||
@@ -21,6 +21,7 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -60,6 +61,7 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -60,6 +61,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
|
||||
@@ -37,6 +37,7 @@ export const distributeEnvelopeRoute = authenticatedProcedure
|
||||
timezone: meta.timezone,
|
||||
redirectUrl: meta.redirectUrl,
|
||||
distributionMethod: meta.distributionMethod,
|
||||
includeAuditLog: meta.includeAuditLog,
|
||||
emailSettings: meta.emailSettings ?? undefined,
|
||||
language: meta.language,
|
||||
emailId: meta.emailId,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ZDistributeEnvelopeRequestSchema = z.object({
|
||||
timezone: true,
|
||||
dateFormat: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
redirectUrl: true,
|
||||
language: true,
|
||||
emailId: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -89,6 +90,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
|
||||
@@ -61,6 +61,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
|
||||
select: {
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
},
|
||||
},
|
||||
directLink: {
|
||||
@@ -106,6 +107,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
|
||||
? {
|
||||
signingOrder: envelope.documentMeta.signingOrder,
|
||||
distributionMethod: envelope.documentMeta.distributionMethod,
|
||||
includeAuditLog: envelope.documentMeta.includeAuditLog,
|
||||
}
|
||||
: null,
|
||||
directLink: envelope.directLink
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { convertToPdf } from '@documenso/lib/server-only/document-conversion';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
||||
import { duplicateEnvelope } from '@documenso/lib/server-only/envelope/duplicate-envelope';
|
||||
@@ -269,9 +270,18 @@ export const templateRouter = router({
|
||||
attachments,
|
||||
} = payload;
|
||||
|
||||
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(file, {
|
||||
flattenForm: false,
|
||||
});
|
||||
const pdf = await convertToPdf(file, ctx.logger);
|
||||
|
||||
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(
|
||||
{
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdf),
|
||||
},
|
||||
{
|
||||
flattenForm: false,
|
||||
},
|
||||
);
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
|
||||
@@ -70,9 +70,12 @@ const t = initTRPC
|
||||
* Middlewares
|
||||
*/
|
||||
export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, meta }) => {
|
||||
const infoToLog: TrpcApiLog = {
|
||||
// Auth-independent log bindings. `auth` is set per-branch below since it
|
||||
// depends on which auth path was taken; `ctx.metadata.auth` here is still
|
||||
// `null` (the resolved value is set in the `next()` call below).
|
||||
const baseLogAttributes: TrpcApiLog = {
|
||||
path,
|
||||
auth: ctx.metadata.auth,
|
||||
auth: null,
|
||||
source: ctx.metadata.source,
|
||||
trpcMiddleware: 'authenticated',
|
||||
unverifiedTeamId: ctx.teamId,
|
||||
@@ -93,15 +96,21 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me
|
||||
|
||||
const apiToken = await getApiTokenByToken({ token });
|
||||
|
||||
ctx.logger.info({
|
||||
...infoToLog,
|
||||
const trpcApiV2Logger = ctx.logger.child({
|
||||
...baseLogAttributes,
|
||||
auth: 'api',
|
||||
userId: apiToken.user.id,
|
||||
apiTokenId: apiToken.id,
|
||||
} satisfies TrpcApiLog);
|
||||
|
||||
trpcApiV2Logger.info({
|
||||
position: 'trpcProcedure',
|
||||
});
|
||||
|
||||
return await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
logger: trpcApiV2Logger,
|
||||
user: apiToken.user,
|
||||
teamId: apiToken.teamId,
|
||||
session: null,
|
||||
@@ -131,17 +140,21 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me
|
||||
});
|
||||
}
|
||||
|
||||
// Recreate the logger with a sub request ID to differentiate between batched requests.
|
||||
// Recreate the logger with a sub request ID to differentiate between batched
|
||||
// requests, as well as identifying attributes so every subsequent log line
|
||||
// (including errors) inherits them.
|
||||
const trpcSessionLogger = ctx.logger.child({
|
||||
...baseLogAttributes,
|
||||
auth: 'session',
|
||||
nonBatchedRequestId: alphaid(),
|
||||
});
|
||||
|
||||
trpcSessionLogger.info({
|
||||
...infoToLog,
|
||||
userId: ctx.user.id,
|
||||
apiTokenId: null,
|
||||
} satisfies TrpcApiLog);
|
||||
|
||||
trpcSessionLogger.info({
|
||||
position: 'trpcProcedure',
|
||||
});
|
||||
|
||||
return await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
@@ -163,14 +176,9 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me
|
||||
});
|
||||
|
||||
export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, path, meta }) => {
|
||||
// Recreate the logger with a sub request ID to differentiate between batched requests.
|
||||
const trpcSessionLogger = ctx.logger.child({
|
||||
nonBatchedRequestId: alphaid(),
|
||||
});
|
||||
|
||||
const infoToLog: TrpcApiLog = {
|
||||
const baseLogAttributes: TrpcApiLog = {
|
||||
path,
|
||||
auth: ctx.metadata.auth,
|
||||
auth: null,
|
||||
source: ctx.metadata.source,
|
||||
trpcMiddleware: 'maybeAuthenticated',
|
||||
unverifiedTeamId: ctx.teamId,
|
||||
@@ -191,15 +199,23 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
|
||||
|
||||
const apiToken = await getApiTokenByToken({ token });
|
||||
|
||||
ctx.logger.info({
|
||||
...infoToLog,
|
||||
// Attach identifying attributes to the logger so every subsequent log line
|
||||
// within this request (including errors) inherits them.
|
||||
const trpcApiV2Logger = ctx.logger.child({
|
||||
...baseLogAttributes,
|
||||
auth: 'api',
|
||||
userId: apiToken.user.id,
|
||||
apiTokenId: apiToken.id,
|
||||
} satisfies TrpcApiLog);
|
||||
|
||||
trpcApiV2Logger.info({
|
||||
position: 'trpcProcedure',
|
||||
});
|
||||
|
||||
return await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
logger: trpcApiV2Logger,
|
||||
user: apiToken.user,
|
||||
teamId: apiToken.teamId,
|
||||
session: null,
|
||||
@@ -222,12 +238,25 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
|
||||
});
|
||||
}
|
||||
|
||||
trpcSessionLogger.info({
|
||||
...infoToLog,
|
||||
// Resolve `auth` once so it stays in sync between the logger bindings and
|
||||
// the outgoing metadata.
|
||||
const auth = ctx.session ? 'session' : null;
|
||||
|
||||
// Recreate the logger with a sub request ID to differentiate between batched
|
||||
// requests, as well as identifying attributes so every subsequent log line
|
||||
// (including errors) inherits them.
|
||||
const trpcSessionLogger = ctx.logger.child({
|
||||
...baseLogAttributes,
|
||||
auth,
|
||||
nonBatchedRequestId: alphaid(),
|
||||
userId: ctx.user?.id,
|
||||
apiTokenId: null,
|
||||
} satisfies TrpcApiLog);
|
||||
|
||||
trpcSessionLogger.info({
|
||||
position: 'trpcProcedure',
|
||||
});
|
||||
|
||||
return await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
@@ -243,7 +272,7 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
|
||||
email: ctx.user.email,
|
||||
}
|
||||
: undefined,
|
||||
auth: ctx.session ? 'session' : null,
|
||||
auth,
|
||||
} satisfies ApiRequestMetadata,
|
||||
},
|
||||
});
|
||||
@@ -266,20 +295,24 @@ export const adminMiddleware = t.middleware(async ({ ctx, next, path }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Recreate the logger with a sub request ID to differentiate between batched requests.
|
||||
// Recreate the logger with a sub request ID to differentiate between batched
|
||||
// requests, as well as identifying attributes so every subsequent log line
|
||||
// (including errors) inherits them.
|
||||
const trpcSessionLogger = ctx.logger.child({
|
||||
nonBatchedRequestId: alphaid(),
|
||||
});
|
||||
|
||||
trpcSessionLogger.info({
|
||||
unverifiedTeamId: ctx.teamId,
|
||||
path,
|
||||
auth: ctx.metadata.auth,
|
||||
auth: 'session',
|
||||
source: ctx.metadata.source,
|
||||
userId: ctx.user.id,
|
||||
apiTokenId: null,
|
||||
trpcMiddleware: 'admin',
|
||||
} satisfies TrpcApiLog);
|
||||
|
||||
trpcSessionLogger.info({
|
||||
position: 'trpcProcedure',
|
||||
});
|
||||
|
||||
return await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
@@ -300,12 +333,12 @@ export const adminMiddleware = t.middleware(async ({ ctx, next, path }) => {
|
||||
});
|
||||
|
||||
export const procedureMiddleware = t.middleware(async ({ ctx, next, path }) => {
|
||||
// Recreate the logger with a sub request ID to differentiate between batched requests.
|
||||
// Recreate the logger with a sub request ID to differentiate between batched
|
||||
// requests, as well as identifying attributes so every subsequent log line
|
||||
// (including errors) inherits them.
|
||||
const trpcSessionLogger = ctx.logger.child({
|
||||
nonBatchedRequestId: alphaid(),
|
||||
});
|
||||
|
||||
trpcSessionLogger.info({
|
||||
unverifiedTeamId: ctx.teamId,
|
||||
path,
|
||||
auth: ctx.metadata.auth,
|
||||
source: ctx.metadata.source,
|
||||
@@ -314,6 +347,10 @@ export const procedureMiddleware = t.middleware(async ({ ctx, next, path }) => {
|
||||
trpcMiddleware: 'procedure',
|
||||
} satisfies TrpcApiLog);
|
||||
|
||||
trpcSessionLogger.info({
|
||||
position: 'trpcProcedure',
|
||||
});
|
||||
|
||||
return await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
|
||||
@@ -41,7 +41,7 @@ export const CopyTextButton = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="none"
|
||||
className="ml-2 h-7 rounded border bg-neutral-50 px-0.5 font-normal dark:border dark:border-neutral-500 dark:bg-neutral-600"
|
||||
className="ml-2 h-7 rounded-md border-border bg-muted px-0.5 font-normal"
|
||||
onClick={async () => onCopy()}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
@@ -56,7 +56,7 @@ export const CopyTextButton = ({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded transition-all hover:bg-neutral-200 hover:active:bg-neutral-300 dark:hover:bg-neutral-500 dark:hover:active:bg-neutral-400',
|
||||
'flex h-6 w-6 items-center justify-center rounded transition-all hover:bg-muted-foreground/10 hover:active:bg-muted-foreground/20',
|
||||
{
|
||||
'ml-1': Boolean(badgeContentCopied || badgeContentUncopied),
|
||||
},
|
||||
|
||||
@@ -83,7 +83,7 @@ const CommandTextInput = React.forwardRef<
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
{
|
||||
'!ring-red-500 ring-2 transition-all': props['aria-invalid'],
|
||||
'!ring-destructive ring-2 transition-all': props['aria-invalid'],
|
||||
},
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -92,7 +92,7 @@ export const DocumentDropzone = ({
|
||||
// Disabled State
|
||||
<div className="flex">
|
||||
<motion.div
|
||||
className="a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-destructive/10 group-hover:bg-destructive/2 dark:bg-muted/80"
|
||||
className="a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-background/80 px-2 py-4 backdrop-blur-sm group-hover:border-destructive/10 group-hover:bg-destructive/2"
|
||||
variants={DocumentDropzoneDisabledCardLeftVariants}
|
||||
>
|
||||
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/10 group-hover:bg-destructive/10" />
|
||||
@@ -100,7 +100,7 @@ export const DocumentDropzone = ({
|
||||
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/10 group-hover:bg-destructive/10" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-destructive/50 group-hover:bg-destructive/5 dark:bg-muted/80"
|
||||
className="z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border border-muted-foreground/20 bg-background/80 px-2 py-4 backdrop-blur-sm group-hover:border-destructive/50 group-hover:bg-destructive/5"
|
||||
variants={DocumentDropzoneDisabledCardCenterVariants}
|
||||
>
|
||||
<AlertTriangle
|
||||
@@ -109,7 +109,7 @@ export const DocumentDropzone = ({
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-destructive/10 group-hover:bg-destructive/2 dark:bg-muted/80"
|
||||
className="z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-background/80 px-2 py-4 backdrop-blur-sm group-hover:border-destructive/10 group-hover:bg-destructive/2"
|
||||
variants={DocumentDropzoneDisabledCardRightVariants}
|
||||
>
|
||||
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/10 group-hover:bg-destructive/10" />
|
||||
@@ -121,7 +121,7 @@ export const DocumentDropzone = ({
|
||||
// Non Disabled State
|
||||
<div className="flex">
|
||||
<motion.div
|
||||
className="a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-documenso/80 dark:bg-muted/80"
|
||||
className="a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-background/80 px-2 py-4 backdrop-blur-sm group-hover:border-documenso/80"
|
||||
variants={DocumentDropzoneCardLeftVariants}
|
||||
>
|
||||
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/20 group-hover:bg-documenso" />
|
||||
@@ -129,13 +129,13 @@ export const DocumentDropzone = ({
|
||||
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/20 group-hover:bg-documenso" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-documenso/80 dark:bg-muted/80"
|
||||
className="z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border border-muted-foreground/20 bg-background/80 px-2 py-4 backdrop-blur-sm group-hover:border-documenso/80"
|
||||
variants={DocumentDropzoneCardCenterVariants}
|
||||
>
|
||||
<Plus strokeWidth="2px" className="h-12 w-12 text-muted-foreground/20 group-hover:text-documenso" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-documenso/80 dark:bg-muted/80"
|
||||
className="z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-background/80 px-2 py-4 backdrop-blur-sm group-hover:border-documenso/80"
|
||||
variants={DocumentDropzoneCardRightVariants}
|
||||
>
|
||||
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/20 group-hover:bg-documenso" />
|
||||
|
||||
@@ -920,7 +920,7 @@ export const AddFieldsFormPartial = ({
|
||||
{hasErrors && (
|
||||
<div className="mt-4">
|
||||
<ul>
|
||||
<li className="text-red-500 text-sm">
|
||||
<li className="text-destructive text-sm">
|
||||
<Trans>
|
||||
To proceed further, please set at least one value for the{' '}
|
||||
{emptyCheckboxFields.length > 0 ? 'Checkbox' : emptyRadioFields.length > 0 ? 'Radio' : 'Select'}{' '}
|
||||
|
||||
@@ -326,7 +326,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
||||
<div className="mt-4">
|
||||
<ul>
|
||||
{errors.map((error, index) => (
|
||||
<li className="text-red-500 text-sm" key={index}>
|
||||
<li className="text-destructive text-sm" key={index}>
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
|
||||
+1
-1
@@ -240,7 +240,7 @@ export const CheckboxFieldAdvancedSettings = ({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-muted-foreground hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => removeValue(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ export const DropdownFieldAdvancedSettings = ({
|
||||
<Input className="w-1/2" value={value.value} onChange={(e) => handleValueChange(index, e.target.value)} />
|
||||
<button
|
||||
type="button"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-muted-foreground hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => removeValue(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
|
||||
@@ -176,7 +176,7 @@ export const RadioFieldAdvancedSettings = ({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50 dark:text-white"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-muted-foreground hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => removeValue(value.id)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
|
||||
@@ -38,7 +38,7 @@ export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) =>
|
||||
opacity: 0,
|
||||
y: 10,
|
||||
}}
|
||||
className={cn('text-red-500 text-xs', className)}
|
||||
className={cn('text-destructive text-xs', className)}
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.p>
|
||||
|
||||
@@ -156,7 +156,7 @@ const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<
|
||||
y: 10,
|
||||
}}
|
||||
>
|
||||
<p ref={ref} id={formMessageId} className={cn('text-red-500 text-xs', className)} {...props}>
|
||||
<p ref={ref} id={formMessageId} className={cn('text-destructive text-xs', className)} {...props}>
|
||||
{body}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -12,7 +12,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className,
|
||||
{
|
||||
'!ring-red-500 ring-2 transition-all': props['aria-invalid'],
|
||||
'!ring-destructive ring-2 transition-all': props['aria-invalid'],
|
||||
},
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -119,7 +119,7 @@ export function MultiSelectCombobox<T = OptionValue>({
|
||||
<AnimatePresence>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader className="h-5 w-5 animate-spin text-gray-500 dark:text-gray-100" />
|
||||
<Loader className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<AnimateGenericFadeInOut className="flex w-full justify-between">
|
||||
@@ -142,7 +142,7 @@ export function MultiSelectCombobox<T = OptionValue>({
|
||||
{showClearButton && !loading && (
|
||||
<div className="absolute top-0 right-8 bottom-0 flex items-center justify-center">
|
||||
<button
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-gray-300 dark:bg-neutral-700"
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-muted-foreground/20"
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
||||
@@ -31,7 +31,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<AnimatePresence>
|
||||
{loading ? (
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Loader className="h-5 w-5 animate-spin text-gray-500 dark:text-gray-100" />
|
||||
<Loader className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<AnimateGenericFadeInOut className="flex w-full justify-between">
|
||||
|
||||
@@ -28,7 +28,7 @@ export const SignaturePadType = ({ className, value, defaultValue, onChange }: S
|
||||
<input
|
||||
data-testid="signature-pad-type-input"
|
||||
placeholder={t`Type your signature`}
|
||||
className="w-full bg-transparent px-4 text-center font-signature text-7xl text-black placeholder:text-4xl focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white"
|
||||
className="w-full bg-transparent px-4 text-center font-signature text-7xl text-foreground placeholder:text-4xl focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
// style={{ color: selectedColor }}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
|
||||
@@ -169,26 +169,23 @@ export const SignaturePad = ({
|
||||
|
||||
<TabsContent
|
||||
value="draw"
|
||||
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-neutral-50 text-center dark:bg-background"
|
||||
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25 text-center"
|
||||
>
|
||||
<SignaturePadDraw className="h-full w-full" onChange={onDrawSignatureChange} value={drawSignature} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="text"
|
||||
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-neutral-50 text-center dark:bg-background"
|
||||
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25 text-center"
|
||||
>
|
||||
<SignaturePadType value={typedSignature} defaultValue={fullName} onChange={onTypedSignatureChange} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="image"
|
||||
className={cn(
|
||||
'relative aspect-signature-pad rounded-md border border-border bg-neutral-50 dark:bg-background',
|
||||
{
|
||||
'bg-white': imageSignature,
|
||||
},
|
||||
)}
|
||||
className={cn('relative aspect-signature-pad rounded-md border border-border bg-muted/25', {
|
||||
'bg-background': imageSignature,
|
||||
})}
|
||||
>
|
||||
<SignaturePadUpload value={imageSignature} onChange={onImageSignatureChange} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -944,7 +944,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
{hasErrors && (
|
||||
<div className="mt-4">
|
||||
<ul>
|
||||
<li className="text-red-500 text-sm">
|
||||
<li className="text-destructive text-sm">
|
||||
<Trans>
|
||||
To proceed further, please set at least one value for the{' '}
|
||||
{emptyCheckboxFields.length > 0 ? 'Checkbox' : emptyRadioFields.length > 0 ? 'Radio' : 'Select'}{' '}
|
||||
|
||||
@@ -725,7 +725,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
|
||||
{isSignerDirectRecipient(signer) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80">
|
||||
<TooltipTrigger className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-muted-foreground hover:opacity-80">
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
|
||||
@@ -744,7 +744,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-muted-foreground hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSubmitting || signers.length === 1}
|
||||
onClick={() => onRemoveSigner(index)}
|
||||
data-testid="remove-placeholder-recipient-button"
|
||||
|
||||
@@ -11,7 +11,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
|
||||
'flex h-20 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
{
|
||||
'!ring-red-500 ring-2 transition-all': props['aria-invalid'],
|
||||
'!ring-destructive ring-2 transition-all': props['aria-invalid'],
|
||||
},
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
Reference in New Issue
Block a user