Compare commits

...

12 Commits

Author SHA1 Message Date
ephraimduncan 358677fc3a feat: add PDF viewer zoom controls 2026-05-21 03:56:09 +00:00
roshboi c0ea4c60e4 fix(docs): correct API example URLs from /documents to /document (#2836)
## Description

Corrected API endpoint path from /api/v2/documents to /api/v2/document

The current example in the docs(/api/v2/documents) returns a 404
NOT_FOUND object.
2026-05-20 18:17:14 +10:00
Ephraim Duncan 2cb4cc29ea feat: allow admins to create users (#2082) 2026-05-19 20:37:03 +10:00
Lucas Smith d9b5f01e21 chore: add translations (#2833) 2026-05-19 16:19:44 +10:00
Lucas Smith bc3acba72c fix: use captcha imperatively (#2832) 2026-05-19 14:38:40 +10:00
Ephraim Duncan 247a0158bd refactor(ui): replace hardcoded colors with semantic tokens (#2749) 2026-05-19 14:19:31 +10:00
Lucas Smith 9e0b567686 chore: deps upgrade (#2831) 2026-05-18 22:25:48 +10:00
David Nguyen 8f6be474a9 fix: improve api logging (#2820) 2026-05-15 13:41:35 +10:00
Ephraim Duncan 8f5bdef384 docs: require English for PRs and issues (#2819) 2026-05-15 12:30:13 +10:00
David Nguyen 999942014e chore: update docs for self hosters (#2816) 2026-05-14 15:07:10 +10:00
Tarana 194b2134cc docs: remove leftover Next.js commands and update to Remix-compatible syntax (#2695) 2026-05-14 12:06:59 +10:00
Ephraim Duncan b8df02750b fix: convert DOCX template uploads to PDF (#2807) 2026-05-14 11:59:27 +10:00
66 changed files with 2860 additions and 1839 deletions
+4
View File
@@ -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:
+14 -144
View File
@@ -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
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p)
### Render
#### Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso)
### Koyeb
#### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
## Elestio
#### Elestio
[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](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
+2 -2
View File
@@ -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"
}
+1 -1
View File
@@ -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>
);
};
+21 -13
View File
@@ -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>
+18 -7
View File
@@ -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>
@@ -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,8 +1,10 @@
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { MinusIcon, PlusIcon, RotateCcwIcon } from 'lucide-react';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
@@ -27,6 +29,10 @@ type LoadingState = 'loading' | 'loaded' | 'error';
const LOW_RENDER_RESOLUTION = 1;
const HIGH_RENDER_RESOLUTION = 2;
const IDLE_RENDER_DELAY = 200;
const DEFAULT_ZOOM = 1;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2;
const ZOOM_STEP = 0.25;
export type PDFViewerProps = {
className?: string;
@@ -72,6 +78,22 @@ export default function PDFViewer({
const $el = useRef<HTMLDivElement>(null);
const [loadingState, setLoadingState] = useState<LoadingState>('loading');
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
const canZoomOut = zoom > MIN_ZOOM;
const canZoomIn = zoom < MAX_ZOOM;
const zoomOut = () => {
setZoom((currentZoom) => Math.max(MIN_ZOOM, currentZoom - ZOOM_STEP));
};
const resetZoom = () => {
setZoom(DEFAULT_ZOOM);
};
const zoomIn = () => {
setZoom((currentZoom) => Math.min(MAX_ZOOM, currentZoom + ZOOM_STEP));
};
const pdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
@@ -88,6 +110,7 @@ export default function PDFViewer({
try {
setLoadingState('loading');
setPages([]);
setZoom(DEFAULT_ZOOM);
if (isCancelled) {
return;
@@ -109,7 +132,11 @@ export default function PDFViewer({
return;
}
const loadedPdf = await pdfjsLib.getDocument({ data: result!, cMapUrl: '/static/cmaps/' }).promise;
if (!result) {
throw new Error('Failed to load PDF data');
}
const loadedPdf = await pdfjsLib.getDocument({ data: result, cMapUrl: '/static/cmaps/' }).promise;
if (isCancelled) {
await loadedPdf.destroy();
@@ -191,13 +218,57 @@ export default function PDFViewer({
}
return (
<div ref={$el} className={cn('h-full w-full', className)} {...props}>
<div ref={$el} className={cn('h-full w-full overflow-x-auto', className)} {...props}>
{/* Loading State */}
{isLoading && <PdfViewerLoadingState />}
{/* Error State */}
{hasError && <PdfViewerErrorState />}
{loadingState === 'loaded' && (
<div className="sticky top-2 right-2 z-20 ml-auto flex w-fit items-center gap-1 rounded-md border bg-background/95 p-1 shadow-sm">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0"
disabled={!canZoomOut}
aria-label={t`Zoom out`}
onClick={zoomOut}
>
<MinusIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Zoom out</Trans>
</span>
</Button>
<Button
type="button"
variant="ghost"
className="h-8 min-w-12 px-2 font-medium text-xs tabular-nums"
disabled={zoom === DEFAULT_ZOOM}
aria-label={t`Reset zoom`}
onClick={resetZoom}
>
<RotateCcwIcon className="mr-1 h-3.5 w-3.5" />
{Math.round(zoom * 100)}%
</Button>
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0"
disabled={!canZoomIn}
aria-label={t`Zoom in`}
onClick={zoomIn}
>
<PlusIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Zoom in</Trans>
</span>
</Button>
</div>
)}
{/* Loaded State */}
{loadingState === 'loaded' && pages.length > 0 && pdfRef.current && (
<VirtualizedPageList
@@ -206,6 +277,7 @@ export default function PDFViewer({
numPages={pages.length}
pages={pages}
pdf={pdfRef.current}
zoom={zoom}
customPageRenderer={customPageRenderer}
/>
)}
@@ -220,6 +292,7 @@ type VirtualizedPageListProps = {
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
zoom: number;
};
const VirtualizedPageList = ({
@@ -229,6 +302,7 @@ const VirtualizedPageList = ({
numPages,
pdf,
customPageRenderer,
zoom,
}: VirtualizedPageListProps) => {
const contentRef = useRef<HTMLDivElement>(null);
@@ -240,9 +314,9 @@ const VirtualizedPageList = ({
itemSize: (index, width) => {
const pageMeta = pages[index];
// Calculate height based on aspect ratio and available width
// Calculate height based on aspect ratio and zoomed width
const aspectRatio = pageMeta.height / pageMeta.width;
const scaledHeight = width * aspectRatio;
const scaledHeight = width * zoom * aspectRatio;
// Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
// Add additional 2px for the top and bottom borders.
@@ -261,7 +335,7 @@ const VirtualizedPageList = ({
data-page-count={numPages}
style={{
height: `${totalSize}px`,
width: '100%',
width: `${Math.max(constraintWidth, constraintWidth * zoom)}px`,
position: 'relative',
}}
>
@@ -270,20 +344,22 @@ const VirtualizedPageList = ({
const pageMeta = pages[index];
const pageNumber = index + 1;
// Calculate scale based on constraint width
const scale = constraintWidth / pageMeta.width;
// Calculate scale based on fit-to-width plus viewer zoom
const pageDisplayWidth = constraintWidth * zoom;
const scale = pageDisplayWidth / pageMeta.width;
const scaledWidth = Math.floor(pageMeta.width * scale);
const scaledHeight = Math.floor(pageMeta.height * scale);
return (
<div
className="flex flex-col items-center"
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: constraintWidth,
width: Math.max(constraintWidth, scaledWidth),
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
@@ -373,6 +449,7 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const renderedResolutionRef = useRef<number | null>(null);
const renderedScaleRef = useRef<number | null>(null);
const renderedPageNumberRef = useRef<number | null>(null);
const renderedPdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
@@ -392,7 +469,8 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
return (
renderedPdfRef.current === pdf &&
renderedPageNumberRef.current === pageNumber &&
renderedResolutionRef.current === resolution
renderedResolutionRef.current === resolution &&
renderedScaleRef.current === scale
);
};
@@ -400,6 +478,7 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
renderedPdfRef.current = pdf;
renderedPageNumberRef.current = pageNumber;
renderedResolutionRef.current = resolution;
renderedScaleRef.current = scale;
};
const renderAtResolution = async (resolution: number) => {
@@ -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
View File
@@ -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
+1341 -1291
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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();
});
@@ -13,9 +13,9 @@ import {
} from '@documenso/prisma/seed/documents';
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { expect, type Locator, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
import { apiSignin } from '../fixtures/authentication';
export const PDF_PAGE_SELECTOR = 'img[data-page-number]';
@@ -46,6 +46,16 @@ async function addSecondEnvelopeItem(envelopeId: string) {
});
}
async function getLocatorWidth(locator: Locator) {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Locator bounding box not found');
}
return box.width;
}
test.describe('PDF Viewer Rendering', () => {
test.describe('Authenticated Pages', () => {
test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => {
@@ -101,6 +111,38 @@ test.describe('PDF Viewer Rendering', () => {
await page.goto(`/t/${team.url}/documents/${documentV1.id}`);
await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 });
});
test('should zoom in and reset to fit width', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}`,
});
const pageImage = page.locator(PDF_PAGE_SELECTOR).first();
const pdfContent = page.locator('[data-pdf-content]');
await expect(pageImage).toBeVisible({ timeout: 30_000 });
const initialImageWidth = await getLocatorWidth(pageImage);
const initialContentWidth = await getLocatorWidth(pdfContent);
expect(Math.abs(initialImageWidth - initialContentWidth)).toBeLessThanOrEqual(2);
await page.getByRole('button', { name: 'Zoom in' }).click();
await expect.poll(async () => await getLocatorWidth(pageImage)).toBeGreaterThan(initialImageWidth);
await expect.poll(async () => await getLocatorWidth(pdfContent)).toBeGreaterThan(initialContentWidth);
await page.getByRole('button', { name: 'Reset zoom' }).click();
await expect
.poll(async () => Math.abs((await getLocatorWidth(pageImage)) - initialImageWidth))
.toBeLessThanOrEqual(2);
});
});
test.describe('Recipient Signing', () => {
@@ -131,6 +173,68 @@ test.describe('PDF Viewer Rendering', () => {
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should keep V2 signing fields clickable after zooming', async ({ page, request }) => {
const { distributeResult, envelope } = await apiSeedPendingDocument(request, {
recipients: [{ email: 'pdf-zoom-signer@test.documenso.com', name: 'PDF Zoom Signer' }],
fieldsPerRecipient: [
[
{
type: FieldType.SIGNATURE,
page: 1,
positionX: 10,
positionY: 10,
width: 15,
height: 5,
},
],
],
});
const { token } = distributeResult.recipients[0];
await page.goto(`/sign/${token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('1 Field Remaining').first()).toBeVisible();
const initialCanvasWidth = await getLocatorWidth(canvas);
await page.getByRole('button', { name: 'Zoom in' }).click();
await expect.poll(async () => await getLocatorWidth(canvas)).toBeGreaterThan(initialCanvasWidth);
await page.getByTestId('signature-pad-dialog-button').click();
await page.getByRole('tab', { name: 'Type' }).click();
await page.getByTestId('signature-pad-type-input').fill('Signature');
await page.getByRole('button', { name: 'Next' }).click();
const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE);
if (!signatureField) {
throw new Error('Signature field not found');
}
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const x =
(Number(signatureField.positionX) / 100) * canvasBox.width +
((Number(signatureField.width) / 100) * canvasBox.width) / 2;
const y =
(Number(signatureField.positionY) / 100) * canvasBox.height +
((Number(signatureField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
});
});
test.describe('Direct Template', () => {
@@ -168,7 +272,7 @@ test.describe('PDF Viewer Rendering', () => {
const qrTokenV1 = prefixedId('qr');
const qrTokenV2 = prefixedId('qr');
const documentV1 = await seedCompletedDocument(user, team.id, ['share-v1@test.documenso.com'], {
await seedCompletedDocument(user, team.id, ['share-v1@test.documenso.com'], {
createDocumentOptions: { qrToken: qrTokenV1 },
});
@@ -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;
+2
View File
@@ -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
>;
+3 -3
View File
@@ -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",
@@ -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;
};
+33 -22
View File
@@ -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.
+13 -1
View File
@@ -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 CAPTCHAPrü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
+13 -1
View File
@@ -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
+13 -1
View File
@@ -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
+13 -1
View File
@@ -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
+13 -1
View File
@@ -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
+13 -1
View File
@@ -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
+13 -1
View File
@@ -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 email 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
+13 -1
View File
@@ -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
+13 -1
View File
@@ -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
+5
View File
@@ -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;
};
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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,
+13 -3
View File
@@ -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: {
+67 -30
View File
@@ -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),
},
+1 -1
View File
@@ -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}
+6 -6
View File
@@ -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>
))}
@@ -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" />
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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" />
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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}