Compare commits

..

28 Commits

Author SHA1 Message Date
ephraimduncan 5e1d85bc33 fix(ui): add accessible selected-count label to bulk action bar 2026-07-02 12:01:27 +00:00
ephraimduncan 1102e833cc Merge remote-tracking branch 'origin/main' into pr-2711
# Conflicts:
#	apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx
#	apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx
#	packages/app-tests/e2e/documents/bulk-document-actions.spec.ts
2026-07-02 06:48:23 +00:00
Lucas Smith 337f85f021 chore: upgrade libpdf (#3058) 2026-07-02 15:09:07 +10:00
github-actions[bot] 2332b0316b chore: extract translations (#3013) 2026-07-02 14:58:56 +10:00
David Nguyen 393b51d484 fix: add sticky form update button (#3056) 2026-07-02 14:52:28 +10:00
Arun Kumar 5a8335e0eb fix: webhook payload contains stale deletedAt on document cancellation (#2980) 2026-07-01 17:19:21 +10:00
Kendry Grullon 562d78e2d7 feat: add granular signin disable flags and OIDC auto-redirect (#2857) 2026-06-30 16:08:09 +10:00
Grégory Chevalier 3b110cf70d fix: french translation for confirmation message (#3050) 2026-06-30 15:52:59 +10:00
David Nguyen 7062fadf0b fix: add additional team group permission checks (#3052) 2026-06-30 15:45:48 +10:00
Martin Glaser 9cdd2e7ff9 fix(email): render Preview inside Body across all email templates (#3004) 2026-06-29 16:07:43 +10:00
David Nguyen a70b0702c3 fix: add missing teams branding guard (#3049) 2026-06-29 14:50:35 +10:00
David Nguyen 1f170ef5e5 fix: scope organisation group deletion (#3047) 2026-06-29 14:11:31 +10:00
Lucas Smith 8f68393241 fix: tighten permission and validation checks (#3046) 2026-06-29 13:15:13 +10:00
Lucas Smith 381293af0c fix: invite email placeholder (#3045)
- **fix: interpolate inviterEmail in invite email mailto link**
- **fix: add alt attributes to email template images**
2026-06-28 22:01:20 +10:00
David Nguyen 97835b8dbb feat: add field multiselect (#3031) 2026-06-28 15:08:11 +10:00
David Nguyen 977d07330b fix: auto select field on drop (#3028) 2026-06-28 15:07:33 +10:00
Ephraim Duncan 814eb51b26 Merge branch 'main' into feat/bulk-download 2026-05-27 13:46:51 +00:00
ephraimduncan a16dd73ec1 Merge remote-tracking branch 'origin/main' into pr-2711
# Conflicts:
#	packages/lib/translations/de/web.po
#	packages/lib/translations/en/web.po
#	packages/lib/translations/es/web.po
#	packages/lib/translations/fr/web.po
#	packages/lib/translations/it/web.po
#	packages/lib/translations/ja/web.po
#	packages/lib/translations/ko/web.po
#	packages/lib/translations/nl/web.po
#	packages/lib/translations/pl/web.po
#	packages/lib/translations/pt-BR/web.po
#	packages/lib/translations/zh/web.po
2026-05-14 15:43:59 +00:00
ephraimduncan 5da2a2020e chore: merge main, resolve biome formatting conflicts
Merge origin/main into feat/bulk-download. Take PR's bulk action bar
design (escape-key dismiss, popover pill with count badge, optional
download button via onDownloadClick) and the documents page bulk
download state (envelopeMetaCache cached across pages, selected
envelopes mapped from cache for download).

Imports reordered into biome's @documenso → external → relative
groups; combined the @prisma/client import line to include
DocumentStatus as PrismaDocumentStatus alongside EnvelopeType,
FolderType, OrganisationType.
2026-05-12 11:50:10 +00:00
ephraimduncan d79b1d4612 fix: persist selected envelope metadata across pagination for bulk download 2026-04-22 17:23:49 +00:00
ephraimduncan 3685acc0ab fix: propagate createDocumentOptions.title to envelope item in seedDraftDocument 2026-04-20 21:58:46 +00:00
ephraimduncan eeea3651ee fix: update bulk action e2e tests for redesigned toolbar
The bulk action toolbar redesign split the count and "selected"
text into separate spans and renamed "Move to Folder" to "Move",
breaking getByText('N selected') and the toolbar button lookups.
Use /N\s*selected/ regex and scope the dialog submit to the
open dialog since the toolbar's "Move" button persists.
2026-04-20 21:27:58 +00:00
ephraimduncan 50997d7e92 chore: drop blur and transparency from bulk action bar
The translucent popover + backdrop blur muddied the pill against busy
table rows. Switch to an opaque background so the bar reads cleanly.
2026-04-20 20:42:32 +00:00
ephraimduncan bc97af14d3 chore: drop unused toolbar role and aria-label from bulk action bar 2026-04-20 19:08:30 +00:00
ephraimduncan 278dfa3d77 chore: deslop bulk download dialog and action bar
Merge duplicate lingui imports, drop redundant state-reset comment,
simplify Select onValueChange to match codebase style, and remove
broken tap-target span with invalid Tailwind 3.4 classes
(pointer-fine:, -translate-1/2).
2026-04-20 18:57:43 +00:00
ephraimduncan 5b64137237 feat: redesign bulk action toolbar and add escape-to-clear 2026-04-20 18:35:42 +00:00
ephraimduncan b9b29e5a76 fix: bulk download partial failure, abort, and race-safe e2e
- onSuccess now reports successful envelope ids so the parent clears
  only those rows from selection instead of wiping all pages.
- Partial failures no longer auto-close the dialog; failed/unprocessed
  ids stay selected for retry.
- Cancel button turns into Stop while downloading and aborts the batch
  at the next envelope boundary.
- Replace dual waitForEvent('download') with a page.on collector +
  expect.poll so both downloads are captured reliably.
2026-04-20 15:48:18 +00:00
ephraimduncan 5b63b5deb9 feat: bulk download documents 2026-04-20 15:04:59 +00:00
129 changed files with 3002 additions and 727 deletions
+14
View File
@@ -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>
+63 -49
View File
@@ -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>
@@ -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"
@@ -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}
+6
View File
@@ -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}
+4 -4
View File
@@ -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
View File
@@ -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);
+32
View File
@@ -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;
+2 -1
View File
@@ -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>
+2 -1
View File
@@ -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" />
+2 -1
View File
@@ -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">
+3 -2
View File
@@ -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>
+2 -1
View File
@@ -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>
+2 -1
View File
@@ -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>
+2 -2
View File
@@ -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" />
+27
View File
@@ -41,6 +41,14 @@ export const IS_OIDC_SSO_ENABLED = Boolean(
export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL'); 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