mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 01:15:49 +10:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e1d85bc33 | |||
| 1102e833cc | |||
| 337f85f021 | |||
| 2332b0316b | |||
| 393b51d484 | |||
| 5a8335e0eb | |||
| 562d78e2d7 | |||
| 3b110cf70d | |||
| 7062fadf0b | |||
| 9cdd2e7ff9 | |||
| a70b0702c3 | |||
| 1f170ef5e5 | |||
| 8f68393241 | |||
| 381293af0c | |||
| 97835b8dbb | |||
| 977d07330b | |||
| 814eb51b26 | |||
| a16dd73ec1 | |||
| 5da2a2020e | |||
| d79b1d4612 | |||
| 3685acc0ab | |||
| eeea3651ee | |||
| 50997d7e92 | |||
| bc97af14d3 | |||
| 278dfa3d77 | |||
| 5b64137237 | |||
| b9b29e5a76 | |||
| 5b63b5deb9 |
@@ -180,6 +180,20 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=
|
|||||||
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
|
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
|
||||||
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
|
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
|
||||||
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
|
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
|
||||||
|
# OPTIONAL: Set to "true" to disable all signin methods (email, Google, Microsoft, OIDC).
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGNIN=
|
||||||
|
# OPTIONAL: Set to "true" to disable email/password signin only. Also closes /forgot-password and /reset-password.
|
||||||
|
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=
|
||||||
|
# OPTIONAL: Set to "true" to hide the Google signin button.
|
||||||
|
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=
|
||||||
|
# OPTIONAL: Set to "true" to hide the Microsoft signin button.
|
||||||
|
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=
|
||||||
|
# OPTIONAL: Set to "true" to hide the OIDC signin button.
|
||||||
|
NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=
|
||||||
|
# OPTIONAL: When OIDC is the only enabled signin transport, /signin auto-redirects
|
||||||
|
# to the OIDC provider (rendering only a spinner). Set to "true" to disable this
|
||||||
|
# and keep showing the signin page.
|
||||||
|
NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=
|
||||||
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
|
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
|
||||||
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,12 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
|
|||||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` |
|
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` |
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` |
|
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` |
|
||||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
|
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch. Disable all signin methods application-wide | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin. Also closes `/forgot-password` and `/reset-password` | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable the automatic `/signin` redirect when OIDC is the only enabled transport | `false` |
|
||||||
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
|
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
|
||||||
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
|
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
|
||||||
|
|
||||||
@@ -303,6 +309,44 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
|||||||
NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Sign-in Restrictions
|
||||||
|
|
||||||
|
You can control which methods are available for users to sign in with the following environment variables:
|
||||||
|
|
||||||
|
- **`NEXT_PUBLIC_DISABLE_SIGNIN`** (master switch): Set to `true` to block all signin methods (email/password, Google, Microsoft, OIDC). Hides every signin entry point on `/signin` and rejects email/password signin server-side with a `SIGNIN_DISABLED` error.
|
||||||
|
- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN`**: Set to `true` to disable email/password signin only. The email/password form is hidden, the `/forgot-password` and `/reset-password` pages redirect to `/signin`, and the corresponding server endpoints reject requests. SSO signin is unaffected.
|
||||||
|
- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNIN`**: Set to `true` to hide the matching SSO button on the signin page. Useful when an SSO provider is kept configured for account linking but not advertised as a signin entry point.
|
||||||
|
|
||||||
|
These flags are opt-in: when none are set, signin behaviour is unchanged from a stock Documenso instance.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Allow only OIDC signin (e.g. enterprise SSO-only)
|
||||||
|
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
|
||||||
|
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
|
||||||
|
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
|
||||||
|
|
||||||
|
# Or disable signin entirely
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGNIN="true"
|
||||||
|
```
|
||||||
|
|
||||||
|
### OIDC Auto-redirect
|
||||||
|
|
||||||
|
When OIDC is the only enabled signin transport on your instance, `/signin` automatically redirects users straight to the OIDC provider instead of showing the signin form. The page renders a spinner while the redirect happens. No extra configuration is required — disabling every other signin method is enough to trigger it.
|
||||||
|
|
||||||
|
- **`NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT`**: Set to `true` to opt out of the automatic redirect and keep rendering the signin page even when OIDC is the only enabled transport.
|
||||||
|
|
||||||
|
The redirect only triggers when OIDC is configured and email/password, Google, and Microsoft signin are all disabled. If any other transport remains enabled, the signin form is shown as normal.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OIDC-only signin: disabling all other methods auto-redirects to the provider
|
||||||
|
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
|
||||||
|
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
|
||||||
|
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
|
||||||
|
|
||||||
|
# Opt out of the auto-redirect while still OIDC-only
|
||||||
|
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## AI Features
|
## AI Features
|
||||||
@@ -446,6 +490,16 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
|
|||||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
||||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
|
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
|
||||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
||||||
|
|
||||||
|
# Sign-in restrictions (optional)
|
||||||
|
# NEXT_PUBLIC_DISABLE_SIGNIN="true"
|
||||||
|
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
|
||||||
|
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
|
||||||
|
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
|
||||||
|
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN="true"
|
||||||
|
|
||||||
|
# Opt out of the automatic OIDC redirect when OIDC is the only enabled transport (optional)
|
||||||
|
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -163,6 +163,19 @@ NEXT_PUBLIC_DISABLE_SIGNUP=false
|
|||||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
|
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
|
||||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
|
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
|
||||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
|
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
|
||||||
|
|
||||||
|
# Signin restrictions (optional)
|
||||||
|
# Master switch — disables every signin method
|
||||||
|
# NEXT_PUBLIC_DISABLE_SIGNIN=true
|
||||||
|
# Per-method switches (optional). Each disables that signin path.
|
||||||
|
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=true
|
||||||
|
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=true
|
||||||
|
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=true
|
||||||
|
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=true
|
||||||
|
|
||||||
|
# When OIDC is the only enabled transport, /signin auto-redirects to the provider.
|
||||||
|
# Set this to opt out and keep showing the signin page (optional).
|
||||||
|
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=true
|
||||||
```
|
```
|
||||||
|
|
||||||
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
|
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
|
|||||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` |
|
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` |
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` |
|
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` |
|
||||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
|
||||||
|
|
||||||
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
|
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,12 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
|
|||||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` |
|
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` |
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` |
|
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` |
|
||||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`| Hide the Microsoft signin button | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
|
||||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
|
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
|
||||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
|
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { plural } from '@lingui/core/macro';
|
||||||
|
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type EnvelopeBulkDownloadItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: DocumentStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EnvelopesBulkDownloadDialogProps = {
|
||||||
|
envelopes: EnvelopeBulkDownloadItem[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess?: (successfulEnvelopeIds: string[]) => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const EnvelopesBulkDownloadDialog = ({
|
||||||
|
envelopes,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
...props
|
||||||
|
}: EnvelopesBulkDownloadDialogProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [versionMap, setVersionMap] = useState<Record<string, 'signed' | 'original'>>({});
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const abortRef = useRef(false);
|
||||||
|
|
||||||
|
const trpcUtils = trpc.useUtils();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVersionMap(
|
||||||
|
Object.fromEntries(
|
||||||
|
envelopes.map((envelope) => [
|
||||||
|
envelope.id,
|
||||||
|
envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original',
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setProgress(0);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const getStatusLabel = (status: DocumentStatus) =>
|
||||||
|
match(status)
|
||||||
|
.with(DocumentStatus.COMPLETED, () => t`Completed`)
|
||||||
|
.with(DocumentStatus.PENDING, () => t`Pending`)
|
||||||
|
.with(DocumentStatus.DRAFT, () => t`Draft`)
|
||||||
|
.with(DocumentStatus.REJECTED, () => t`Rejected`)
|
||||||
|
.with(DocumentStatus.CANCELLED, () => t`Cancelled`)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
const onDownload = async () => {
|
||||||
|
if (envelopes.length === 0 || isDownloading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortRef.current = false;
|
||||||
|
setIsDownloading(true);
|
||||||
|
setProgress(0);
|
||||||
|
|
||||||
|
const successfulEnvelopeIds: string[] = [];
|
||||||
|
let failedDownloads = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const envelope of envelopes) {
|
||||||
|
if (abortRef.current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const downloadVersion = versionMap[envelope.id] ?? 'original';
|
||||||
|
|
||||||
|
const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
access: {
|
||||||
|
type: 'user',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const envelopeItem of envelopeItems) {
|
||||||
|
await downloadPDF({
|
||||||
|
envelopeItem,
|
||||||
|
token: undefined,
|
||||||
|
fileName: envelopeItem.title,
|
||||||
|
version: downloadVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
successfulEnvelopeIds.push(envelope.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
failedDownloads++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress((p) => p + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successfulEnvelopeIds.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`An error occurred while downloading the documents.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedDownloads > 0) {
|
||||||
|
toast({
|
||||||
|
title: t`Documents partially downloaded`,
|
||||||
|
description: t`${plural(successfulEnvelopeIds.length, {
|
||||||
|
one: '# document downloaded.',
|
||||||
|
other: '# documents downloaded.',
|
||||||
|
})} ${plural(failedDownloads, {
|
||||||
|
one: '# document could not be downloaded.',
|
||||||
|
other: '# documents could not be downloaded.',
|
||||||
|
})}`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
onSuccess?.(successfulEnvelopeIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Documents downloaded`,
|
||||||
|
description: plural(successfulEnvelopeIds.length, {
|
||||||
|
one: '# document has been downloaded.',
|
||||||
|
other: '# documents have been downloaded.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess?.(successfulEnvelopeIds);
|
||||||
|
onOpenChange(false);
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => {
|
||||||
|
if (!isDownloading) {
|
||||||
|
onOpenChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Download Documents</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Plural
|
||||||
|
value={envelopes.length}
|
||||||
|
one="Select the version to download for the selected document."
|
||||||
|
other="Select the version to download for each of the # selected documents."
|
||||||
|
/>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<fieldset disabled={isDownloading} className="space-y-4">
|
||||||
|
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||||
|
{envelopes.map((envelope) => {
|
||||||
|
const isCompleted = envelope.status === DocumentStatus.COMPLETED;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={envelope.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className="truncate text-sm font-medium text-foreground"
|
||||||
|
title={envelope.title}
|
||||||
|
>
|
||||||
|
{envelope.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{getStatusLabel(envelope.status)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCompleted && (
|
||||||
|
<Select
|
||||||
|
value={versionMap[envelope.id] ?? 'signed'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setVersionMap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[envelope.id]: value as 'signed' | 'original',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px] flex-shrink-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="signed">
|
||||||
|
<Trans context="Signed document (adjective)">Signed</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="original">
|
||||||
|
<Trans context="Original document (adjective)">Original</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDownloading && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<Trans>
|
||||||
|
Downloading {progress} / {envelopes.length}...
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (isDownloading) {
|
||||||
|
abortRef.current = true;
|
||||||
|
} else {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDownloading ? <Trans>Stop</Trans> : <Trans>Cancel</Trans>}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onDownload()}
|
||||||
|
loading={isDownloading}
|
||||||
|
disabled={envelopes.length === 0}
|
||||||
|
>
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -21,6 +21,8 @@ import { z } from 'zod';
|
|||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
import { useCspNonce } from '~/utils/nonce';
|
import { useCspNonce } from '~/utils/nonce';
|
||||||
|
|
||||||
|
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
|
||||||
@@ -71,38 +73,82 @@ export function BrandingPreferencesForm({
|
|||||||
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
|
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
|
||||||
const initialColors = parsedColors.success ? parsedColors.data : {};
|
const initialColors = parsedColors.success ? parsedColors.data : {};
|
||||||
|
|
||||||
|
// The saved state the form maps to. Used both as the reactive `values` source and as
|
||||||
|
// the explicit target for a Reset (see handleReset).
|
||||||
|
const savedValues: TBrandingPreferencesFormSchema = {
|
||||||
|
brandingEnabled: settings.brandingEnabled ?? null,
|
||||||
|
brandingUrl: settings.brandingUrl ?? '',
|
||||||
|
brandingLogo: undefined,
|
||||||
|
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
|
||||||
|
brandingColors: initialColors,
|
||||||
|
brandingCss: settings.brandingCss ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
const form = useForm<TBrandingPreferencesFormSchema>({
|
const form = useForm<TBrandingPreferencesFormSchema>({
|
||||||
values: {
|
values: savedValues,
|
||||||
brandingEnabled: settings.brandingEnabled ?? null,
|
|
||||||
brandingUrl: settings.brandingUrl ?? '',
|
|
||||||
brandingLogo: undefined,
|
|
||||||
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
|
|
||||||
brandingColors: initialColors,
|
|
||||||
brandingCss: settings.brandingCss ?? '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZBrandingPreferencesFormSchema),
|
resolver: zodResolver(ZBrandingPreferencesFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isBrandingEnabled = form.watch('brandingEnabled');
|
const isBrandingEnabled = form.watch('brandingEnabled');
|
||||||
|
|
||||||
|
const getSavedLogoPreviewUrl = () => {
|
||||||
|
if (!settings.brandingLogo) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = JSON.parse(settings.brandingLogo);
|
||||||
|
|
||||||
|
if (!('type' in file) || !('data' in file)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoUrl =
|
||||||
|
context === 'Team'
|
||||||
|
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
||||||
|
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
||||||
|
|
||||||
|
return `${logoUrl}?v=${Date.now()}`;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.brandingLogo) {
|
const savedLogoPreviewUrl = getSavedLogoPreviewUrl();
|
||||||
const file = JSON.parse(settings.brandingLogo);
|
|
||||||
|
|
||||||
if ('type' in file && 'data' in file) {
|
if (savedLogoPreviewUrl) {
|
||||||
const logoUrl =
|
setPreviewUrl(savedLogoPreviewUrl);
|
||||||
context === 'Team'
|
|
||||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
|
||||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
|
||||||
|
|
||||||
setPreviewUrl(logoUrl + '?v=' + Date.now());
|
|
||||||
setHasLoadedPreview(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasLoadedPreview(true);
|
setHasLoadedPreview(true);
|
||||||
}, [settings.brandingLogo]);
|
}, [settings.brandingLogo]);
|
||||||
|
|
||||||
|
// Reset the form to the saved values. The form is driven by the `values` prop (no
|
||||||
|
// `defaultValues`), so `reset()` with no argument doesn't re-baseline the dirty check;
|
||||||
|
// passing the saved values clears the per-field dirty tracking (dirtyFields).
|
||||||
|
const handleReset = () => {
|
||||||
|
setPreviewUrl(getSavedLogoPreviewUrl());
|
||||||
|
form.reset(savedValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
// `formState.isDirty` is unreliable for a `values`-driven form: after a reset (or a
|
||||||
|
// save + refetch) it can stay true even though every field already matches its saved
|
||||||
|
// value and `dirtyFields` is empty. Derive the flag from `dirtyFields` instead so the
|
||||||
|
// sticky save bar reliably disappears.
|
||||||
|
const hasUnsavedChanges = Object.keys(form.formState.dirtyFields).length > 0;
|
||||||
|
|
||||||
|
// Re-baseline the form to the just-saved state after a successful submit. The `values`
|
||||||
|
// prop re-syncs most fields once the route refetches, but write-only fields (the logo
|
||||||
|
// is a File that isn't reflected back into `values`) would otherwise stay dirty and
|
||||||
|
// keep the save bar visible. Relies on the page handler rethrowing on error so we only
|
||||||
|
// re-baseline on success.
|
||||||
|
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await onFormSubmit(data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset(form.getValues());
|
||||||
|
});
|
||||||
|
|
||||||
// Cleanup ObjectURL on unmount or when previewUrl changes
|
// Cleanup ObjectURL on unmount or when previewUrl changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -114,7 +160,7 @@ export function BrandingPreferencesForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleFormSubmit}>
|
||||||
<fieldset className="flex h-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="flex h-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -167,7 +213,7 @@ export function BrandingPreferencesForm({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative flex w-full flex-col gap-y-4">
|
<div className="relative flex w-full flex-col gap-y-4">
|
||||||
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
|
{!isBrandingEnabled && <div className="absolute inset-0 z-30 bg-background/60" />}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -321,7 +367,7 @@ export function BrandingPreferencesForm({
|
|||||||
|
|
||||||
{hasAdvancedBranding && (
|
{hasAdvancedBranding && (
|
||||||
<div className="relative flex w-full flex-col gap-y-6">
|
<div className="relative flex w-full flex-col gap-y-6">
|
||||||
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
|
{!isBrandingEnabled && <div className="absolute inset-0 z-30 bg-background/60" />}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -538,11 +584,11 @@ export function BrandingPreferencesForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
<FormStickySaveBar
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
isDirty={hasUnsavedChanges}
|
||||||
<Trans>Update</Trans>
|
isSubmitting={form.formState.isSubmitting}
|
||||||
</Button>
|
onReset={handleReset}
|
||||||
</div>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { ReminderSettingsPicker } from '@documenso/ui/components/document/remind
|
|||||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -46,6 +45,7 @@ import { z } from 'zod';
|
|||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
||||||
|
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can't infer this from the schema since we need to keep the schema inside the component to allow
|
* Can't infer this from the schema since we need to keep the schema inside the component to allow
|
||||||
@@ -147,9 +147,21 @@ export const DocumentPreferencesForm = ({
|
|||||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await onFormSubmit(data);
|
||||||
|
} catch {
|
||||||
|
// The page handler surfaces its own error toast. Keep the form dirty so
|
||||||
|
// the save bar stays visible and the user can retry.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset(data);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleFormSubmit}>
|
||||||
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||||
{!isPersonalLayoutMode && (
|
{!isPersonalLayoutMode && (
|
||||||
<FormField
|
<FormField
|
||||||
@@ -756,11 +768,11 @@ export const DocumentPreferencesForm = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
<FormStickySaveBar
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
isDirty={form.formState.isDirty}
|
||||||
<Trans>Update</Trans>
|
isSubmitting={form.formState.isSubmitting}
|
||||||
</Button>
|
onReset={() => form.reset()}
|
||||||
</div>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@
|
|||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -22,6 +21,8 @@ import type { TeamGlobalSettings } from '@prisma/client';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||||
|
|
||||||
const ZEmailPreferencesFormSchema = z.object({
|
const ZEmailPreferencesFormSchema = z.object({
|
||||||
emailId: z.string().nullable(),
|
emailId: z.string().nullable(),
|
||||||
emailReplyTo: zEmail().nullable(),
|
emailReplyTo: zEmail().nullable(),
|
||||||
@@ -59,9 +60,21 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
|||||||
|
|
||||||
const emails = emailData?.data || [];
|
const emails = emailData?.data || [];
|
||||||
|
|
||||||
|
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await onFormSubmit(data);
|
||||||
|
} catch {
|
||||||
|
// The page handler surfaces its own error toast. Keep the form dirty so
|
||||||
|
// the save bar stays visible and the user can retry.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset(data);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleFormSubmit}>
|
||||||
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||||
{organisation.organisationClaim.flags.emailDomains && (
|
{organisation.organisationClaim.flags.emailDomains && (
|
||||||
<FormField
|
<FormField
|
||||||
@@ -203,11 +216,11 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
<FormStickySaveBar
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
isDirty={form.formState.isDirty}
|
||||||
<Trans>Update</Trans>
|
isSubmitting={form.formState.isSubmitting}
|
||||||
</Button>
|
onReset={() => form.reset()}
|
||||||
</div>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { AlertTriangleIcon } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export type FormStickySaveBarProps = {
|
||||||
|
isDirty: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onReset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single `position: sticky` bar rendered at the bottom of the form.
|
||||||
|
*
|
||||||
|
* - When the form's end is on screen it settles into place as a plain footer (just the
|
||||||
|
* Reset / Save buttons).
|
||||||
|
* - When the form's end is scrolled off, it sticks to the bottom of the viewport and
|
||||||
|
* shows the "unsaved changes" pill chrome.
|
||||||
|
*
|
||||||
|
* Because it's the same element in the form's flow, it auto-aligns to the form and the
|
||||||
|
* float <-> dock hand-off is a native, scroll-linked transition (no measurement, no
|
||||||
|
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
|
||||||
|
* the pill chrome.
|
||||||
|
*/
|
||||||
|
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isStuck, setIsStuck] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current;
|
||||||
|
|
||||||
|
if (!sentinel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sentinel sits at the bar's resting position (the end of the form). While the
|
||||||
|
// bar is stuck to the bottom of the viewport the sentinel is scrolled past (out of
|
||||||
|
// view); once you reach the form's end it comes into view and the bar settles.
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
setIsStuck(!entry.isIntersecting);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: null,
|
||||||
|
rootMargin: '0px 0px -24px 0px',
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show the floating pill chrome only when there are unsaved changes AND the form's
|
||||||
|
// end is off screen.
|
||||||
|
const isFloating = isDirty && isStuck;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
data-testid="form-sticky-save-bar"
|
||||||
|
className={cn(
|
||||||
|
'z-40 flex min-h-9 min-w-0 items-center gap-x-2 rounded-lg py-4 transition-[margin,padding,background-color,border-color,box-shadow] duration-200 md:gap-x-4',
|
||||||
|
isDirty ? 'sticky bottom-6' : '',
|
||||||
|
// On mobile the docked and floating states are geometrically identical (only
|
||||||
|
// paint changes): a horizontal bleed there overflows the narrow viewport and
|
||||||
|
// fights the IntersectionObserver (oscillation + partial hiding). From `sm` up
|
||||||
|
// there's room, so we restore the original chrome — the island bleeds 8px past
|
||||||
|
// the form when floating, and the buttons sit flush with the fields when docked.
|
||||||
|
isFloating
|
||||||
|
? 'border border-border bg-background px-4 shadow-2xl sm:-mx-2'
|
||||||
|
: 'border border-transparent bg-transparent px-4 shadow-none sm:px-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isFloating && (
|
||||||
|
<motion.div
|
||||||
|
key="notice"
|
||||||
|
role="region"
|
||||||
|
aria-label={t`Unsaved changes`}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="flex min-h-9 min-w-0 items-center gap-x-2 text-sm"
|
||||||
|
>
|
||||||
|
<AlertTriangleIcon className="h-5 w-5 flex-shrink-0 text-destructive" />
|
||||||
|
<span className="font-medium text-xs md:text-sm">
|
||||||
|
<Trans>You have unsaved changes</Trans>
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
|
||||||
|
{isDirty && (
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
|
||||||
|
<Trans>Undo</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="shrink-0" size="sm" loading={isSubmitting} disabled={!isDirty}>
|
||||||
|
<Trans>Save changes</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sentinel: detects when the sticky bar is floating (stuck) vs settled (docked). */}
|
||||||
|
<div ref={sentinelRef} aria-hidden className="pointer-events-none h-px w-full" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,7 +4,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types';
|
import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@@ -12,11 +11,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||||
|
|
||||||
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
|
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
|
||||||
name: true,
|
name: true,
|
||||||
url: true,
|
url: true,
|
||||||
@@ -137,36 +137,11 @@ export const OrganisationUpdateForm = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
<FormStickySaveBar
|
||||||
<AnimatePresence>
|
isDirty={form.formState.isDirty}
|
||||||
{form.formState.isDirty && (
|
isSubmitting={form.formState.isSubmitting}
|
||||||
<motion.div
|
onReset={() => form.reset()}
|
||||||
initial={{
|
/>
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => form.reset()}>
|
|
||||||
<Trans>Reset</Trans>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="transition-opacity"
|
|
||||||
disabled={!form.formState.isDirty}
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<Trans>Update organisation</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
|||||||
export type SignInFormProps = {
|
export type SignInFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
|
isEmailPasswordSigninEnabled?: boolean;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
isMicrosoftSSOEnabled?: boolean;
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
@@ -68,6 +69,7 @@ export type SignInFormProps = {
|
|||||||
export const SignInForm = ({
|
export const SignInForm = ({
|
||||||
className,
|
className,
|
||||||
initialEmail,
|
initialEmail,
|
||||||
|
isEmailPasswordSigninEnabled = true,
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
isMicrosoftSSOEnabled,
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
@@ -324,66 +326,78 @@ export const SignInForm = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting || isPasskeyLoading}>
|
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting || isPasskeyLoading}>
|
||||||
<FormField
|
{isEmailPasswordSigninEnabled && (
|
||||||
control={form.control}
|
<>
|
||||||
name="email"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="email"
|
||||||
<FormLabel>
|
render={({ field }) => (
|
||||||
<Trans>Email</Trans>
|
<FormItem>
|
||||||
</FormLabel>
|
<FormLabel>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="email" {...field} />
|
<Input type="email" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Password</Trans>
|
<Trans>Password</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput {...field} />
|
<PasswordInput {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|
||||||
<p className="mt-2 text-right">
|
<p className="mt-2 text-right">
|
||||||
<Link to="/forgot-password" className="text-muted-foreground text-sm duration-200 hover:opacity-70">
|
<Link
|
||||||
<Trans>Forgot your password?</Trans>
|
to="/forgot-password"
|
||||||
</Link>
|
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
||||||
</p>
|
>
|
||||||
</FormItem>
|
<Trans>Forgot your password?</Trans>
|
||||||
)}
|
</Link>
|
||||||
/>
|
</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
|
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
|
||||||
<Turnstile
|
<Turnstile
|
||||||
ref={turnstileRef}
|
ref={turnstileRef}
|
||||||
siteKey={turnstileSiteKey}
|
siteKey={turnstileSiteKey}
|
||||||
options={{
|
options={{
|
||||||
size: 'flexible',
|
size: 'flexible',
|
||||||
appearance: 'always',
|
appearance: 'always',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
loading={isSubmitting}
|
||||||
|
className="dark:bg-documenso dark:hover:opacity-90"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" size="lg" loading={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
|
||||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{!isEmbeddedRedirect && (
|
{!isEmbeddedRedirect && (
|
||||||
<>
|
<>
|
||||||
{hasSocialAuthEnabled && (
|
{isEmailPasswordSigninEnabled && hasSocialAuthEnabled && (
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="h-px flex-1 bg-border" />
|
<div className="h-px flex-1 bg-border" />
|
||||||
<span className="bg-transparent text-muted-foreground">
|
<span className="bg-transparent text-muted-foreground">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types';
|
import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@@ -10,11 +9,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||||
|
|
||||||
export type UpdateTeamDialogProps = {
|
export type UpdateTeamDialogProps = {
|
||||||
teamId: number;
|
teamId: number;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
@@ -135,36 +135,11 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
<FormStickySaveBar
|
||||||
<AnimatePresence>
|
isDirty={form.formState.isDirty}
|
||||||
{form.formState.isDirty && (
|
isSubmitting={form.formState.isSubmitting}
|
||||||
<motion.div
|
onReset={() => form.reset()}
|
||||||
initial={{
|
/>
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => form.reset()}>
|
|
||||||
<Trans>Reset</Trans>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="transition-opacity"
|
|
||||||
disabled={!form.formState.isDirty}
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<Trans>Update team</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSafeHref } from '@documenso/lib/utils/is-http-url';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
@@ -53,7 +54,7 @@ export const DocumentSigningAttachmentsPopover = ({
|
|||||||
{attachments?.data.map((attachment) => (
|
{attachments?.data.map((attachment) => (
|
||||||
<a
|
<a
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
href={attachment.data}
|
href={toSafeHref(attachment.data)}
|
||||||
title={attachment.data}
|
title={attachment.data}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -24,7 +25,7 @@ export type DocumentAttachmentsPopoverProps = {
|
|||||||
|
|
||||||
const ZAttachmentFormSchema = z.object({
|
const ZAttachmentFormSchema = z.object({
|
||||||
label: z.string().min(1, 'Label is required'),
|
label: z.string().min(1, 'Label is required'),
|
||||||
url: z.string().url('Must be a valid URL'),
|
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
|
||||||
});
|
});
|
||||||
|
|
||||||
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||||
@@ -156,7 +157,7 @@ export const DocumentAttachmentsPopover = ({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-medium text-sm">{attachment.label}</p>
|
<p className="truncate font-medium text-sm">{attachment.label}</p>
|
||||||
<a
|
<a
|
||||||
href={attachment.data}
|
href={toSafeHref(attachment.data)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
|
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
|
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
@@ -22,7 +23,7 @@ export type EmbeddedEditorAttachmentPopoverProps = {
|
|||||||
|
|
||||||
const ZAttachmentFormSchema = z.object({
|
const ZAttachmentFormSchema = z.object({
|
||||||
label: z.string().min(1, 'Label is required'),
|
label: z.string().min(1, 'Label is required'),
|
||||||
url: z.string().url('Must be a valid URL'),
|
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
|
||||||
});
|
});
|
||||||
|
|
||||||
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||||
@@ -117,7 +118,7 @@ export const EmbeddedEditorAttachmentPopover = ({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-medium text-sm">{attachment.label}</p>
|
<p className="truncate font-medium text-sm">{attachment.label}</p>
|
||||||
<a
|
<a
|
||||||
href={attachment.data}
|
href={toSafeHref(attachment.data)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
|
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
|
||||||
|
|||||||
+92
-54
@@ -49,6 +49,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
|||||||
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||||
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the field was automatically selected on creation (drag-drop or marquee).
|
||||||
|
*
|
||||||
|
* We purposefully supress the floating toolbar for newly created fields.
|
||||||
|
*/
|
||||||
|
const [isAutoSelectedField, setIsAutoSelectedField] = useState(false);
|
||||||
|
|
||||||
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
|
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
|
||||||
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
|
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
|
||||||
pageData,
|
pageData,
|
||||||
@@ -237,10 +244,26 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
|||||||
fieldGroup.off('transformend');
|
fieldGroup.off('transformend');
|
||||||
fieldGroup.off('dragend');
|
fieldGroup.off('dragend');
|
||||||
|
|
||||||
// Set up field selection.
|
// Set up field selection. Shift + click toggles this field in/out of the current
|
||||||
fieldGroup.on('click', () => {
|
// multi-selection, so fields can be added to a group by clicking them --
|
||||||
|
// complementing marquee drag-selection. A plain click (no modifier) selects just
|
||||||
|
// this field.
|
||||||
|
fieldGroup.on('click', (event) => {
|
||||||
removePendingField();
|
removePendingField();
|
||||||
setSelectedFields([fieldGroup]);
|
|
||||||
|
const isMultiSelectModifier = event.evt.shiftKey;
|
||||||
|
|
||||||
|
if (isMultiSelectModifier) {
|
||||||
|
const currentNodes = interactiveTransformer.current?.nodes() ?? [];
|
||||||
|
const isAlreadySelected = currentNodes.includes(fieldGroup);
|
||||||
|
|
||||||
|
setSelectedFields(
|
||||||
|
isAlreadySelected ? currentNodes.filter((node) => node !== fieldGroup) : [...currentNodes, fieldGroup],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedFields([fieldGroup]);
|
||||||
|
}
|
||||||
|
|
||||||
pageLayer.current?.batchDraw();
|
pageLayer.current?.batchDraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -445,43 +468,18 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clicks should select/deselect shapes
|
// Clicking empty stage area clears the selection. Field clicks -- including
|
||||||
|
// Shift+click multi-select -- are handled by each field group's own click
|
||||||
|
// handler in `unsafeRenderFieldOnLayer`.
|
||||||
currentStage.on('click tap', (e) => {
|
currentStage.on('click tap', (e) => {
|
||||||
// if we are selecting with rect, do nothing
|
// If we are selecting with the marquee rectangle, do nothing.
|
||||||
if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
|
if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If empty area clicked, remove all selections
|
// If empty area clicked, remove all selections.
|
||||||
if (e.target === stage.current) {
|
if (e.target === stage.current) {
|
||||||
setSelectedFields([]);
|
setSelectedFields([]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do nothing if field not clicked, or if field is not editable
|
|
||||||
if (!e.target.hasName('field-group') || e.target.draggable() === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// do we pressed shift or ctrl?
|
|
||||||
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
|
|
||||||
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
|
|
||||||
|
|
||||||
if (!metaPressed && !isSelected) {
|
|
||||||
// if no key pressed and the node is not selected
|
|
||||||
// select just one
|
|
||||||
setSelectedFields([e.target]);
|
|
||||||
} else if (metaPressed && isSelected) {
|
|
||||||
// if we pressed keys and node was selected
|
|
||||||
// we need to remove it from selection:
|
|
||||||
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
|
|
||||||
// remove node from array
|
|
||||||
nodes.splice(nodes.indexOf(e.target), 1);
|
|
||||||
setSelectedFields(nodes);
|
|
||||||
} else if (metaPressed && !isSelected) {
|
|
||||||
// add the node into selection
|
|
||||||
const nodes = transformer.nodes().concat([e.target]);
|
|
||||||
setSelectedFields(nodes);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -521,13 +519,48 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
|||||||
setSelectedFields(liveSelectedFieldGroups);
|
setSelectedFields(liveSelectedFieldGroups);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirror the editor's single selected field onto the canvas (Konva) selection.
|
||||||
|
//
|
||||||
|
// `addField` already marks a newly created field as the selected field, so this
|
||||||
|
// makes a field placed via the palette (drag-drop) or marquee creation show its
|
||||||
|
// resize handles immediately -- no second click needed. It also clears the canvas
|
||||||
|
// selection when the selected field is cleared (e.g. when the author starts
|
||||||
|
// placing another field), so the floating action toolbar can't intercept the next
|
||||||
|
// placement click. Runs after the render loop above so the field's group exists.
|
||||||
|
const selectedFormId = editorFields.selectedField?.formId ?? null;
|
||||||
|
const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1;
|
||||||
|
|
||||||
|
if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) {
|
||||||
|
const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId;
|
||||||
|
|
||||||
|
if (!isAlreadySelected) {
|
||||||
|
const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`);
|
||||||
|
|
||||||
|
if (fieldGroupToSelect instanceof Konva.Group) {
|
||||||
|
setSelectedFields([fieldGroupToSelect], { isAutoSelect: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (selectedFormId === null && isSingleCanvasSelection) {
|
||||||
|
setSelectedFields([]);
|
||||||
|
}
|
||||||
|
|
||||||
// Rerender the transformer
|
// Rerender the transformer
|
||||||
interactiveTransformer.current?.forceUpdate();
|
interactiveTransformer.current?.forceUpdate();
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
}, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]);
|
}, [
|
||||||
|
localPageFields,
|
||||||
|
selectedKonvaFieldGroups,
|
||||||
|
overlappingFieldFormIds,
|
||||||
|
isFieldChanging,
|
||||||
|
editorFields.selectedField?.formId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setSelectedFields = (nodes: Konva.Node[], options?: { isAutoSelect?: boolean }) => {
|
||||||
|
// Any explicit (user-driven) selection shows the action toolbar; only auto-selection
|
||||||
|
// on field creation suppresses it.
|
||||||
|
setIsAutoSelectedField(Boolean(options?.isAutoSelect));
|
||||||
|
|
||||||
const setSelectedFields = (nodes: Konva.Node[]) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const fieldGroups = nodes.filter(
|
const fieldGroups = nodes.filter(
|
||||||
(node) => node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
|
(node) => node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
|
||||||
@@ -663,25 +696,30 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && (
|
{selectedKonvaFieldGroups.length > 0 &&
|
||||||
<FieldActionButtons
|
interactiveTransformer.current &&
|
||||||
handleDuplicateSelectedFields={duplicatedSelectedFields}
|
!isFieldChanging &&
|
||||||
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
|
!isAutoSelectedField && (
|
||||||
handleDeleteSelectedFields={deletedSelectedFields}
|
<FieldActionButtons
|
||||||
handleChangeRecipient={changeSelectedFieldsRecipients}
|
handleDuplicateSelectedFields={duplicatedSelectedFields}
|
||||||
handleChangeFieldType={changeSelectedFieldsType}
|
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
|
||||||
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
|
handleDeleteSelectedFields={deletedSelectedFields}
|
||||||
style={{
|
handleChangeRecipient={changeSelectedFieldsRecipients}
|
||||||
position: 'absolute',
|
handleChangeFieldType={changeSelectedFieldsType}
|
||||||
top: interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px',
|
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
|
||||||
left: interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px',
|
style={{
|
||||||
transform: 'translateX(-50%)',
|
position: 'absolute',
|
||||||
gap: '8px',
|
top:
|
||||||
pointerEvents: 'auto',
|
interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px',
|
||||||
zIndex: 50,
|
left:
|
||||||
}}
|
interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px',
|
||||||
/>
|
transform: 'translateX(-50%)',
|
||||||
)}
|
gap: '8px',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 50,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{pendingFieldCreation && (
|
{pendingFieldCreation && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export type EnvelopesTableBulkActionBarProps = {
|
export type EnvelopesTableBulkActionBarProps = {
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
|
onDownloadClick?: () => void;
|
||||||
onMoveClick: () => void;
|
onMoveClick: () => void;
|
||||||
onDeleteClick: () => void;
|
onDeleteClick: () => void;
|
||||||
onCancelClick?: () => void;
|
onCancelClick?: () => void;
|
||||||
@@ -12,6 +14,7 @@ export type EnvelopesTableBulkActionBarProps = {
|
|||||||
|
|
||||||
export const EnvelopesTableBulkActionBar = ({
|
export const EnvelopesTableBulkActionBar = ({
|
||||||
selectedCount,
|
selectedCount,
|
||||||
|
onDownloadClick,
|
||||||
onMoveClick,
|
onMoveClick,
|
||||||
onDeleteClick,
|
onDeleteClick,
|
||||||
onCancelClick,
|
onCancelClick,
|
||||||
@@ -19,37 +22,103 @@ export const EnvelopesTableBulkActionBar = ({
|
|||||||
}: EnvelopesTableBulkActionBarProps) => {
|
}: EnvelopesTableBulkActionBarProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClearSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [selectedCount, onClearSelection]);
|
||||||
|
|
||||||
if (selectedCount === 0) {
|
if (selectedCount === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
|
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10">
|
||||||
<span className="font-medium text-sm">
|
<div className="flex items-center gap-x-2 px-2">
|
||||||
<Trans>{selectedCount} selected</Trans>
|
<span className="sr-only" aria-live="polite">
|
||||||
</span>
|
<Trans>{selectedCount} selected</Trans>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-5 min-w-5 items-center justify-center rounded-md bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums"
|
||||||
|
>
|
||||||
|
{selectedCount}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" className="font-medium text-foreground text-sm max-[420px]:hidden">
|
||||||
|
<Trans>selected</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="h-6 w-px bg-border" />
|
<div className="mx-1 h-5 w-px bg-border" />
|
||||||
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
|
<Button
|
||||||
<FolderInputIcon className="mr-2 h-4 w-4" />
|
type="button"
|
||||||
<Trans>Move to Folder</Trans>
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onMoveClick}
|
||||||
|
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||||
|
>
|
||||||
|
<FolderInputIcon className="size-4 shrink-0" />
|
||||||
|
<Trans>Move</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{onDownloadClick && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDownloadClick}
|
||||||
|
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="size-4 shrink-0" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{onCancelClick && (
|
{onCancelClick && (
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
|
<Button
|
||||||
<XCircleIcon className="mr-2 h-4 w-4" />
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onCancelClick}
|
||||||
|
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="size-4 shrink-0" />
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
|
<Button
|
||||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDeleteClick}
|
||||||
|
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4 shrink-0" />
|
||||||
<Trans>Delete</Trans>
|
<Trans>Delete</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
|
<div className="mx-1 h-5 w-px bg-border" />
|
||||||
<XIcon className="h-4 w-4" />
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
aria-label={t`Clear selection`}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4 shrink-0" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ export default function OrganisationSettingsBrandingPage() {
|
|||||||
description: t`We were unable to update your branding preferences at this time, please try again later`,
|
description: t`We were unable to update your branding preferences at this time, please try again later`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rethrow so the form knows the save failed and keeps the unsaved changes.
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
description: t`We were unable to update your document preferences at this time, please try again later`,
|
description: t`We were unable to update your document preferences at this time, please try again later`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export default function OrganisationSettingsGeneral() {
|
|||||||
description: t`We were unable to update your email preferences at this time, please try again later`,
|
description: t`We were unable to update your email preferences at this time, please try again later`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,19 @@ import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
|
import {
|
||||||
|
EnvelopeType,
|
||||||
|
FolderType,
|
||||||
|
OrganisationType,
|
||||||
|
type DocumentStatus as PrismaDocumentStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
|
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
|
||||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||||
|
import { EnvelopesBulkDownloadDialog } from '~/components/dialogs/envelopes-bulk-download-dialog';
|
||||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
@@ -62,8 +68,12 @@ export default function DocumentsPage() {
|
|||||||
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
|
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
|
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
|
||||||
|
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<
|
||||||
|
Record<string, { title: string; status: PrismaDocumentStatus }>
|
||||||
|
>('documents-bulk-selection-meta', {});
|
||||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||||
|
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
|
||||||
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
|
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
|
||||||
|
|
||||||
const selectedEnvelopeIds = useMemo(() => {
|
const selectedEnvelopeIds = useMemo(() => {
|
||||||
@@ -95,6 +105,38 @@ export default function DocumentsPage() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEnvelopeMetaCache((prev) => {
|
||||||
|
const next: Record<string, { title: string; status: PrismaDocumentStatus }> = {};
|
||||||
|
|
||||||
|
for (const id of Object.keys(prev)) {
|
||||||
|
if (rowSelection[id]) {
|
||||||
|
next[id] = prev[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const document of data?.data ?? []) {
|
||||||
|
if (rowSelection[document.envelopeId]) {
|
||||||
|
next[document.envelopeId] = {
|
||||||
|
title: document.title,
|
||||||
|
status: document.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
|
||||||
|
|
||||||
|
const selectedEnvelopesForDownload = useMemo(() => {
|
||||||
|
return selectedEnvelopeIds
|
||||||
|
.map((id) => {
|
||||||
|
const meta = envelopeMetaCache[id];
|
||||||
|
return meta ? { id, title: meta.title, status: meta.status } : null;
|
||||||
|
})
|
||||||
|
.filter((item): item is { id: string; title: string; status: PrismaDocumentStatus } => item !== null);
|
||||||
|
}, [selectedEnvelopeIds, envelopeMetaCache]);
|
||||||
|
|
||||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
@@ -235,12 +277,28 @@ export default function DocumentsPage() {
|
|||||||
|
|
||||||
<EnvelopesTableBulkActionBar
|
<EnvelopesTableBulkActionBar
|
||||||
selectedCount={selectedEnvelopeIds.length}
|
selectedCount={selectedEnvelopeIds.length}
|
||||||
|
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
|
||||||
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
||||||
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||||
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
|
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
|
||||||
onClearSelection={() => setRowSelection({})}
|
onClearSelection={() => setRowSelection({})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EnvelopesBulkDownloadDialog
|
||||||
|
envelopes={selectedEnvelopesForDownload}
|
||||||
|
open={isBulkDownloadDialogOpen}
|
||||||
|
onOpenChange={setIsBulkDownloadDialogOpen}
|
||||||
|
onSuccess={(successfulEnvelopeIds) => {
|
||||||
|
setRowSelection((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const id of successfulEnvelopeIds) {
|
||||||
|
delete next[id];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<EnvelopesBulkMoveDialog
|
<EnvelopesBulkMoveDialog
|
||||||
envelopeIds={selectedEnvelopeIds}
|
envelopeIds={selectedEnvelopeIds}
|
||||||
envelopeType={EnvelopeType.DOCUMENT}
|
envelopeType={EnvelopeType.DOCUMENT}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { plural } from '@lingui/core/macro';
|
import { plural } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BrandingPreferencesForm,
|
BrandingPreferencesForm,
|
||||||
@@ -36,6 +39,8 @@ export default function TeamsSettingsPage() {
|
|||||||
|
|
||||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
||||||
|
|
||||||
|
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||||
|
|
||||||
const canCustomBranding =
|
const canCustomBranding =
|
||||||
organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED();
|
organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED();
|
||||||
|
|
||||||
@@ -94,6 +99,9 @@ export default function TeamsSettingsPage() {
|
|||||||
description: t`We were unable to update your branding preferences at this time, please try again later`,
|
description: t`We were unable to update your branding preferences at this time, please try again later`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rethrow so the form knows the save failed and keeps the unsaved changes.
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,39 +120,61 @@ export default function TeamsSettingsPage() {
|
|||||||
subtitle={t`Here you can set preferences and defaults for branding.`}
|
subtitle={t`Here you can set preferences and defaults for branding.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section>
|
{canConfigureBranding ? (
|
||||||
<BrandingPreferencesForm
|
<section>
|
||||||
canInherit={true}
|
<BrandingPreferencesForm
|
||||||
hasAdvancedBranding={canCustomBranding}
|
canInherit={true}
|
||||||
context="Team"
|
hasAdvancedBranding={canCustomBranding}
|
||||||
settings={teamWithSettings.teamSettings}
|
context="Team"
|
||||||
onFormSubmit={onBrandingPreferencesFormSubmit}
|
settings={teamWithSettings.teamSettings}
|
||||||
/>
|
onFormSubmit={onBrandingPreferencesFormSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
{cssWarnings.length > 0 && (
|
{cssWarnings.length > 0 && (
|
||||||
<Alert variant="warning" className="mt-6">
|
<Alert variant="warning" className="mt-6">
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>CSS rules were dropped during sanitisation</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
{cssWarnings.map((warning, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
{warning.detail}
|
||||||
|
{warning.line !== undefined && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{' '}
|
||||||
|
<Trans>(line {warning.line})</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>CSS rules were dropped during sanitisation</Trans>
|
<Trans>Branding Preferences</Trans>
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription>
|
<AlertDescription className="mr-2">
|
||||||
<ul className="list-disc pl-5">
|
<Trans>Currently branding can only be configured for Teams and above plans.</Trans>
|
||||||
{cssWarnings.map((warning, index) => (
|
|
||||||
<li key={index}>
|
|
||||||
{warning.detail}
|
|
||||||
{warning.line !== undefined && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{' '}
|
|
||||||
<Trans>(line {warning.line})</Trans>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||||
|
<Trans>Update Billing</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export default function TeamsSettingsPage() {
|
|||||||
description: t`We were unable to update your document preferences at this time, please try again later`,
|
description: t`We were unable to update your document preferences at this time, please try again later`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export default function TeamEmailSettingsGeneral() {
|
|||||||
description: t`We were unable to update your email preferences at this time, please try again later`,
|
description: t`We were unable to update your email preferences at this time, please try again later`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Link } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@@ -9,6 +10,14 @@ export function meta() {
|
|||||||
return appMetaTags(msg`Forgot Password`);
|
return appMetaTags(msg`Forgot Password`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
if (!isSigninEnabledForProvider('email')) {
|
||||||
|
throw redirect('/signin');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||||
import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity';
|
import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@@ -13,6 +14,10 @@ export function meta() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
if (!isSigninEnabledForProvider('email')) {
|
||||||
|
throw redirect('/signin');
|
||||||
|
}
|
||||||
|
|
||||||
const { token } = params;
|
const { token } = params;
|
||||||
|
|
||||||
const isValid = await getResetTokenValidity({ token });
|
const isValid = await getResetTokenValidity({ token });
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Link } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
@@ -9,6 +10,14 @@ export function meta() {
|
|||||||
return appMetaTags(msg`Reset Password`);
|
return appMetaTags(msg`Reset Password`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
if (!isSigninEnabledForProvider('email')) {
|
||||||
|
throw redirect('/signin');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import {
|
import {
|
||||||
IS_GOOGLE_SSO_ENABLED,
|
IS_GOOGLE_SSO_ENABLED,
|
||||||
IS_MICROSOFT_SSO_ENABLED,
|
IS_MICROSOFT_SSO_ENABLED,
|
||||||
|
IS_OIDC_AUTO_REDIRECT_DISABLED,
|
||||||
IS_OIDC_SSO_ENABLED,
|
IS_OIDC_SSO_ENABLED,
|
||||||
|
isSigninEnabledForProvider,
|
||||||
isSignupEnabledForProvider,
|
isSignupEnabledForProvider,
|
||||||
OIDC_PROVIDER_LABEL,
|
OIDC_PROVIDER_LABEL,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
@@ -11,6 +14,7 @@ import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, redirect, useSearchParams } from 'react-router';
|
import { Link, redirect, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
@@ -28,10 +32,20 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
const { isAuthenticated } = await getOptionalSession(request);
|
const { isAuthenticated } = await getOptionalSession(request);
|
||||||
|
|
||||||
// SSR env variables.
|
// SSR env variables.
|
||||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
|
||||||
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED && isSigninEnabledForProvider('google');
|
||||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED && isSigninEnabledForProvider('microsoft');
|
||||||
|
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED && isSigninEnabledForProvider('oidc');
|
||||||
|
|
||||||
|
// Automatically redirect to OIDC when it is the only enabled signin transport,
|
||||||
|
// unless the redirect has been explicitly disabled via env.
|
||||||
|
const isOIDCOnlyTransport =
|
||||||
|
isOIDCSSOEnabled && !isEmailPasswordSigninEnabled && !isGoogleSSOEnabled && !isMicrosoftSSOEnabled;
|
||||||
|
|
||||||
|
const shouldAutoRedirectToOIDC = isOIDCOnlyTransport && !IS_OIDC_AUTO_REDIRECT_DISABLED;
|
||||||
|
|
||||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||||
|
|
||||||
const isSignupEnabled =
|
const isSignupEnabled =
|
||||||
isSignupEnabledForProvider('email') ||
|
isSignupEnabledForProvider('email') ||
|
||||||
(IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google')) ||
|
(IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google')) ||
|
||||||
@@ -47,18 +61,28 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isEmailPasswordSigninEnabled,
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
isMicrosoftSSOEnabled,
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
isSignupEnabled,
|
isSignupEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
returnTo,
|
returnTo,
|
||||||
|
shouldAutoRedirectToOIDC,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, isSignupEnabled, oidcProviderLabel, returnTo } =
|
const {
|
||||||
loaderData;
|
isEmailPasswordSigninEnabled,
|
||||||
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
|
isOIDCSSOEnabled,
|
||||||
|
isSignupEnabled,
|
||||||
|
oidcProviderLabel,
|
||||||
|
returnTo,
|
||||||
|
shouldAutoRedirectToOIDC,
|
||||||
|
} = loaderData;
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
@@ -76,6 +100,27 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
|||||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldAutoRedirectToOIDC) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void authClient.oidc.signIn({ redirectPath: returnTo ?? '/' });
|
||||||
|
}, [shouldAutoRedirectToOIDC, returnTo]);
|
||||||
|
|
||||||
|
if (shouldAutoRedirectToOIDC) {
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
|
||||||
|
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans>Redirecting to {oidcProviderLabel || 'OIDC'}...</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div className="w-screen max-w-lg px-4">
|
||||||
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
|
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
|
||||||
@@ -95,6 +140,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
|||||||
<hr className="-mx-6 my-4" />
|
<hr className="-mx-6 my-4" />
|
||||||
|
|
||||||
<SignInForm
|
<SignInForm
|
||||||
|
isEmailPasswordSigninEnabled={isEmailPasswordSigninEnabled}
|
||||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ services:
|
|||||||
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP}
|
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP}
|
||||||
- NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=${NEXT_PUBLIC_DISABLE_OIDC_SIGNUP}
|
- NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=${NEXT_PUBLIC_DISABLE_OIDC_SIGNUP}
|
||||||
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
|
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
|
||||||
|
- NEXT_PUBLIC_DISABLE_SIGNIN=${NEXT_PUBLIC_DISABLE_SIGNIN}
|
||||||
|
- NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=${NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN}
|
||||||
|
- NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=${NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN}
|
||||||
|
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN}
|
||||||
|
- NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=${NEXT_PUBLIC_DISABLE_OIDC_SIGNIN}
|
||||||
|
- NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=${NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT}
|
||||||
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
|
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
|
||||||
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
|
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
|
||||||
- NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=${NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS}
|
- NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=${NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS}
|
||||||
|
|||||||
Generated
+4
-4
@@ -15,7 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/google-vertex": "3.0.81",
|
"@ai-sdk/google-vertex": "3.0.81",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@libpdf/core": "^0.4.0",
|
"@libpdf/core": "^0.4.1",
|
||||||
"@lingui/conf": "^5.6.0",
|
"@lingui/conf": "^5.6.0",
|
||||||
"@lingui/core": "^5.6.0",
|
"@lingui/core": "^5.6.0",
|
||||||
"@marsidev/react-turnstile": "^1.5.0",
|
"@marsidev/react-turnstile": "^1.5.0",
|
||||||
@@ -4661,9 +4661,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@libpdf/core": {
|
"node_modules/@libpdf/core": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.1.tgz",
|
||||||
"integrity": "sha512-G9nZRjf9DGDJaS/C23YWogk8akPM7O/6HfMslxVsKTKRbbbb+0szpQIetcGGUGRu7KtmBDmGDWCgz//DXSmq8A==",
|
"integrity": "sha512-DWGxWw1na8oFPixz+b6kOgu4tMD8xJLDgyPGVUNqIswO5POHAxsqmTvUvDW0IrwHP7kFRHBV3I3T/KhTABf9rA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "^2.2.0",
|
"@noble/ciphers": "^2.2.0",
|
||||||
|
|||||||
+1
-1
@@ -87,7 +87,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/google-vertex": "3.0.81",
|
"@ai-sdk/google-vertex": "3.0.81",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@libpdf/core": "^0.4.0",
|
"@libpdf/core": "^0.4.1",
|
||||||
"@lingui/conf": "^5.6.0",
|
"@lingui/conf": "^5.6.0",
|
||||||
"@lingui/core": "^5.6.0",
|
"@lingui/core": "^5.6.0",
|
||||||
"@prisma/extension-read-replicas": "^0.4.1",
|
"@prisma/extension-read-replicas": "^0.4.1",
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
|||||||
// Should be able to access organisation settings
|
// Should be able to access organisation settings
|
||||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible();
|
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
|
||||||
|
|
||||||
// Should have delete permissions
|
// Should have delete permissions
|
||||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@
|
|||||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
import { expect, test } from '@playwright/test';
|
import { type Download, expect, test } from '@playwright/test';
|
||||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||||
|
|
||||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||||
@@ -50,10 +50,10 @@ test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ p
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('2 selected')).toBeVisible();
|
await expect(page.getByText(/2\s*selected/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
|
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
|
||||||
@@ -67,7 +67,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
|
|||||||
|
|
||||||
await page.locator('thead').getByRole('checkbox').click();
|
await page.locator('thead').getByRole('checkbox').click();
|
||||||
|
|
||||||
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
|
await expect(page.getByText(new RegExp(`${documents.length}\\s*selected`))).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||||
@@ -80,11 +80,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('thead').getByRole('checkbox').click();
|
await page.locator('thead').getByRole('checkbox').click();
|
||||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Clear selection').click();
|
await page.getByLabel('Clear selection').click();
|
||||||
|
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
|
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
|
||||||
@@ -98,13 +98,13 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
|
|||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
|
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: folder.name }).click();
|
await page.getByRole('button', { name: folder.name }).click();
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
|
|
||||||
@@ -113,6 +113,43 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
|
|||||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[BULK_ACTIONS]: can bulk download multiple documents', async ({ page }) => {
|
||||||
|
const { sender } = await seedBulkActionsTestRequirements();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: sender.user.email,
|
||||||
|
redirectPath: `/t/${sender.team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
|
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Download', exact: true }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.getByText('Download Documents')).toBeVisible();
|
||||||
|
await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible();
|
||||||
|
await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible();
|
||||||
|
await expect(dialog.getByText('Draft').first()).toBeVisible();
|
||||||
|
|
||||||
|
const downloads: Download[] = [];
|
||||||
|
page.on('download', (d) => downloads.push(d));
|
||||||
|
|
||||||
|
await dialog.getByRole('button', { name: 'Download' }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => downloads.length, { timeout: 10_000 }).toBe(2);
|
||||||
|
|
||||||
|
expect(downloads.map((d) => d.suggestedFilename())).toEqual(
|
||||||
|
expect.arrayContaining(['Bulk Test Doc 1.pdf', 'Bulk Test Doc 2.pdf']),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectToastTextToBeVisible(page, 'Documents downloaded');
|
||||||
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
|
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
|
||||||
const { sender } = await seedBulkActionsTestRequirements();
|
const { sender } = await seedBulkActionsTestRequirements();
|
||||||
|
|
||||||
@@ -152,14 +189,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await page.getByRole('button', { name: folder.name }).click();
|
await page.getByRole('button', { name: folder.name }).click();
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||||
@@ -172,13 +209,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Documents deleted');
|
await expectToastTextToBeVisible(page, 'Documents deleted');
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||||
@@ -199,7 +236,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
|
|||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||||
@@ -236,14 +273,14 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
|
|||||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
|
||||||
|
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||||
|
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||||
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const requestMetadata = {
|
||||||
|
auth: null,
|
||||||
|
requestMetadata: {},
|
||||||
|
source: 'app' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
const canReadEnvelope = async (envelopeId: string, userId: number, teamId: number) => {
|
||||||
|
try {
|
||||||
|
await getEnvelopeWhereInput({
|
||||||
|
id: { type: 'envelopeId', id: envelopeId },
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
type: null,
|
||||||
|
}).then(({ envelopeWhereInput }) => prisma.envelope.findFirstOrThrow({ where: envelopeWhereInput }));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test('[DOCUMENTS]: a member cannot delete a document with restricted visibility', async () => {
|
||||||
|
const { user: owner, team } = await seedUser();
|
||||||
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||||
|
|
||||||
|
const envelope = await seedBlankDocument(owner, team.id, {
|
||||||
|
createDocumentOptions: {
|
||||||
|
visibility: DocumentVisibility.ADMIN,
|
||||||
|
status: DocumentStatus.DRAFT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The member cannot read an ADMIN-visibility document, so they must not be
|
||||||
|
// able to delete it either.
|
||||||
|
expect(await canReadEnvelope(envelope.id, member.id, team.id)).toBe(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
deleteDocument({
|
||||||
|
id: { type: 'envelopeId', id: envelope.id },
|
||||||
|
userId: member.id,
|
||||||
|
teamId: team.id,
|
||||||
|
requestMetadata,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
const stillExists = await prisma.envelope.findUnique({ where: { id: envelope.id } });
|
||||||
|
expect(stillExists).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENTS]: a manager cannot cancel a document with restricted visibility', async () => {
|
||||||
|
const { user: owner, team } = await seedUser();
|
||||||
|
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||||
|
|
||||||
|
const envelope = await seedBlankDocument(owner, team.id, {
|
||||||
|
createDocumentOptions: {
|
||||||
|
visibility: DocumentVisibility.ADMIN,
|
||||||
|
status: DocumentStatus.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// A manager outranks a member but still cannot read an ADMIN-visibility
|
||||||
|
// document, so cancellation must be blocked despite the sufficient role.
|
||||||
|
expect(await canReadEnvelope(envelope.id, manager.id, team.id)).toBe(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
cancelDocument({
|
||||||
|
id: { type: 'envelopeId', id: envelope.id },
|
||||||
|
userId: manager.id,
|
||||||
|
teamId: team.id,
|
||||||
|
reason: 'test-cancel',
|
||||||
|
requestMetadata,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
const after = await prisma.envelope.findUnique({ where: { id: envelope.id } });
|
||||||
|
expect(after?.status).toBe(DocumentStatus.PENDING);
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||||
|
import { alphaid } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||||
|
const token = `api_${alphaid(16)}`;
|
||||||
|
|
||||||
|
await prisma.apiToken.create({
|
||||||
|
data: { name: 'attachment-url-test', token: hashString(token), expires: null, userId, teamId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attachment URLs are rendered as link hrefs, so they must be restricted to
|
||||||
|
* http(s). The API must reject any other scheme.
|
||||||
|
*/
|
||||||
|
const NON_HTTP_URLS = [
|
||||||
|
'javascript:alert(document.cookie)',
|
||||||
|
'data:text/html,<script>alert(1)</script>',
|
||||||
|
'vbscript:msgbox(1)',
|
||||||
|
'file:///etc/passwd',
|
||||||
|
];
|
||||||
|
|
||||||
|
test('[ATTACHMENTS]: rejects attachment URLs with a non-http(s) protocol', async ({ request }) => {
|
||||||
|
const { team, owner } = await seedTeam();
|
||||||
|
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||||
|
|
||||||
|
const envelope = await seedBlankDocument(owner, team.id);
|
||||||
|
|
||||||
|
for (const url of NON_HTTP_URLS) {
|
||||||
|
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
data: { envelopeId: envelope.id, data: { label: 'attachment', data: url } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok(), `expected ${url} to be rejected`).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||||
|
expect(attachments).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ATTACHMENTS]: accepts attachment URLs with an http(s) protocol', async ({ request }) => {
|
||||||
|
const { team, owner } = await seedTeam();
|
||||||
|
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||||
|
|
||||||
|
const envelope = await seedBlankDocument(owner, team.id);
|
||||||
|
|
||||||
|
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
data: { envelopeId: envelope.id, data: { label: 'safe', data: 'https://example.com/file.pdf' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
|
||||||
|
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||||
|
expect(attachments).toHaveLength(1);
|
||||||
|
expect(attachments[0].data).toBe('https://example.com/file.pdf');
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||||
|
import { alphaid } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||||
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||||
|
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||||
|
const token = `api_${alphaid(16)}`;
|
||||||
|
|
||||||
|
await prisma.apiToken.create({
|
||||||
|
data: { name: 'attachment-access-test', token: hashString(token), expires: null, userId, teamId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token };
|
||||||
|
};
|
||||||
|
|
||||||
|
const canReadEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => {
|
||||||
|
const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.ok();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attachment create/update/delete/list must enforce document visibility, not
|
||||||
|
* just team membership. A member whose visibility tier excludes a restricted
|
||||||
|
* envelope must not be able to read or mutate its attachments.
|
||||||
|
*/
|
||||||
|
test('[ATTACHMENTS]: a member cannot create or delete attachments on a restricted document', async ({ request }) => {
|
||||||
|
const { team, owner } = await seedTeam();
|
||||||
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||||
|
|
||||||
|
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||||
|
|
||||||
|
const envelope = await seedBlankDocument(owner, team.id, {
|
||||||
|
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||||
|
|
||||||
|
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
|
||||||
|
data: { envelopeId: envelope.id, data: { label: 'attachment', data: 'https://example.com' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createRes.ok()).toBe(false);
|
||||||
|
|
||||||
|
// No attachment should have been created.
|
||||||
|
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||||
|
expect(attachments).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ATTACHMENTS]: a member cannot update an attachment on a restricted document', async ({ request }) => {
|
||||||
|
const { team, owner } = await seedTeam();
|
||||||
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||||
|
|
||||||
|
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||||
|
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||||
|
|
||||||
|
const envelope = await seedBlankDocument(owner, team.id, {
|
||||||
|
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The owner (who can see the document) creates the attachment.
|
||||||
|
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
|
||||||
|
data: { envelopeId: envelope.id, data: { label: 'original', data: 'https://example.com/original' } },
|
||||||
|
});
|
||||||
|
expect(createRes.ok()).toBe(true);
|
||||||
|
const attachment = await createRes.json();
|
||||||
|
|
||||||
|
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||||
|
|
||||||
|
const updateRes = await request.post(`${API_BASE_URL}/envelope/attachment/update`, {
|
||||||
|
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
|
||||||
|
data: { id: attachment.id, data: { label: 'tampered', data: 'https://example.com/tampered' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateRes.ok()).toBe(false);
|
||||||
|
|
||||||
|
const persisted = await prisma.envelopeAttachment.findUnique({ where: { id: attachment.id } });
|
||||||
|
expect(persisted?.label).toBe('original');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ATTACHMENTS]: a member cannot list attachments on a restricted document', async ({ request }) => {
|
||||||
|
const { team, owner } = await seedTeam();
|
||||||
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||||
|
|
||||||
|
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||||
|
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||||
|
|
||||||
|
const envelope = await seedBlankDocument(owner, team.id, {
|
||||||
|
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||||
|
});
|
||||||
|
|
||||||
|
await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
|
||||||
|
data: { envelopeId: envelope.id, data: { label: 'restricted', data: 'https://example.com/restricted' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||||
|
|
||||||
|
const findRes = await request.get(`${API_BASE_URL}/envelope/attachment?envelopeId=${envelope.id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${memberToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findRes.ok()).toBe(false);
|
||||||
|
|
||||||
|
const body = findRes.ok() ? await findRes.json() : null;
|
||||||
|
const attachments = body?.data ?? [];
|
||||||
|
expect(attachments).toHaveLength(0);
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
type TEnvelopeEditorSurface,
|
type TEnvelopeEditorSurface,
|
||||||
} from '../fixtures/envelope-editor';
|
} from '../fixtures/envelope-editor';
|
||||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||||
import { getKonvaElementCountForPage } from '../fixtures/konva';
|
import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
|
||||||
|
|
||||||
type TFieldFlowResult = {
|
type TFieldFlowResult = {
|
||||||
externalId: string;
|
externalId: string;
|
||||||
@@ -46,6 +46,7 @@ const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: str
|
|||||||
|
|
||||||
if (!surface.isEmbedded) {
|
if (!surface.isEmbedded) {
|
||||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||||
|
await surface.root.getByTestId('toast-close').click();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,6 +99,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number
|
|||||||
await canvas.click({ position, force: true });
|
await canvas.click({ position, force: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift+click a field on the canvas to toggle it in/out of the current multi-selection.
|
||||||
|
*/
|
||||||
|
const shiftClickFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
|
||||||
|
const canvas = root.locator('.konva-container canvas').first();
|
||||||
|
await expect(canvas).toBeVisible();
|
||||||
|
await root.waitForTimeout(300);
|
||||||
|
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
|
||||||
|
await canvas.click({ position, modifiers: ['Shift'], force: true });
|
||||||
|
};
|
||||||
|
|
||||||
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
|
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
|
||||||
const externalId = `e2e-fields-${nanoid()}`;
|
const externalId = `e2e-fields-${nanoid()}`;
|
||||||
|
|
||||||
@@ -760,9 +772,106 @@ const assertChangeFieldTypePersistedInDatabase = async ({
|
|||||||
expect(actualMetaTypes).toEqual(['date', 'date']);
|
expect(actualMetaTypes).toEqual(['date', 'date']);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Shift+click multi-select flow ---
|
||||||
|
|
||||||
|
type TShiftClickFlowResult = {
|
||||||
|
externalId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHIFT_CLICK_FIELD_POSITIONS = {
|
||||||
|
signature: { x: 150, y: 120 },
|
||||||
|
text: { x: 150, y: 260 },
|
||||||
|
name: { x: 150, y: 400 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const runShiftClickMultiSelectFlow = async (surface: TEnvelopeEditorSurface): Promise<TShiftClickFlowResult> => {
|
||||||
|
const externalId = `e2e-shift-click-${nanoid()}`;
|
||||||
|
const root = surface.root;
|
||||||
|
|
||||||
|
if (surface.isEmbedded && !surface.envelopeId) {
|
||||||
|
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateExternalId(surface, externalId);
|
||||||
|
await setupRecipientsForFieldPlacement(surface);
|
||||||
|
|
||||||
|
await clickEnvelopeEditorStep(root, 'addFields');
|
||||||
|
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Place three fields, spaced far enough apart that their action toolbars don't
|
||||||
|
// overlap a neighbouring field's click target.
|
||||||
|
await placeFieldOnPdf(root, 'Signature', SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||||
|
await placeFieldOnPdf(root, 'Text', SHIFT_CLICK_FIELD_POSITIONS.text);
|
||||||
|
await placeFieldOnPdf(root, 'Name', SHIFT_CLICK_FIELD_POSITIONS.name);
|
||||||
|
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
|
||||||
|
|
||||||
|
// A plain click selects exactly one field.
|
||||||
|
await selectFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||||
|
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||||
|
|
||||||
|
// Shift+click a second field ADDS it to the selection (the new behaviour).
|
||||||
|
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.text);
|
||||||
|
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||||
|
|
||||||
|
// Shift+click an already-selected field REMOVES it from the selection.
|
||||||
|
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||||
|
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||||
|
|
||||||
|
// Shift+click it again RE-ADDS it, leaving Signature + Text selected and Name excluded.
|
||||||
|
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||||
|
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||||
|
|
||||||
|
// Delete the two selected fields via the floating action toolbar. Only the
|
||||||
|
// un-selected Name field should remain -- proving the multi-selection contained
|
||||||
|
// exactly the two Shift-clicked fields.
|
||||||
|
await expect(root.locator('button[title="Remove"]')).toBeVisible();
|
||||||
|
await root.locator('button[title="Remove"]').click();
|
||||||
|
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
|
||||||
|
|
||||||
|
// Navigate away and back to verify persistence.
|
||||||
|
await clickEnvelopeEditorStep(root, 'upload');
|
||||||
|
await clickEnvelopeEditorStep(root, 'addFields');
|
||||||
|
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
|
||||||
|
|
||||||
|
return { externalId };
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertShiftClickMultiSelectPersistedInDatabase = async ({
|
||||||
|
surface,
|
||||||
|
externalId,
|
||||||
|
}: {
|
||||||
|
surface: TEnvelopeEditorSurface;
|
||||||
|
externalId: string;
|
||||||
|
}) => {
|
||||||
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
externalId,
|
||||||
|
userId: surface.userId,
|
||||||
|
teamId: surface.teamId,
|
||||||
|
type: surface.envelopeType,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { fields: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signature + Text were multi-selected via Shift+click and deleted; only Name remains.
|
||||||
|
expect(envelope.fields).toHaveLength(1);
|
||||||
|
expect(envelope.fields[0].type).toBe(FieldType.NAME);
|
||||||
|
};
|
||||||
|
|
||||||
// --- Test describe blocks ---
|
// --- Test describe blocks ---
|
||||||
|
|
||||||
test.describe('document editor', () => {
|
test.describe('document editor', () => {
|
||||||
|
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||||
|
const surface = await openDocumentEnvelopeEditor(page);
|
||||||
|
const result = await runShiftClickMultiSelectFlow(surface);
|
||||||
|
|
||||||
|
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('add and persist signature/text fields', async ({ page }) => {
|
test('add and persist signature/text fields', async ({ page }) => {
|
||||||
const surface = await openDocumentEnvelopeEditor(page);
|
const surface = await openDocumentEnvelopeEditor(page);
|
||||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||||
@@ -815,6 +924,16 @@ test.describe('document editor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('template editor', () => {
|
test.describe('template editor', () => {
|
||||||
|
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||||
|
const surface = await openTemplateEnvelopeEditor(page);
|
||||||
|
const result = await runShiftClickMultiSelectFlow(surface);
|
||||||
|
|
||||||
|
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('add and persist signature/text fields', async ({ page }) => {
|
test('add and persist signature/text fields', async ({ page }) => {
|
||||||
const surface = await openTemplateEnvelopeEditor(page);
|
const surface = await openTemplateEnvelopeEditor(page);
|
||||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||||
@@ -867,6 +986,21 @@ test.describe('template editor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('embedded create', () => {
|
test.describe('embedded create', () => {
|
||||||
|
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||||
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
|
envelopeType: 'DOCUMENT',
|
||||||
|
tokenNamePrefix: 'e2e-embed-shift-click',
|
||||||
|
});
|
||||||
|
const result = await runShiftClickMultiSelectFlow(surface);
|
||||||
|
|
||||||
|
await persistEmbeddedEnvelope(surface);
|
||||||
|
|
||||||
|
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('add and persist signature/text fields', async ({ page }) => {
|
test('add and persist signature/text fields', async ({ page }) => {
|
||||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
envelopeType: 'DOCUMENT',
|
envelopeType: 'DOCUMENT',
|
||||||
@@ -944,6 +1078,22 @@ test.describe('embedded create', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('embedded edit', () => {
|
test.describe('embedded edit', () => {
|
||||||
|
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||||
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
|
envelopeType: 'TEMPLATE',
|
||||||
|
mode: 'edit',
|
||||||
|
tokenNamePrefix: 'e2e-embed-shift-click',
|
||||||
|
});
|
||||||
|
const result = await runShiftClickMultiSelectFlow(surface);
|
||||||
|
|
||||||
|
await persistEmbeddedEnvelope(surface);
|
||||||
|
|
||||||
|
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('add and persist signature/text fields', async ({ page }) => {
|
test('add and persist signature/text fields', async ({ page }) => {
|
||||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
envelopeType: 'TEMPLATE',
|
envelopeType: 'TEMPLATE',
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait for the form to load.
|
// Wait for the form to load.
|
||||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||||
|
|
||||||
// Change the amount to 2.
|
// Change the amount to 2.
|
||||||
const amountInput = page.getByTestId('envelope-expiration-amount');
|
const amountInput = page.getByTestId('envelope-expiration-amount');
|
||||||
@@ -35,7 +35,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
|||||||
await unitTrigger.click();
|
await unitTrigger.click();
|
||||||
await page.getByRole('option', { name: 'Weeks' }).click();
|
await page.getByRole('option', { name: 'Weeks' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||||
|
|
||||||
// Verify via database.
|
// Verify via database.
|
||||||
@@ -57,14 +57,14 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
|
|||||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||||
|
|
||||||
// Find the mode select (shows "Custom duration") and change to "Never expires".
|
// Find the mode select (shows "Custom duration") and change to "Never expires".
|
||||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||||
await modeTrigger.click();
|
await modeTrigger.click();
|
||||||
await page.getByRole('option', { name: 'Never expires' }).click();
|
await page.getByRole('option', { name: 'Never expires' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||||
|
|
||||||
// Verify via database.
|
// Verify via database.
|
||||||
@@ -109,7 +109,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
|||||||
redirectPath: `/t/${team.url}/settings/document`,
|
redirectPath: `/t/${team.url}/settings/document`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||||
|
|
||||||
// The expiration picker mode select should show "Inherit from organisation" by default.
|
// The expiration picker mode select should show "Inherit from organisation" by default.
|
||||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||||
@@ -128,7 +128,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
|||||||
await unitTrigger.click();
|
await unitTrigger.click();
|
||||||
await page.getByRole('option', { name: 'Days' }).click();
|
await page.getByRole('option', { name: 'Days' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||||
|
|
||||||
// Verify team setting is overridden.
|
// Verify team setting is overridden.
|
||||||
|
|||||||
@@ -324,10 +324,7 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
.click();
|
.click();
|
||||||
await page.getByRole('option', { name: 'No' }).click();
|
await page.getByRole('option', { name: 'No' }).click();
|
||||||
|
|
||||||
await page
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
.getByRole('button', { name: /Update/ })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
@@ -347,10 +344,7 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
.getByRole('combobox')
|
.getByRole('combobox')
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole('option', { name: 'Yes' }).click();
|
await page.getByRole('option', { name: 'Yes' }).click();
|
||||||
await page
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
.getByRole('button', { name: /Update/ })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
|||||||
@@ -16,3 +16,35 @@ export const getKonvaElementCountForPage = async (page: Page, pageNumber: number
|
|||||||
{ pageNumber, elementSelector },
|
{ pageNumber, elementSelector },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns how many field groups are currently attached to the page's Konva
|
||||||
|
* transformer, i.e. the size of the active canvas selection. Used to assert
|
||||||
|
* multi-select behaviour (marquee drag and Shift+click).
|
||||||
|
*/
|
||||||
|
export const getKonvaTransformerNodeCountForPage = async (page: Page, pageNumber: number) => {
|
||||||
|
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
|
||||||
|
|
||||||
|
return await page.evaluate(
|
||||||
|
({ pageNumber }) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
|
||||||
|
|
||||||
|
const stage = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformer = stage.find('Transformer')[0];
|
||||||
|
|
||||||
|
if (!transformer) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
return (transformer as Konva.Transformer).nodes().length;
|
||||||
|
},
|
||||||
|
{ pageNumber },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
|
|||||||
await page.getByLabel('Organisation URL*').clear();
|
await page.getByLabel('Organisation URL*').clear();
|
||||||
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
|
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update organisation' }).click();
|
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||||
|
|
||||||
// Check we have been redirected to the new organisation URL and the name is updated.
|
// Check we have been redirected to the new organisation URL and the name is updated.
|
||||||
await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`);
|
await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`);
|
||||||
|
|||||||
@@ -340,3 +340,67 @@ test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', ()
|
|||||||
expect(deleted).toBeNull();
|
expect(deleted).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group membership scoping', () => {
|
||||||
|
test('cannot add a member from another organisation to a group', async ({ page }) => {
|
||||||
|
// Organisation A, where the actor is the owner/admin.
|
||||||
|
const { user: actor, organisation: organisationA } = await seedUser({
|
||||||
|
isPersonalOrganisation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A separate organisation B with a member the actor has no authority over.
|
||||||
|
const { organisation: organisationB } = await seedUser({ isPersonalOrganisation: false });
|
||||||
|
const [foreignUser] = await seedOrganisationMembers({
|
||||||
|
members: [{ name: 'Foreign', organisationRole: 'MEMBER' }],
|
||||||
|
organisationId: organisationB.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const foreignMember = await getOrganisationMember(foreignUser.id, organisationB.id);
|
||||||
|
|
||||||
|
// A custom group the actor legitimately controls in organisation A.
|
||||||
|
const groupA = await createCustomGroup(organisationA.id, 'MEMBER');
|
||||||
|
|
||||||
|
await apiSignin({ page, email: actor.email });
|
||||||
|
|
||||||
|
const res = await trpcMutation(page, 'organisation.group.update', {
|
||||||
|
id: groupA.id,
|
||||||
|
memberIds: [foreignMember.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
|
||||||
|
const injectedMembership = await prisma.organisationGroupMember.findFirst({
|
||||||
|
where: { groupId: groupA.id, organisationMemberId: foreignMember.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(injectedMembership).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can add a member from the same organisation to a group (positive control)', async ({ page }) => {
|
||||||
|
const { user: actor, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||||
|
|
||||||
|
const [memberUser] = await seedOrganisationMembers({
|
||||||
|
members: [{ name: 'Member', organisationRole: 'MEMBER' }],
|
||||||
|
organisationId: organisation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await getOrganisationMember(memberUser.id, organisation.id);
|
||||||
|
|
||||||
|
const group = await createCustomGroup(organisation.id, 'MEMBER');
|
||||||
|
|
||||||
|
await apiSignin({ page, email: actor.email });
|
||||||
|
|
||||||
|
const res = await trpcMutation(page, 'organisation.group.update', {
|
||||||
|
id: group.id,
|
||||||
|
memberIds: [member.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const membership = await prisma.organisationGroupMember.findFirst({
|
||||||
|
where: { groupId: group.id, organisationMemberId: member.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(membership).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
await page.getByRole('option', { name: 'No' }).click();
|
await page.getByRole('option', { name: 'No' }).click();
|
||||||
await page.getByTestId('include-signing-certificate-trigger').click();
|
await page.getByTestId('include-signing-certificate-trigger').click();
|
||||||
await page.getByRole('option', { name: 'No' }).click();
|
await page.getByRole('option', { name: 'No' }).click();
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||||
|
|
||||||
const teamSettings = await getTeamSettings({
|
const teamSettings = await getTeamSettings({
|
||||||
@@ -73,7 +73,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
await page.getByTestId('document-date-format-trigger').click();
|
await page.getByTestId('document-date-format-trigger').click();
|
||||||
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
|
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||||
|
|
||||||
const updatedTeamSettings = await getTeamSettings({
|
const updatedTeamSettings = await getTeamSettings({
|
||||||
@@ -128,7 +128,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
|||||||
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
|
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
|
||||||
await page.getByRole('textbox', { name: 'Brand Details' }).click();
|
await page.getByRole('textbox', { name: 'Brand Details' }).click();
|
||||||
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
|
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||||
|
|
||||||
const teamSettings = await getTeamSettings({
|
const teamSettings = await getTeamSettings({
|
||||||
@@ -150,7 +150,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
|||||||
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com');
|
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com');
|
||||||
await page.getByRole('textbox', { name: 'Brand Details' }).click();
|
await page.getByRole('textbox', { name: 'Brand Details' }).click();
|
||||||
await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails');
|
await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails');
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||||
|
|
||||||
const updatedTeamSettings = await getTeamSettings({
|
const updatedTeamSettings = await getTeamSettings({
|
||||||
@@ -165,7 +165,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
|||||||
// Test inheritance by setting team back to inherit from organisation
|
// Test inheritance by setting team back to inherit from organisation
|
||||||
await page.getByTestId('enable-branding').click();
|
await page.getByTestId('enable-branding').click();
|
||||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||||
|
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
@@ -208,7 +208,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
|||||||
await page.getByRole('checkbox', { name: 'Email the signer if the document is still pending' }).uncheck();
|
await page.getByRole('checkbox', { name: 'Email the signer if the document is still pending' }).uncheck();
|
||||||
await page.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' }).uncheck();
|
await page.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' }).uncheck();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||||
|
|
||||||
const teamSettings = await getTeamSettings({
|
const teamSettings = await getTeamSettings({
|
||||||
@@ -245,7 +245,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
|||||||
await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck();
|
await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck();
|
||||||
await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck();
|
await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||||
|
|
||||||
const updatedTeamSettings = await getTeamSettings({
|
const updatedTeamSettings = await getTeamSettings({
|
||||||
@@ -292,7 +292,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
|||||||
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
|
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
|
||||||
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
|
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
|
||||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||||
|
import { alphaid } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||||
|
import { seedCompletedDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||||
|
const token = `api_${alphaid(16)}`;
|
||||||
|
|
||||||
|
await prisma.apiToken.create({
|
||||||
|
data: { name: 'recipient-access-test', token: hashString(token), expires: null, userId, teamId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading a recipient exposes its signing token (a bearer credential), so the
|
||||||
|
* recipient read must enforce document visibility — a member who cannot read a
|
||||||
|
* restricted document must not be able to read its recipients either. This
|
||||||
|
* mirrors the field read, which is asserted as a control below.
|
||||||
|
*/
|
||||||
|
test('[RECIPIENT]: a member cannot read a recipient of a restricted document', async ({ request }) => {
|
||||||
|
const { team, owner } = await seedTeam();
|
||||||
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||||
|
|
||||||
|
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||||
|
|
||||||
|
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||||
|
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipient = await prisma.recipient.findFirstOrThrow({ where: { envelopeId: document.id } });
|
||||||
|
|
||||||
|
const res = await request.get(`${API_BASE_URL}/envelope/recipient/${recipient.id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${memberToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
|
||||||
|
const body = res.ok() ? await res.json() : null;
|
||||||
|
expect(body?.token).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[RECIPIENT]: a member cannot read a field of a restricted document', async ({ request }) => {
|
||||||
|
const { team, owner } = await seedTeam();
|
||||||
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||||
|
|
||||||
|
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||||
|
|
||||||
|
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||||
|
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||||
|
});
|
||||||
|
|
||||||
|
const field = await prisma.field.findFirst({ where: { envelopeId: document.id } });
|
||||||
|
|
||||||
|
test.skip(!field, 'No field seeded on completed document');
|
||||||
|
|
||||||
|
const res = await request.get(`${API_BASE_URL}/envelope/field/${field!.id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${memberToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
@@ -66,7 +66,7 @@ test('[TEAMS]: update team', async ({ page }) => {
|
|||||||
await page.getByLabel('Team URL*').clear();
|
await page.getByLabel('Team URL*').clear();
|
||||||
await page.getByLabel('Team URL*').fill(updatedTeamId);
|
await page.getByLabel('Team URL*').fill(updatedTeamId);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update team' }).click();
|
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||||
|
|
||||||
// Check we have been redirected to the new team URL and the name is updated.
|
// Check we have been redirected to the new team URL and the name is updated.
|
||||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
|
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, type Page, test } from '@playwright/test';
|
||||||
|
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls a team-group tRPC mutation directly, bypassing the UI.
|
||||||
|
*
|
||||||
|
* The UI only ever surfaces CUSTOM / INTERNAL_ORGANISATION groups, so these
|
||||||
|
* authorisation rules must be enforced on the server - a crafted request can
|
||||||
|
* target any `teamGroupId`, including the system-managed INTERNAL_TEAM groups.
|
||||||
|
*/
|
||||||
|
const callTeamGroupMutation = (
|
||||||
|
page: Page,
|
||||||
|
procedure: 'team.group.delete' | 'team.group.update',
|
||||||
|
teamId: number,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
) =>
|
||||||
|
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
|
||||||
|
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
|
||||||
|
data: JSON.stringify({ json: input }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every team is created with three system-managed INTERNAL_TEAM groups
|
||||||
|
* (admin/manager/member). They are the backbone of team-specific access and,
|
||||||
|
* like organisation internal groups, must not be deletable - deleting them
|
||||||
|
* silently strips team members of access while leaving the team row in place.
|
||||||
|
*/
|
||||||
|
test('[TEAMS]: internal team groups cannot be deleted via the API', async ({ page }) => {
|
||||||
|
// Member inheritance OFF: membership is granted exclusively through the team's
|
||||||
|
// INTERNAL_TEAM groups, so removing them is what causes the access loss.
|
||||||
|
const { user: owner, team } = await seedUser({ inheritMembers: false });
|
||||||
|
|
||||||
|
// A direct team member whose access depends on the INTERNAL_TEAM member group.
|
||||||
|
const directMember = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: owner.email });
|
||||||
|
|
||||||
|
const internalTeamGroups = await prisma.teamGroup.findMany({
|
||||||
|
where: {
|
||||||
|
teamId: team.id,
|
||||||
|
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// admin + manager + member.
|
||||||
|
expect(internalTeamGroups).toHaveLength(3);
|
||||||
|
|
||||||
|
for (const group of internalTeamGroups) {
|
||||||
|
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
|
||||||
|
teamId: team.id,
|
||||||
|
teamGroupId: group.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status(), `INTERNAL_TEAM ${group.teamRole} group must not be deletable`).not.toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// None of the internal groups were removed.
|
||||||
|
const remaining = await prisma.teamGroup.count({
|
||||||
|
where: {
|
||||||
|
teamId: team.id,
|
||||||
|
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(remaining).toBe(3);
|
||||||
|
|
||||||
|
// The direct member therefore keeps their team access.
|
||||||
|
const memberStillHasAccess = await prisma.teamGroup.findFirst({
|
||||||
|
where: {
|
||||||
|
teamId: team.id,
|
||||||
|
organisationGroup: {
|
||||||
|
type: OrganisationGroupType.INTERNAL_TEAM,
|
||||||
|
organisationGroupMembers: {
|
||||||
|
some: { organisationMember: { userId: directMember.id } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(memberStillHasAccess).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards against over-blocking: user-created (CUSTOM) team groups are not
|
||||||
|
* internal and must remain removable by team managers/admins.
|
||||||
|
*/
|
||||||
|
test('[TEAMS]: custom team groups can still be deleted', async ({ page }) => {
|
||||||
|
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
|
||||||
|
|
||||||
|
const customGroup = await prisma.organisationGroup.create({
|
||||||
|
data: {
|
||||||
|
id: generateDatabaseId('org_group'),
|
||||||
|
name: `custom-${team.url}`,
|
||||||
|
type: OrganisationGroupType.CUSTOM,
|
||||||
|
organisationRole: OrganisationMemberRole.MEMBER,
|
||||||
|
organisationId: organisation.id,
|
||||||
|
teamGroups: {
|
||||||
|
create: {
|
||||||
|
id: generateDatabaseId('team_group'),
|
||||||
|
teamId: team.id,
|
||||||
|
teamRole: TeamMemberRole.MEMBER,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { teamGroups: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const customTeamGroup = customGroup.teamGroups[0];
|
||||||
|
|
||||||
|
await apiSignin({ page, email: owner.email });
|
||||||
|
|
||||||
|
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
|
||||||
|
teamId: team.id,
|
||||||
|
teamGroupId: customTeamGroup.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
const deleted = await prisma.teamGroup.findUnique({ where: { id: customTeamGroup.id } });
|
||||||
|
|
||||||
|
expect(deleted).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same root cause affects updates: an INTERNAL_TEAM group's role must not be
|
||||||
|
* editable either, otherwise a team admin could rewrite the backbone roles
|
||||||
|
* (e.g. promote the member group to admin).
|
||||||
|
*/
|
||||||
|
test('[TEAMS]: internal team groups cannot be updated via the API', async ({ page }) => {
|
||||||
|
const { user: owner, team } = await seedUser({ inheritMembers: false });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: owner.email });
|
||||||
|
|
||||||
|
const internalMemberGroup = await prisma.teamGroup.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
teamId: team.id,
|
||||||
|
teamRole: TeamMemberRole.MEMBER,
|
||||||
|
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await callTeamGroupMutation(page, 'team.group.update', team.id, {
|
||||||
|
id: internalMemberGroup.id,
|
||||||
|
data: { teamRole: TeamMemberRole.ADMIN },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status()).not.toBe(200);
|
||||||
|
|
||||||
|
const reloaded = await prisma.teamGroup.findUniqueOrThrow({ where: { id: internalMemberGroup.id } });
|
||||||
|
|
||||||
|
expect(reloaded.teamRole).toBe(TeamMemberRole.MEMBER);
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||||
|
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editing the team public profile is a team-management action and must require
|
||||||
|
* MANAGE_TEAM, consistent with renaming the team or changing its URL.
|
||||||
|
*/
|
||||||
|
test('[TEAMS]: a member cannot edit the team public profile', async ({ page }) => {
|
||||||
|
const { team, owner } = await seedTeam();
|
||||||
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: member.email });
|
||||||
|
|
||||||
|
const profileRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
|
||||||
|
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
|
||||||
|
data: JSON.stringify({
|
||||||
|
json: {
|
||||||
|
teamId: team.id,
|
||||||
|
data: { profileEnabled: true, profileBio: 'edited-by-member' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profileRes.status()).not.toBe(200);
|
||||||
|
|
||||||
|
const profile = await prisma.teamProfile.findUnique({ where: { teamId: team.id } });
|
||||||
|
expect(profile?.enabled ?? false).toBe(false);
|
||||||
|
expect(profile?.bio ?? '').not.toBe('edited-by-member');
|
||||||
|
|
||||||
|
// The name/url path of the same route is also management-gated.
|
||||||
|
const nameRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
|
||||||
|
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
|
||||||
|
data: JSON.stringify({
|
||||||
|
json: { teamId: team.id, data: { name: 'renamed-by-member' } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nameRes.status()).not.toBe(200);
|
||||||
|
|
||||||
|
const reloaded = await prisma.team.findUnique({ where: { id: team.id } });
|
||||||
|
expect(reloaded?.name).not.toBe('renamed-by-member');
|
||||||
|
|
||||||
|
expect(owner.id).toBeTruthy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test('[TEAMS]: settings save bar docks at the bottom of the form', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByLabel('Team Name*')).toBeVisible();
|
||||||
|
|
||||||
|
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||||
|
|
||||||
|
// Pristine: the docked Save button is present but disabled; no Undo, no floating notice.
|
||||||
|
await expect(saveButton).toBeVisible();
|
||||||
|
await expect(saveButton).toBeDisabled();
|
||||||
|
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
|
||||||
|
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Make a change → Save enables and Undo appears.
|
||||||
|
const updatedName = `team-${Date.now()}`;
|
||||||
|
await page.getByLabel('Team Name*').clear();
|
||||||
|
await page.getByLabel('Team Name*').fill(updatedName);
|
||||||
|
|
||||||
|
await expect(saveButton).toBeEnabled();
|
||||||
|
await expect(page.getByRole('button', { name: 'Undo' })).toBeVisible();
|
||||||
|
|
||||||
|
// Undo → value restored, Save disabled again, Undo gone.
|
||||||
|
await page.getByRole('button', { name: 'Undo' }).click();
|
||||||
|
await expect(page.getByLabel('Team Name*')).toHaveValue(team.name);
|
||||||
|
await expect(saveButton).toBeDisabled();
|
||||||
|
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Change again → Save → success toast, returns to a pristine (disabled) state.
|
||||||
|
await page.getByLabel('Team Name*').clear();
|
||||||
|
await page.getByLabel('Team Name*').fill(updatedName);
|
||||||
|
await expect(saveButton).toBeEnabled();
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Your team has been successfully updated.').first()).toBeVisible();
|
||||||
|
await expect(saveButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ORGANISATIONS]: settings save bar floats when the form footer is off-screen', async ({ page }) => {
|
||||||
|
const { user, organisation } = await seedUser({
|
||||||
|
isPersonalOrganisation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the long document-preferences form to load.
|
||||||
|
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||||
|
|
||||||
|
// Pristine: no floating notice even though the footer is below the fold.
|
||||||
|
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Edit a field near the top → the footer is off-screen, so the floating pill appears.
|
||||||
|
await page.getByTestId('document-language-trigger').click();
|
||||||
|
await page.getByRole('option', { name: 'German' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('You have unsaved changes')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||||
|
|
||||||
|
// Scroll to the footer → the floating pill merges into the docked buttons and the
|
||||||
|
// notice disappears.
|
||||||
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||||
|
|
||||||
|
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -75,7 +75,7 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
|||||||
await item.click();
|
await item.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
|
|
||||||
// Wait for the update to complete
|
// Wait for the update to complete
|
||||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||||
@@ -140,7 +140,7 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
|||||||
await item.click();
|
await item.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
|
|
||||||
// Wait for finish
|
// Wait for finish
|
||||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ p
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('2 selected')).toBeVisible();
|
await expect(page.getByText(/2\s*selected/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
|
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
|
||||||
@@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
|
|||||||
|
|
||||||
await page.locator('thead').getByRole('checkbox').click();
|
await page.locator('thead').getByRole('checkbox').click();
|
||||||
|
|
||||||
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
|
await expect(page.getByText(new RegExp(`${templates.length}\\s*selected`))).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||||
@@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('thead').getByRole('checkbox').click();
|
await page.locator('thead').getByRole('checkbox').click();
|
||||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Clear selection').click();
|
await page.getByLabel('Clear selection').click();
|
||||||
|
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
|
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
|
||||||
@@ -97,13 +97,13 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page })
|
|||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
|
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: folder.name }).click();
|
await page.getByRole('button', { name: folder.name }).click();
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
|
|
||||||
@@ -151,14 +151,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await page.getByRole('button', { name: folder.name }).click();
|
await page.getByRole('button', { name: folder.name }).click();
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||||
@@ -171,13 +171,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Templates deleted');
|
await expectToastTextToBeVisible(page, 'Templates deleted');
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||||
@@ -199,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
|
|||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||||
@@ -236,14 +236,14 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p
|
|||||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const AuthenticationErrorCode = {
|
|||||||
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
|
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
|
||||||
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
|
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
|
||||||
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
|
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
|
||||||
|
SigninDisabled: 'SIGNIN_DISABLED',
|
||||||
SignupDisabled: 'SIGNUP_DISABLED',
|
SignupDisabled: 'SIGNUP_DISABLED',
|
||||||
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
|
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
|
||||||
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
|
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
isDisposableEmail,
|
isDisposableEmail,
|
||||||
isEmailDomainAllowedForSignup,
|
isEmailDomainAllowedForSignup,
|
||||||
|
isSigninEnabledForProvider,
|
||||||
isSignupEnabledForProvider,
|
isSignupEnabledForProvider,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
|
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
|
||||||
@@ -64,6 +65,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||||||
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
|
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
|
||||||
const requestMetadata = c.get('requestMetadata');
|
const requestMetadata = c.get('requestMetadata');
|
||||||
|
|
||||||
|
if (!isSigninEnabledForProvider('email')) {
|
||||||
|
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json');
|
const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json');
|
||||||
|
|
||||||
const loginLimitResult = await loginRateLimit.check({
|
const loginLimitResult = await loginRateLimit.check({
|
||||||
@@ -244,6 +251,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||||||
const { password, currentPassword } = c.req.valid('json');
|
const { password, currentPassword } = c.req.valid('json');
|
||||||
const requestMetadata = c.get('requestMetadata');
|
const requestMetadata = c.get('requestMetadata');
|
||||||
|
|
||||||
|
if (!isSigninEnabledForProvider('email')) {
|
||||||
|
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { session, user } = await getSession(c);
|
const { session, user } = await getSession(c);
|
||||||
|
|
||||||
await updatePassword({
|
await updatePassword({
|
||||||
@@ -346,6 +359,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||||||
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
|
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
|
||||||
const requestMetadata = c.get('requestMetadata');
|
const requestMetadata = c.get('requestMetadata');
|
||||||
|
|
||||||
|
if (!isSigninEnabledForProvider('email')) {
|
||||||
|
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { email } = c.req.valid('json');
|
const { email } = c.req.valid('json');
|
||||||
|
|
||||||
const forgotLimitResult = await forgotPasswordRateLimit.check({
|
const forgotLimitResult = await forgotPasswordRateLimit.check({
|
||||||
@@ -377,6 +396,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||||||
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
|
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
|
||||||
const requestMetadata = c.get('requestMetadata');
|
const requestMetadata = c.get('requestMetadata');
|
||||||
|
|
||||||
|
if (!isSigninEnabledForProvider('email')) {
|
||||||
|
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { token, password } = c.req.valid('json');
|
const { token, password } = c.req.valid('json');
|
||||||
|
|
||||||
const resetLimitResult = await resetPasswordRateLimit.check({
|
const resetLimitResult = await resetPasswordRateLimit.check({
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ export const TemplateDocumentCompleted = ({
|
|||||||
<Section className="mb-4">
|
<Section className="mb-4">
|
||||||
<Column align="center">
|
<Column align="center">
|
||||||
<Text className="font-semibold text-base text-foreground">
|
<Text className="font-semibold text-base text-foreground">
|
||||||
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
<Img
|
||||||
|
src={getAssetUrl('/static/completed.png')}
|
||||||
|
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
<Trans>Completed</Trans>
|
<Trans>Completed</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -47,7 +51,7 @@ export const TemplateDocumentCompleted = ({
|
|||||||
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
||||||
href={downloadLink}
|
href={downloadLink}
|
||||||
>
|
>
|
||||||
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
|
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
|
||||||
<Trans>Download</Trans>
|
<Trans>Download</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
|
|||||||
<Section className="mb-4">
|
<Section className="mb-4">
|
||||||
<Column align="center">
|
<Column align="center">
|
||||||
<Text className="font-semibold text-base text-foreground">
|
<Text className="font-semibold text-base text-foreground">
|
||||||
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" alt="" />
|
||||||
<Trans>Waiting for others</Trans>
|
<Trans>Waiting for others</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Column>
|
</Column>
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export const TemplateDocumentRecipientSigned = ({
|
|||||||
<Section className="mb-4">
|
<Section className="mb-4">
|
||||||
<Column align="center">
|
<Column align="center">
|
||||||
<Text className="font-semibold text-base text-foreground">
|
<Text className="font-semibold text-base text-foreground">
|
||||||
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
<Img
|
||||||
|
src={getAssetUrl('/static/completed.png')}
|
||||||
|
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
<Trans>Completed</Trans>
|
<Trans>Completed</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Column>
|
</Column>
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
|
|||||||
<Section>
|
<Section>
|
||||||
<Column align="center">
|
<Column align="center">
|
||||||
<Text className="font-semibold text-base text-foreground">
|
<Text className="font-semibold text-base text-foreground">
|
||||||
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
<Img
|
||||||
|
src={getAssetUrl('/static/completed.png')}
|
||||||
|
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
<Trans>Completed</Trans>
|
<Trans>Completed</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -51,7 +55,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
|
|||||||
href={signUpUrl}
|
href={signUpUrl}
|
||||||
className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
||||||
>
|
>
|
||||||
<Img src={getAssetUrl('/static/user-plus.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
|
<Img
|
||||||
|
src={getAssetUrl('/static/user-plus.png')}
|
||||||
|
className="mr-2 mb-0.5 inline h-5 w-5 align-middle"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
<Trans>Create account</Trans>
|
<Trans>Create account</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -59,7 +67,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
|
|||||||
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
||||||
href="https://documenso.com/pricing"
|
href="https://documenso.com/pricing"
|
||||||
>
|
>
|
||||||
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
|
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
|
||||||
<Trans>View plans</Trans>
|
<Trans>View plans</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: Template
|
|||||||
return new URL(path, assetBaseUrl).toString();
|
return new URL(path, assetBaseUrl).toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} />;
|
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} alt="" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TemplateImage;
|
export default TemplateImage;
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ export const AccessAuth2FAEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ export const AdminUserCreatedTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ export const BulkSendCompleteEmail = ({
|
|||||||
}: BulkSendCompleteEmailProps) => {
|
}: BulkSendCompleteEmailProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const previewText = msg`Bulk send operation complete for template "${templateName}"`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ export const ConfirmEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export const ConfirmTeamEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background">
|
<Section className="bg-background">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ export const DocumentCancelTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export const DocumentCompletedEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background">
|
<Section className="bg-background">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||||
<Section className="p-2">
|
<Section className="p-2">
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background">
|
<Section className="bg-background">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||||
<Section className="p-2">
|
<Section className="p-2">
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
@@ -85,7 +86,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
<Text className="my-4 font-semibold text-base">
|
<Text className="my-4 font-semibold text-base">
|
||||||
<Trans>
|
<Trans>
|
||||||
{inviterName}{' '}
|
{inviterName}{' '}
|
||||||
<Link className="font-normal text-muted-foreground" href="mailto:{inviterEmail}">
|
<Link className="font-normal text-muted-foreground" href={`mailto:${inviterEmail}`}>
|
||||||
({inviterEmail})
|
({inviterEmail})
|
||||||
</Link>
|
</Link>
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export const DocumentPendingEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background">
|
<Section className="bg-background">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export const DocumentRecipientSignedEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background">
|
<Section className="bg-background">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||||
<Section className="p-2">
|
<Section className="p-2">
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ export function DocumentRejectedEmail({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{previewText}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ export function DocumentRejectionConfirmedEmail({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{previewText}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ export const DocumentReminderEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export const DocumentSelfSignedEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background">
|
<Section className="bg-background">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||||
<Section className="p-2">
|
<Section className="p-2">
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ export const DocumentSuperDeleteEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ export const ForgotPasswordTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background">
|
<Section className="bg-background">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ export const OrganisationDeleteEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background text-muted-foreground">
|
<Section className="bg-background text-muted-foreground">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export const OrganisationInviteEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background text-muted-foreground">
|
<Section className="bg-background text-muted-foreground">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ export const OrganisationJoinEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background text-muted-foreground">
|
<Section className="bg-background text-muted-foreground">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ export const OrganisationLeaveEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background text-muted-foreground">
|
<Section className="bg-background text-muted-foreground">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export const OrganisationLimitAlertEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background text-muted-foreground">
|
<Section className="bg-background text-muted-foreground">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ export const RecipientExpiredTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ export const RecipientRemovedFromDocumentTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ export const ResetPasswordTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto bg-background font-sans">
|
<Body className="mx-auto my-auto bg-background font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export const TeamDeleteEmailTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background text-muted-foreground">
|
<Section className="bg-background text-muted-foreground">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export const TeamEmailRemovedTemplate = ({
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{_(previewText)}</Preview>
|
|
||||||
|
|
||||||
<Body className="mx-auto my-auto font-sans">
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
<Section className="bg-background text-muted-foreground">
|
<Section className="bg-background text-muted-foreground">
|
||||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export const IS_OIDC_SSO_ENABLED = Boolean(
|
|||||||
|
|
||||||
export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
|
export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-out flag for the automatic OIDC redirect.
|
||||||
|
*
|
||||||
|
* When OIDC is the only enabled signin transport we redirect to the provider
|
||||||
|
* automatically. Set this to "true" to keep rendering the signin page instead.
|
||||||
|
*/
|
||||||
|
export const IS_OIDC_AUTO_REDIRECT_DISABLED = env('NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT') === 'true';
|
||||||
|
|
||||||
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
|
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
|
||||||
ACCOUNT_SSO_LINK: 'Linked account to SSO',
|
ACCOUNT_SSO_LINK: 'Linked account to SSO',
|
||||||
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
|
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
|
||||||
@@ -188,3 +196,22 @@ export const isSignupEnabledForProvider = (provider: 'email' | 'google' | 'micro
|
|||||||
|
|
||||||
return env(flagMap[provider]) !== 'true';
|
return env(flagMap[provider]) !== 'true';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if signin is enabled for the given provider.
|
||||||
|
* The master switch takes precedence over the per-provider flags.
|
||||||
|
*/
|
||||||
|
export const isSigninEnabledForProvider = (provider: 'email' | 'google' | 'microsoft' | 'oidc'): boolean => {
|
||||||
|
if (env('NEXT_PUBLIC_DISABLE_SIGNIN') === 'true') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagMap = {
|
||||||
|
email: 'NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN',
|
||||||
|
google: 'NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN',
|
||||||
|
microsoft: 'NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN',
|
||||||
|
oidc: 'NEXT_PUBLIC_DISABLE_OIDC_SIGNIN',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return env(flagMap[provider]) !== 'true';
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../
|
|||||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||||
import { isMemberManagerOrAbove } from '../../utils/teams';
|
import { isMemberManagerOrAbove } from '../../utils/teams';
|
||||||
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
import { getMemberRoles } from '../team/get-member-roles';
|
import { getMemberRoles } from '../team/get-member-roles';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
@@ -22,9 +23,18 @@ export type CancelDocumentOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
|
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
|
||||||
// Note: This is an unsafe request, we validate the ownership/permission later in the function.
|
// Resolve the envelope through the visibility-aware helper so the caller must
|
||||||
const envelope = await prisma.envelope.findUnique({
|
// have read access (ownership OR team membership with sufficient visibility OR
|
||||||
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
// team-email). This prevents cancelling a document the caller cannot see.
|
||||||
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: envelopeWhereInput,
|
||||||
include: {
|
include: {
|
||||||
recipients: true,
|
recipients: true,
|
||||||
documentMeta: true,
|
documentMeta: true,
|
||||||
@@ -49,16 +59,6 @@ export const cancelDocument = async ({ id, userId, teamId, reason, requestMetada
|
|||||||
.then((roles) => roles.teamRole)
|
.then((roles) => roles.teamRole)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
|
|
||||||
const isUserTeamMember = teamRole !== null;
|
|
||||||
|
|
||||||
// Callers with no relationship to the document must not be able to determine
|
|
||||||
// whether it exists, so respond as if it was not found.
|
|
||||||
if (!isUserOwner && !isUserTeamMember) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Document not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
|
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
|
||||||
|
|
||||||
// The document is visible to the caller, but cancelling requires elevated permissions.
|
// The document is visible to the caller, but cancelling requires elevated permissions.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
|||||||
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
import { getMemberRoles } from '../team/get-member-roles';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
export type DeleteDocumentOptions = {
|
export type DeleteDocumentOptions = {
|
||||||
@@ -36,7 +36,9 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: This is an unsafe request, we validate the ownership later in the function.
|
// Note: This is an unsafe request. It is used purely to resolve the recipient
|
||||||
|
// self-hide path below. The authoritative delete authorization is performed
|
||||||
|
// via the visibility-aware `getEnvelopeWhereInput` helper.
|
||||||
const envelope = await prisma.envelope.findUnique({
|
const envelope = await prisma.envelope.findUnique({
|
||||||
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||||
include: {
|
include: {
|
||||||
@@ -51,36 +53,47 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUserTeamMember = await getMemberRoles({
|
// Determine whether the user has authorized delete access using the
|
||||||
teamId: envelope.teamId,
|
// visibility-aware helper. This enforces ownership OR (team membership AND
|
||||||
reference: {
|
// the document's visibility is permitted for the member's role) OR team-email
|
||||||
type: 'User',
|
// access. A bare team member without sufficient visibility will resolve to
|
||||||
id: userId,
|
// null here and therefore must not be able to delete the document.
|
||||||
},
|
const hasDeleteAccess = await getEnvelopeWhereInput({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
})
|
})
|
||||||
.then(() => true)
|
.then(({ envelopeWhereInput }) =>
|
||||||
|
prisma.envelope.findFirst({
|
||||||
|
where: envelopeWhereInput,
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((result) => Boolean(result))
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
const isUserOwner = envelope.userId === userId;
|
|
||||||
const userRecipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
const userRecipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
|
if (!hasDeleteAccess && !userRecipient) {
|
||||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
message: 'Not allowed',
|
message: 'Not allowed',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle hard or soft deleting the actual document if user has permission.
|
// Handle hard or soft deleting the actual document if user has permission.
|
||||||
if (isUserOwner || isUserTeamMember) {
|
if (hasDeleteAccess) {
|
||||||
await handleDocumentOwnerDelete({
|
const updatedEnvelope = await handleDocumentOwnerDelete({
|
||||||
envelope,
|
envelope,
|
||||||
user,
|
user,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const envelopeForWebhook = { ...envelope, ...(updatedEnvelope ?? {}) };
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeForWebhook)),
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
|
|
||||||
export type CreateAttachmentOptions = {
|
export type CreateAttachmentOptions = {
|
||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
@@ -15,11 +15,15 @@ export type CreateAttachmentOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createAttachment = async ({ envelopeId, teamId, userId, data }: CreateAttachmentOptions) => {
|
export const createAttachment = async ({ envelopeId, teamId, userId, data }: CreateAttachmentOptions) => {
|
||||||
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
|
id: { type: 'envelopeId', id: envelopeId },
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
type: null,
|
||||||
|
});
|
||||||
|
|
||||||
const envelope = await prisma.envelope.findFirst({
|
const envelope = await prisma.envelope.findFirst({
|
||||||
where: {
|
where: envelopeWhereInput,
|
||||||
id: envelopeId,
|
|
||||||
team: buildTeamWhereQuery({ teamId, userId }),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!envelope) {
|
if (!envelope) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
|
|
||||||
export type DeleteAttachmentOptions = {
|
export type DeleteAttachmentOptions = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,9 +14,6 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
|
|||||||
const attachment = await prisma.envelopeAttachment.findFirst({
|
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
envelope: {
|
|
||||||
team: buildTeamWhereQuery({ teamId, userId }),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
envelope: true,
|
envelope: true,
|
||||||
@@ -29,6 +26,24 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
|
id: { type: 'envelopeId', id: attachment.envelopeId },
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
type: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional validation to check the user has visibility-aware access to the envelope.
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: envelopeWhereInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Attachment not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||||
attachment.envelope.status === DocumentStatus.REJECTED
|
attachment.envelope.status === DocumentStatus.REJECTED
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
|
|
||||||
export type FindAttachmentsByEnvelopeIdOptions = {
|
export type FindAttachmentsByEnvelopeIdOptions = {
|
||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
@@ -14,11 +14,15 @@ export const findAttachmentsByEnvelopeId = async ({
|
|||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
}: FindAttachmentsByEnvelopeIdOptions) => {
|
}: FindAttachmentsByEnvelopeIdOptions) => {
|
||||||
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
|
id: { type: 'envelopeId', id: envelopeId },
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
type: null,
|
||||||
|
});
|
||||||
|
|
||||||
const envelope = await prisma.envelope.findFirst({
|
const envelope = await prisma.envelope.findFirst({
|
||||||
where: {
|
where: envelopeWhereInput,
|
||||||
id: envelopeId,
|
|
||||||
team: buildTeamWhereQuery({ teamId, userId }),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!envelope) {
|
if (!envelope) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
|
|
||||||
export type UpdateAttachmentOptions = {
|
export type UpdateAttachmentOptions = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,9 +15,6 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
|
|||||||
const attachment = await prisma.envelopeAttachment.findFirst({
|
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
envelope: {
|
|
||||||
team: buildTeamWhereQuery({ teamId, userId }),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
envelope: true,
|
envelope: true,
|
||||||
@@ -30,6 +27,24 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
|
id: { type: 'envelopeId', id: attachment.envelopeId },
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
type: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional validation to check the user has visibility-aware access to the envelope.
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: envelopeWhereInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Attachment not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||||
attachment.envelope.status === DocumentStatus.REJECTED
|
attachment.envelope.status === DocumentStatus.REJECTED
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ import { extractDerivedDocumentMeta } from '../../utils/document';
|
|||||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||||
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
|
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { nanoid, prefixedId } from '../../universal/id';
|
|||||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
|
||||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||||
|
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
export interface DuplicateEnvelopeOptions {
|
export interface DuplicateEnvelopeOptions {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
||||||
import { hashString } from '../auth/hash';
|
|
||||||
|
|
||||||
export const getUserByApiToken = async ({ token }: { token: string }) => {
|
|
||||||
const hashedToken = hashString(token);
|
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
apiTokens: {
|
|
||||||
some: {
|
|
||||||
token: hashedToken,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
apiTokens: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
|
||||||
message: 'Invalid token',
|
|
||||||
statusCode: 401,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const retrievedToken = user.apiTokens.find((apiToken) => apiToken.token === hashedToken);
|
|
||||||
|
|
||||||
// This should be impossible but we need to satisfy TypeScript
|
|
||||||
if (!retrievedToken) {
|
|
||||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
|
||||||
message: 'Invalid token',
|
|
||||||
statusCode: 401,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
|
|
||||||
throw new Error('Expired token');
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
};
|
|
||||||
@@ -4,6 +4,7 @@ import { EnvelopeType } from '@prisma/client';
|
|||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
|
|
||||||
export type GetRecipientByIdOptions = {
|
export type GetRecipientByIdOptions = {
|
||||||
recipientId: number;
|
recipientId: number;
|
||||||
@@ -41,6 +42,27 @@ export const getRecipientById = async ({ recipientId, userId, teamId, type }: Ge
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id: recipient.envelopeId,
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional validation to check visibility.
|
||||||
|
const envelope = await prisma.envelope.findUnique({
|
||||||
|
where: envelopeWhereInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Recipient not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const legacyId = {
|
const legacyId = {
|
||||||
documentId: type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId) : null,
|
documentId: type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId) : null,
|
||||||
templateId: type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId) : null,
|
templateId: type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId) : null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { match, P } from 'ts-pattern';
|
|||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { alphaid } from '../../universal/id';
|
import { alphaid } from '../../universal/id';
|
||||||
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||||
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
|
|
||||||
export type CreateSharingIdOptions =
|
export type CreateSharingIdOptions =
|
||||||
| {
|
| {
|
||||||
@@ -27,6 +28,7 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
|
|||||||
),
|
),
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
teamId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,6 +48,31 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
|
|||||||
.then((recipient) => recipient?.email);
|
.then((recipient) => recipient?.email);
|
||||||
})
|
})
|
||||||
.with({ userId: P.number }, async ({ userId }) => {
|
.with({ userId: P.number }, async ({ userId }) => {
|
||||||
|
// Ensure the authenticated user actually has visibility-aware access to the
|
||||||
|
// envelope before allowing them to create a share link. The share route does
|
||||||
|
// not carry a teamId, so we derive it from the envelope and reuse the canonical
|
||||||
|
// visibility check (owner OR team member with sufficient visibility).
|
||||||
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
|
id: {
|
||||||
|
type: 'documentId',
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
teamId: envelope.teamId,
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessibleEnvelope = await prisma.envelope.findFirst({
|
||||||
|
where: envelopeWhereInput,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessibleEnvelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
return await prisma.user
|
return await prisma.user
|
||||||
.findFirst({
|
.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
|||||||
// Purge all internal organisation groups that have no teams.
|
// Purge all internal organisation groups that have no teams.
|
||||||
await tx.organisationGroup.deleteMany({
|
await tx.organisationGroup.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
|
organisationId: team.organisationId,
|
||||||
type: OrganisationGroupType.INTERNAL_TEAM,
|
type: OrganisationGroupType.INTERNAL_TEAM,
|
||||||
teamGroups: {
|
teamGroups: {
|
||||||
none: {},
|
none: {},
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { TeamProfile } from '@prisma/client';
|
|||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { updateTeamPublicProfile } from './update-team-public-profile';
|
|
||||||
|
|
||||||
export type GetTeamPublicProfileOptions = {
|
export type GetTeamPublicProfileOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -32,25 +31,24 @@ export const getTeamPublicProfile = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and return the public profile.
|
// Lazily initialize a disabled public profile on first access. Membership is
|
||||||
|
// already verified by the query above, so this system initialization does not
|
||||||
|
// impose the MANAGE_TEAM gate that updateTeamPublicProfile enforces for writes.
|
||||||
if (!team.profile) {
|
if (!team.profile) {
|
||||||
const { url, profile } = await updateTeamPublicProfile({
|
const profile = await prisma.teamProfile.upsert({
|
||||||
userId: userId,
|
where: {
|
||||||
teamId,
|
teamId,
|
||||||
data: {
|
},
|
||||||
|
create: {
|
||||||
|
teamId,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
update: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!profile) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Failed to create public profile',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profile,
|
profile,
|
||||||
url,
|
url: team.url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user