mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d92aa6ee93 | |||
| 70cdef5a6d | |||
| d1d94f5e46 | |||
| d8e8f36e45 | |||
| 0aa84cecc8 | |||
| 702e747375 | |||
| 3887aa67c8 | |||
| b84b87cea6 | |||
| ac0a0086d6 | |||
| 8c11266747 | |||
| 3c0345f755 | |||
| 3e47f1913c | |||
| 9ccf50ed95 | |||
| ecc98fbd41 | |||
| 58f0f5da43 | |||
| d5c6cf4ad5 | |||
| 90462bf414 | |||
| 791a54bb8b | |||
| 5f4e0ccf6b | |||
| 583e35c768 | |||
| 1e129580b8 | |||
| 184cbd6770 | |||
| b53295a9d5 | |||
| 8448e333cf | |||
| 03b5fe6117 | |||
| f60698a353 | |||
| 7c48ae6ff4 | |||
| 4ee789ea37 | |||
| ebf5b75a19 | |||
| 0ecde7ac1e | |||
| c41e387220 | |||
| 7f796ed74e | |||
| 0a21598fec | |||
| 240bef1a66 | |||
| 9583e79056 | |||
| 993a494784 | |||
| 743d31651f | |||
| ce96238464 | |||
| 8b8e7e9f2e | |||
| 50006ca053 | |||
| c3135a3ce7 | |||
| d2f60b13fd | |||
| c50a01d004 | |||
| 4bda501d51 | |||
| a7713f7228 | |||
| 536142be03 | |||
| 44c4826e92 | |||
| 61138cdd81 | |||
| 22ceff43e3 | |||
| a84da2f2c7 | |||
| 7e8da85bd8 | |||
| d304d8720c |
@@ -0,0 +1,122 @@
|
||||
---
|
||||
date: 2026-05-28
|
||||
title: Custom Brand Logo Url
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
`brandingUrl` (the configured "Brand Website") is persisted and editable in branding
|
||||
settings, but historically it was never consumed anywhere. It flowed into the database,
|
||||
the settings form, and the admin read-only view, but never affected any rendered output.
|
||||
|
||||
We want `brandingUrl` to actually do something, with deliberately different behavior per
|
||||
surface.
|
||||
|
||||
# Relationship we're going for
|
||||
|
||||
`brandingUrl` is an **email-only** linking concept. It is intentionally **not** used on
|
||||
in-app signing surfaces.
|
||||
|
||||
| Surface | Custom branding logo configured | `brandingUrl` behavior |
|
||||
| --- | --- | --- |
|
||||
| Transactional emails (logo) | Logo shown | Logo links to `brandingUrl` when it is a safe http(s) URL; otherwise plain image |
|
||||
| Transactional emails (footer) | n/a | `brandingUrl` rendered as a link in the footer when it is a safe http(s) URL |
|
||||
| Signing pages (V1 + V2, normal + direct-template) | Logo shown | Ignored — logo is a plain image with no link |
|
||||
| Signing pages (no custom logo) | Documenso fallback shown | Fallback keeps its internal `/` link |
|
||||
| Embedded signing | Logo shown | Ignored (logo not linked) |
|
||||
| Embedded authoring/editor | Logo shown | Ignored |
|
||||
| Settings / admin branding previews | n/a | Unchanged (display only) |
|
||||
|
||||
Rationale:
|
||||
|
||||
- On signing pages the recipient is mid-task; sending them off to an external marketing
|
||||
site via the logo is undesirable, so the custom logo is a plain image there.
|
||||
- In emails the logo and a footer link to the brand's own site are a normal, expected
|
||||
pattern and reinforce that the email is legitimately from that brand.
|
||||
|
||||
# Decisions
|
||||
|
||||
## Scope
|
||||
|
||||
- Use `brandingUrl` only in transactional email rendering:
|
||||
- The shared email logo component links the custom branding logo to `brandingUrl`.
|
||||
- The shared email footer renders `brandingUrl` as a link.
|
||||
- On signing surfaces, render a configured custom branding logo as a plain image with no
|
||||
link wrapper. Leave the Documenso fallback logo's internal `/` link untouched.
|
||||
- Do not change embedded signing, embedded authoring/editor, or settings/admin previews.
|
||||
- No Prisma schema or database migration. `brandingUrl` already exists and is editable.
|
||||
|
||||
## URL safety
|
||||
|
||||
Rendering must be defensive because old/imported data can bypass the branding form's URL
|
||||
validation. Only treat the stored value as a usable Brand Website when it parses as an
|
||||
absolute `http:` or `https:` URL.
|
||||
|
||||
- Empty, missing, invalid, relative, or non-http(s) values are treated as "no Brand
|
||||
Website" and produce a plain logo / no footer link.
|
||||
- Do not mutate stored settings or run a cleanup migration.
|
||||
- Factored into a single shared helper so both email logo and footer apply identical rules:
|
||||
- `packages/email/utils/branding-url.ts` -> `getSafeBrandingUrl(value): string | null`.
|
||||
|
||||
## Email rendering
|
||||
|
||||
- New shared component `packages/email/template-components/template-branding-logo.tsx`
|
||||
(`TemplateBrandingLogo`) renders either:
|
||||
- the custom branding logo, wrapped in a `Link` to the safe `brandingUrl` with
|
||||
`target="_blank"` when one exists, or a plain `Img` when not; or
|
||||
- the Documenso fallback logo (`/static/logo.png`) when custom branding is disabled or
|
||||
no logo is set.
|
||||
- This component replaced the duplicated `brandingEnabled && brandingLogo ? <Img/> : <fallback/>`
|
||||
ternary that was copy-pasted across all transactional email templates.
|
||||
- `packages/email/template-components/template-footer.tsx` renders `brandingUrl` as a
|
||||
footer link (via `getSafeBrandingUrl`) when branding is enabled and the URL is safe.
|
||||
|
||||
The branding context already exposes `brandingUrl` (`packages/email/providers/branding.tsx`),
|
||||
populated by `teamGlobalSettingsToBranding` / `organisationGlobalSettingsToBranding`
|
||||
(which spread `...settings`), so no additional plumbing into the email branding context was
|
||||
required.
|
||||
|
||||
## Signing rendering
|
||||
|
||||
- `apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx`:
|
||||
custom logo renders as a bare `<img>`. `brandingUrl` is not read; the local branding type
|
||||
and loader payload no longer carry it.
|
||||
- `apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx` (V2,
|
||||
shared by normal and direct-template signing): custom logo renders as a bare `<img>`; the
|
||||
Documenso fallback keeps its `<Link to="/">`.
|
||||
- `apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx`: V1 loader branding payload no
|
||||
longer includes `brandingUrl`.
|
||||
- `packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts` and
|
||||
`get-envelope-for-direct-template-signing.ts`: `brandingUrl` removed from the V2
|
||||
`EnvelopeForSigningResponse.settings` schema/payload since it is not consumed there.
|
||||
|
||||
# History
|
||||
|
||||
An earlier iteration of this plan wired `brandingUrl` into the in-app signing pages so a
|
||||
custom logo linked to the Brand Website (external `<a target="_blank">`, internal `/`
|
||||
fallback otherwise) and added `brandingUrl` to the V1/V2 signing payloads. That direction
|
||||
was reversed: signing-page logos are now plain images and `brandingUrl` is email-only. The
|
||||
signing payload additions were removed.
|
||||
|
||||
# Test coverage
|
||||
|
||||
`packages/app-tests/e2e/signing-branding.spec.ts`:
|
||||
|
||||
- V1 normal `/sign/:token`: custom logo is a plain image, not inside a link, and no
|
||||
`brandingUrl` link is present.
|
||||
- V2 normal `/sign/:token` and V2 direct-template: same plain-image assertions.
|
||||
- V2 with no custom logo: Documenso fallback still links to `/`.
|
||||
- Embedded signing: no custom-logo Brand Website link is rendered.
|
||||
|
||||
# Acceptance criteria
|
||||
|
||||
- A custom branding logo on any signing surface (V1, V2 normal, V2 direct-template, embedded)
|
||||
renders as a plain image with no link, and `brandingUrl` is never rendered as a link there.
|
||||
- Documenso fallback logos continue linking to `/`.
|
||||
- In transactional emails, when a custom logo and a safe `brandingUrl` are configured, the
|
||||
email logo links to `brandingUrl` (new tab) and the footer shows the Brand Website link.
|
||||
- In transactional emails, when `brandingUrl` is empty/invalid/relative/non-http(s), the logo
|
||||
is a plain image and no footer Brand Website link is shown.
|
||||
- URL safety is enforced through the single shared `getSafeBrandingUrl` helper.
|
||||
- Settings/admin branding previews are unchanged.
|
||||
- No schema or migration changes.
|
||||
Vendored
+1
-1
@@ -29,6 +29,6 @@
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ For full instructions, requirements, and configuration details, see the [Self Ho
|
||||
|
||||
#### Railway
|
||||
|
||||
[](https://railway.app/template/bG6D4p)
|
||||
[](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
|
||||
|
||||
#### Render
|
||||
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ We support a variety of deployment methods, and are actively working on adding m
|
||||
|
||||
## Railway
|
||||
|
||||
[](https://railway.app/template/DjrRRX)
|
||||
[](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
|
||||
|
||||
## Render
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: iframe
|
||||
description: Embed the signing experience directly in your application using an iframe.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
<Callout type="warn" title="iframes are not recommended">
|
||||
Embedding via iframe is not recommended. We strongly recommend using the [official SDKs](/docs/developers/embedding/sdks) instead.
|
||||
</Callout>
|
||||
|
||||
### Basic iframe Embedding
|
||||
|
||||
```html
|
||||
<iframe
|
||||
src="https://app.documenso.com/embed/sign/abc123xyz"
|
||||
width="100%"
|
||||
height="800"
|
||||
frameborder="0"
|
||||
allow="clipboard-write"
|
||||
></iframe>
|
||||
```
|
||||
|
||||
<Callout title="Use the correct embed URL">
|
||||
The URL you embed depends on the embed mode you’re using (for example direct links vs sign-token embeds). Use the
|
||||
embed URL provided by Documenso for your flow.
|
||||
</Callout>
|
||||
|
||||
### iframe Customization
|
||||
|
||||
You can customize the embedded signing experience by passing **encoded options in the iframe URL fragment** (everything
|
||||
after `#`).
|
||||
|
||||
Documenso expects the fragment to be **base64** of:
|
||||
|
||||
- `encodeURIComponent(JSON.stringify(options))`
|
||||
|
||||
#### Supported options
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| `name` | `string` | Prefill signer name. |
|
||||
| `email` | `string` | Prefill signer email. |
|
||||
| `lockName` | `boolean` | Lock the name field (prevents editing). |
|
||||
| `lockEmail` | `boolean` | Lock the email field (prevents editing). |
|
||||
| `language` | `string` | Force the embed language (e.g. `en`). |
|
||||
| `darkModeDisabled` | `boolean` | Disable dark mode behavior. |
|
||||
| `allowDocumentRejection` | `boolean` | Allow or disallow document rejection. |
|
||||
| `css` | `string` | Inject custom CSS into the embed. |
|
||||
| `cssVars` | `object` | Override embed CSS variables (see the CSS Variables page). |
|
||||
|
||||
#### Example
|
||||
|
||||
```ts
|
||||
const buildEmbedSrc = (host: string, token: string) => {
|
||||
const options = {
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
lockName: true,
|
||||
lockEmail: true,
|
||||
language: 'en',
|
||||
darkModeDisabled: false,
|
||||
allowDocumentRejection: true,
|
||||
css: ':root { --radius: 12px; }',
|
||||
cssVars: {},
|
||||
};
|
||||
|
||||
const encodedOptions = btoa(encodeURIComponent(JSON.stringify(options)));
|
||||
|
||||
return `${new URL(`/embed/sign/${token}`, host).toString()}#${encodedOptions}`;
|
||||
};
|
||||
```
|
||||
|
||||
A complete example can be found in the [Embeds repository](https://github.com/documenso/embeds/blob/main/packages/mitosis/src/sign-document.lite.tsx).
|
||||
|
||||
<Callout type="info" title="Why use the URL fragment?">
|
||||
The fragment is **not sent to the server** as part of the HTTP request, but it is available to the embedded app in
|
||||
the browser. This makes it a convenient way to pass client-side configuration without changing the base embed URL.
|
||||
</Callout>
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Embedding",
|
||||
"pages": ["sdks", "direct-links", "css-variables", "editor"]
|
||||
"pages": ["sdks", "direct-links", "css-variables", "editor", "iframe"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"privacy",
|
||||
"terms",
|
||||
"security",
|
||||
"verify-email",
|
||||
"support"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Verifying Emails from Documenso
|
||||
description: How to confirm that an email is genuinely from Documenso, and what to do if you receive a suspicious message.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
## Check the Sender Domain
|
||||
|
||||
All email sent by Documenso originates from one of the following domains. If you receive an email claiming to be from Documenso and the sender address does not end in one of these domains, treat it as suspicious.
|
||||
|
||||
| Domain | Used for |
|
||||
| ------------------------ | -------------------------------------------------------------- |
|
||||
| `app.documenso.com` | Transactional email |
|
||||
| `documensomail.com` | Transactional email |
|
||||
| `documensoemail.com` | Transactional email |
|
||||
| Custom domain | [Enterprise organisations](/docs/users/organisations/email-domains) using a custom email domain |
|
||||
|
||||
Typical sender addresses include:
|
||||
|
||||
- `noreply@app.documenso.com`
|
||||
- `noreply@free.documensomail.com`
|
||||
- `noreply@send.documensoemail.com`
|
||||
|
||||
<Callout type="warn">
|
||||
A misspelling such as `documenso-email.com`, `documensoemaiI.com` (capital i instead of l), or any other variation is not a Documenso domain.
|
||||
</Callout>
|
||||
|
||||
## Types of Email Documenso Sends
|
||||
|
||||
Documenso sends email only for the following purposes:
|
||||
|
||||
- **Account verification** — confirming your email address when you sign up or change it
|
||||
- **Password reset** — a link to reset your password that you requested
|
||||
- **Document invitations** — notifying you that a document has been shared with you to sign, approve, or view
|
||||
- **Signing reminders** — follow-up reminders for pending document actions
|
||||
- **Completed document notifications** — confirmation that all parties have signed a document
|
||||
- **Team invitations** — inviting you to join an organisation or team
|
||||
|
||||
## What Documenso Will Never Do
|
||||
|
||||
- Ask for your password via email
|
||||
- Send you an attachment and ask you to open it to verify your identity
|
||||
- Ask you to confirm payment details or billing information over email
|
||||
- Send unsolicited marketing emails if you have not opted in
|
||||
|
||||
## How to Tell If an Email Is Legitimate
|
||||
|
||||
1. **Check the sender address** — the domain must be `documenso.com` or `documensomail.com`
|
||||
2. **Look at the link destination** — hover over any link before clicking; it should point to `app.documenso.com`
|
||||
3. **Watch for urgency or threats** — legitimate Documenso emails do not threaten account suspension to pressure you into clicking a link immediately
|
||||
4. **Verify the action yourself** — if in doubt, log in to [app.documenso.com](https://app.documenso.com) directly (not via the email link) and check whether the document or notification exists there
|
||||
|
||||
## Report a Suspicious Email
|
||||
|
||||
If you receive an email that appears to impersonate Documenso:
|
||||
|
||||
1. Do not click any links or download any attachments
|
||||
2. Forward the email as an attachment to **support@documenso.com**
|
||||
3. Delete the email from your inbox
|
||||
|
||||
You can also report phishing emails directly to your email provider using their built-in reporting tools.
|
||||
|
||||
## Related
|
||||
|
||||
- [Security Policy](/docs/policies/security) — Documenso's security practices and vulnerability disclosure process
|
||||
- [Create an Account](/docs/users/getting-started/create-account) — What to expect during sign-up
|
||||
- [Security Settings](/docs/users/settings/security) — Enable two-factor authentication and manage sessions
|
||||
@@ -24,7 +24,7 @@ Before deploying, you need:
|
||||
|
||||
The fastest way to deploy Documenso on Railway is using the official template:
|
||||
|
||||
[](https://railway.app/template/bG6D4p)
|
||||
[](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
|
||||
|
||||
This template automatically provisions:
|
||||
|
||||
|
||||
@@ -39,7 +39,11 @@ Navigate to [documen.so/free](https://documen.so/free) to create a free account.
|
||||
|
||||
Provide your name, email address, and create a password. Alternatively, sign up with Google for faster access.
|
||||
|
||||
{/* TODO: Add screenshot of registration form */}
|
||||
<img
|
||||
src="/get-started-images/documenso-registration-form.webp"
|
||||
alt="Documenso registration form with name, email, and password fields"
|
||||
style={{width: '500px', height: '650px', objectFit: 'contain' }}
|
||||
/>
|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
<Callout type="error">
|
||||
Account deletion is permanent and irreversible. All documents, signatures, templates, and account
|
||||
data will be permanently removed. Any active subscription will be cancelled.
|
||||
Account deletion is permanent and irreversible. Your account, signatures, and personal data will be
|
||||
permanently removed, and any active subscription will be cancelled. How your organisations and
|
||||
documents are handled is explained below.
|
||||
</Callout>
|
||||
|
||||
## Before Deleting
|
||||
|
||||
- Download any documents you need to keep
|
||||
- Cancel any active subscriptions
|
||||
- Disable two-factor authentication (required before deletion)
|
||||
|
||||
## Delete Your Account
|
||||
@@ -36,6 +36,31 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
If you have two-factor authentication enabled, you must disable it before deleting your account.
|
||||
</Callout>
|
||||
|
||||
## What Happens to Your Organisations
|
||||
|
||||
When you delete your account, the organisations you **own** are permanently deleted along with all of
|
||||
their teams. If an owned organisation has an active subscription, it is scheduled for cancellation at
|
||||
the end of the current billing period.
|
||||
|
||||
Organisations that you are only a **member** of are not deleted. You are simply removed from them, and
|
||||
the organisation continues to operate as normal.
|
||||
|
||||
## What Happens to Your Documents
|
||||
|
||||
The way your documents and templates are handled depends on whether you owned the organisation they
|
||||
belong to:
|
||||
|
||||
- **Organisations you owned** — Completed and in-progress documents are retained in an anonymized form
|
||||
(reassigned to an internal system account) so the other parties keep their records. Draft documents
|
||||
and templates are permanently removed.
|
||||
- **Organisations you were a member of** — Your documents and templates are transferred to the
|
||||
organisation owner, so they remain accessible to the organisation after you leave.
|
||||
|
||||
<Callout type="warn">
|
||||
Documents that are retained in anonymized form are no longer associated with your account and cannot
|
||||
be recovered or accessed by you after deletion. Download anything you need to keep beforehand.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -73,5 +73,12 @@ if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then
|
||||
echo "╚═════════════════════════════════════════════════════════════════════╝"
|
||||
fi
|
||||
|
||||
NEXT_PUBLIC_WEBAPP_URL=$(load_env_var "NEXT_PUBLIC_WEBAPP_URL")
|
||||
|
||||
if [ -z "$NEXT_PUBLIC_WEBAPP_URL" ]; then
|
||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||
echo "[INFO]: NEXT_PUBLIC_WEBAPP_URL not set, defaulting to $NEXT_PUBLIC_WEBAPP_URL"
|
||||
fi
|
||||
|
||||
echo "[INFO]: Starting Stripe webhook listener..."
|
||||
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook
|
||||
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to "$NEXT_PUBLIC_WEBAPP_URL/api/stripe/webhook"
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type AdminOrganisationSyncSubscriptionDialogProps = {
|
||||
organisationId: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ZAdminOrganisationSyncSubscriptionFormSchema = z.object({
|
||||
syncClaims: z.boolean(),
|
||||
});
|
||||
|
||||
type TAdminOrganisationSyncSubscriptionFormSchema = z.infer<typeof ZAdminOrganisationSyncSubscriptionFormSchema>;
|
||||
|
||||
export const AdminOrganisationSyncSubscriptionDialog = ({
|
||||
organisationId,
|
||||
trigger,
|
||||
}: AdminOrganisationSyncSubscriptionDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<TAdminOrganisationSyncSubscriptionFormSchema>({
|
||||
resolver: zodResolver(ZAdminOrganisationSyncSubscriptionFormSchema),
|
||||
defaultValues: {
|
||||
syncClaims: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: syncSubscription } = trpc.admin.organisation.subscription.sync.useMutation();
|
||||
|
||||
const onFormSubmit = async (values: TAdminOrganisationSyncSubscriptionFormSchema) => {
|
||||
try {
|
||||
await syncSubscription({
|
||||
organisationId,
|
||||
syncClaims: values.syncClaims,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Subscription synced`,
|
||||
description: t`The organisation subscription has been synced with Stripe.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(0);
|
||||
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`Failed to sync subscription`,
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline">
|
||||
<Trans>Sync Stripe subscription</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sync Stripe subscription</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Fetch the latest subscription data from Stripe and apply it to this organisation.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="syncClaims"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="admin-sync-subscription-sync-claims"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<label
|
||||
htmlFor="admin-sync-subscription-sync-claims"
|
||||
className="font-normal text-muted-foreground text-sm leading-snug"
|
||||
>
|
||||
<Trans>
|
||||
Sync claims. This will overwrite the current claim with the one resolved from the Stripe
|
||||
subscription.
|
||||
</Trans>
|
||||
</label>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Sync</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -67,7 +67,7 @@ export const AdminSwapSubscriptionDialog = ({
|
||||
|
||||
const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId);
|
||||
|
||||
const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation();
|
||||
const { mutateAsync: swapSubscription } = trpc.admin.organisation.subscription.swap.useMutation();
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!selectedOrgId) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -28,6 +29,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [backportEmailTransport, setBackportEmailTransport] = useState(false);
|
||||
|
||||
const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -67,19 +69,33 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
|
||||
await updateClaim({
|
||||
id: claim.id,
|
||||
data,
|
||||
backportEmailTransport,
|
||||
})
|
||||
}
|
||||
licenseFlags={licenseFlags}
|
||||
formSubmitTrigger={
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="backport-email-transport"
|
||||
checked={backportEmailTransport}
|
||||
onCheckedChange={(checked) => setBackportEmailTransport(checked === true)}
|
||||
/>
|
||||
<label htmlFor="backport-email-transport" className="text-muted-foreground text-sm">
|
||||
<Trans>Backport email transport</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" loading={isPending}>
|
||||
<Trans>Update Claim</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isPending}>
|
||||
<Trans>Update Claim</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
||||
@@ -30,6 +31,7 @@ import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { StackAvatar } from '../general/stack-avatar';
|
||||
|
||||
@@ -99,9 +101,12 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
const errorMessage = getDistributeErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`This document could not be re-sent at this time. Please try again.`),
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
EmailTransportForm,
|
||||
type EmailTransportFormValues,
|
||||
emailTransportFormToConfig,
|
||||
} from '../forms/email-transport-form';
|
||||
|
||||
export type EmailTransportCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EmailTransportCreateDialog = ({ trigger }: EmailTransportCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: createTransport, isPending } = trpc.admin.emailTransport.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Transport created.`,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t`Failed to create transport.`,
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = async (values: EmailTransportFormValues) => {
|
||||
await createTransport({
|
||||
name: values.name,
|
||||
fromName: values.fromName,
|
||||
fromAddress: values.fromAddress,
|
||||
config: emailTransportFormToConfig(values),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0">
|
||||
<Trans>Add transport</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Add Email Transport</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Fill in the details to create a new email transport.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<EmailTransportForm
|
||||
onFormSubmit={onFormSubmit}
|
||||
formSubmitTrigger={
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isPending}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type EmailTransportDeleteDialogProps = {
|
||||
transportId: string;
|
||||
transportName: string;
|
||||
subscriptionClaimCount: number;
|
||||
organisationClaimCount: number;
|
||||
trigger: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EmailTransportDeleteDialog = ({
|
||||
transportId,
|
||||
transportName,
|
||||
subscriptionClaimCount,
|
||||
organisationClaimCount,
|
||||
trigger,
|
||||
}: EmailTransportDeleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const isInUse = subscriptionClaimCount + organisationClaimCount > 0;
|
||||
|
||||
const { mutateAsync: deleteTransport, isPending } = trpc.admin.emailTransport.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Transport deleted.`,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Failed to delete transport.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Delete Email Transport</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Are you sure you want to delete the following transport?</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription className="text-center font-semibold">{transportName}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isInUse && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans>Warning, this email transport is currently being used by:</Trans>
|
||||
|
||||
<ul className="mt-2 list-disc pl-5">
|
||||
{subscriptionClaimCount > 0 && (
|
||||
<li>
|
||||
<Plural value={subscriptionClaimCount} one="# Subscription claim" other="# Subscription claims" />
|
||||
</li>
|
||||
)}
|
||||
|
||||
{organisationClaimCount > 0 && (
|
||||
<li>
|
||||
<Plural value={organisationClaimCount} one="# Organisation claim" other="# Organisation claims" />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)} disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isPending}
|
||||
onClick={async () => deleteTransport({ id: transportId })}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZSendTestEmailFormSchema = z.object({
|
||||
to: z.string().email(),
|
||||
});
|
||||
|
||||
type TSendTestEmailFormSchema = z.infer<typeof ZSendTestEmailFormSchema>;
|
||||
|
||||
export type EmailTransportSendTestDialogProps = {
|
||||
transportId: string;
|
||||
trigger: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EmailTransportSendTestDialog = ({ transportId, trigger }: EmailTransportSendTestDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: sendTest } = trpc.admin.emailTransport.sendTest.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Test email sent.`,
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t`Test failed.`,
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<TSendTestEmailFormSchema>({
|
||||
resolver: zodResolver(ZSendTestEmailFormSchema),
|
||||
defaultValues: {
|
||||
to: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ to }: TSendTestEmailFormSchema) => {
|
||||
await sendTest({ id: transportId, to });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Send Test Email</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Send a test email using this transport to verify the configuration.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="to"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder={t`test@example.com`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Send</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindEmailTransportsResponse } from '@documenso/trpc/server/admin-router/email-transport/find-email-transports.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
EmailTransportForm,
|
||||
type EmailTransportFormValues,
|
||||
emailTransportFormToConfig,
|
||||
} from '../forms/email-transport-form';
|
||||
|
||||
export type EmailTransportUpdateDialogProps = {
|
||||
transport: TFindEmailTransportsResponse['data'][number];
|
||||
trigger: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EmailTransportUpdateDialog = ({ transport, trigger }: EmailTransportUpdateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: updateTransport, isPending } = trpc.admin.emailTransport.update.useMutation();
|
||||
|
||||
const onFormSubmit = async (values: EmailTransportFormValues) => {
|
||||
try {
|
||||
await updateTransport({
|
||||
id: transport.id,
|
||||
data: {
|
||||
name: values.name,
|
||||
fromName: values.fromName,
|
||||
fromAddress: values.fromAddress,
|
||||
config: emailTransportFormToConfig(values),
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Transport updated.`,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Failed to save transport.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Edit Email Transport</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Modify the details of the email transport.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<EmailTransportForm
|
||||
isEdit
|
||||
defaultValues={{
|
||||
// Pre-fill the non-secret connection settings; secrets stay blank
|
||||
// and are preserved on save unless re-entered.
|
||||
...(transport.config ?? {}),
|
||||
name: transport.name,
|
||||
fromName: transport.fromName,
|
||||
fromAddress: transport.fromAddress,
|
||||
type: transport.type,
|
||||
}}
|
||||
onFormSubmit={onFormSubmit}
|
||||
formSubmitTrigger={
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isPending}>
|
||||
<Trans>Save changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
@@ -37,6 +38,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
export type EnvelopeDistributeDialogProps = {
|
||||
onDistribute?: () => Promise<void>;
|
||||
@@ -66,7 +68,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -174,9 +176,13 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = getDistributeErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This envelope could not be distributed at this time. Please try again.`,
|
||||
title: i18n._(errorMessage.title),
|
||||
description: i18n._(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
@@ -25,7 +26,7 @@ import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
|
||||
import { StackAvatar } from '../general/stack-avatar';
|
||||
|
||||
export type EnvelopeRedistributeDialogProps = {
|
||||
@@ -47,7 +48,7 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist
|
||||
const recipients = envelope.recipients;
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
@@ -77,9 +78,12 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
const errorMessage = getDistributeErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This envelope could not be resent at this time. Please try again.`,
|
||||
title: i18n._(errorMessage.title),
|
||||
description: i18n._(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ export const ManagePublicTemplateDialog = ({
|
||||
.with({ currentStep: 'SELECT_TEMPLATE' }, () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<DialogTitle className="w-full max-w-full whitespace-pre-line break-words">
|
||||
{team?.name ? (
|
||||
<Trans>{team.name} direct signing templates</Trans>
|
||||
) : (
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||
} from '@documenso/lib/constants/template';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -35,8 +35,8 @@ import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
distributeDocument: z.boolean(),
|
||||
@@ -180,22 +180,11 @@ export function TemplateUseDialog({
|
||||
await navigate(documentPath);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('DOCUMENT_SEND_FAILED', () => msg`The document was created but could not be sent to recipients.`)
|
||||
.with(
|
||||
AppErrorCode.INVALID_BODY,
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
() =>
|
||||
msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
|
||||
)
|
||||
.with(AppErrorCode.NOT_FOUND, () => msg`The template or one of its recipients could not be found.`)
|
||||
.with(AppErrorCode.LIMIT_EXCEEDED, () => msg`You have reached your document limit for this plan.`)
|
||||
.otherwise(() => msg`An error occurred while creating document from template.`);
|
||||
const errorMessage = getTemplateUseErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(errorMessage),
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
|
||||
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
@@ -42,6 +43,7 @@ import { useSearchParams } from 'react-router';
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||
@@ -259,9 +261,12 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
);
|
||||
}
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZEmailTransportFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
fromName: z.string().min(1),
|
||||
fromAddress: z.string().email(),
|
||||
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
|
||||
host: z.string().optional(),
|
||||
port: z.coerce.number().int().positive().optional(),
|
||||
secure: z.boolean().optional(),
|
||||
ignoreTLS: z.boolean().optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
service: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
apiKeyUser: z.string().optional(),
|
||||
endpoint: z.string().optional(),
|
||||
});
|
||||
|
||||
export type EmailTransportFormValues = z.infer<typeof ZEmailTransportFormSchema>;
|
||||
|
||||
type EmailTransportFormProps = {
|
||||
defaultValues?: Partial<EmailTransportFormValues>;
|
||||
isEdit?: boolean;
|
||||
onFormSubmit: (values: EmailTransportFormValues) => Promise<void>;
|
||||
formSubmitTrigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EmailTransportForm = ({
|
||||
defaultValues,
|
||||
isEdit = false,
|
||||
onFormSubmit,
|
||||
formSubmitTrigger,
|
||||
}: EmailTransportFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<EmailTransportFormValues>({
|
||||
resolver: zodResolver(ZEmailTransportFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
fromName: '',
|
||||
fromAddress: '',
|
||||
type: 'SMTP_AUTH',
|
||||
secure: false,
|
||||
ignoreTLS: false,
|
||||
...defaultValues,
|
||||
},
|
||||
});
|
||||
|
||||
const type = form.watch('type');
|
||||
const secretPlaceholder = isEdit ? t`Leave blank to keep current` : undefined;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t`e.g. Resend (free plans)`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>From name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>From address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Transport type</Trans>
|
||||
</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled={isEdit}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="SMTP_AUTH">SMTP (auth)</SelectItem>
|
||||
<SelectItem value="SMTP_API">SMTP (api)</SelectItem>
|
||||
<SelectItem value="RESEND">Resend</SelectItem>
|
||||
<SelectItem value="MAILCHANNELS">MailChannels</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isEdit && (
|
||||
<FormDescription>
|
||||
<Trans>Transport type cannot be changed after creation.</Trans>
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(type === 'SMTP_AUTH' || type === 'SMTP_API') && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Host</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Port</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'SMTP_AUTH' && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Username</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'SMTP_API' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>API key</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(type === 'RESEND' || type === 'MAILCHANNELS') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>API key</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'MAILCHANNELS' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Endpoint (optional)</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{formSubmitTrigger}
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps flat form values to the tRPC `config` discriminated union.
|
||||
*/
|
||||
export const emailTransportFormToConfig = (values: EmailTransportFormValues) => {
|
||||
switch (values.type) {
|
||||
case 'SMTP_AUTH':
|
||||
return {
|
||||
type: 'SMTP_AUTH' as const,
|
||||
host: values.host ?? '',
|
||||
port: values.port ?? 587,
|
||||
secure: values.secure ?? false,
|
||||
ignoreTLS: values.ignoreTLS ?? false,
|
||||
username: values.username || undefined,
|
||||
password: values.password || undefined,
|
||||
service: values.service || undefined,
|
||||
};
|
||||
case 'SMTP_API':
|
||||
return {
|
||||
type: 'SMTP_API' as const,
|
||||
host: values.host ?? '',
|
||||
port: values.port ?? 587,
|
||||
secure: values.secure ?? false,
|
||||
apiKey: values.apiKey || '',
|
||||
apiKeyUser: values.apiKeyUser || undefined,
|
||||
};
|
||||
case 'RESEND':
|
||||
return { type: 'RESEND' as const, apiKey: values.apiKey || '' };
|
||||
case 'MAILCHANNELS':
|
||||
return {
|
||||
type: 'MAILCHANNELS' as const,
|
||||
apiKey: values.apiKey || '',
|
||||
endpoint: values.endpoint || undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -197,7 +197,9 @@ export const PublicProfileForm = ({ className, profile, onProfileUpdate }: Publi
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Bio</FormLabel>
|
||||
<FormLabel>
|
||||
<Trans>Bio</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} placeholder={_(msg`Write a description to display on your public profile`)} />
|
||||
</FormControl>
|
||||
|
||||
@@ -49,6 +49,7 @@ export const ZSignUpFormSchema = z
|
||||
|
||||
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
|
||||
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
|
||||
SIGNUP_DISPOSABLE_EMAIL: msg`Disposable email addresses are not allowed. Please sign up with a permanent email address.`,
|
||||
[AppErrorCode.ALREADY_EXISTS]: msg`We were unable to create your account. If you already have an account, try signing in instead.`,
|
||||
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { SubscriptionClaim } from '@prisma/client';
|
||||
@@ -20,6 +22,8 @@ import { useForm } from 'react-hook-form';
|
||||
import { Link } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { ClaimLimitFields } from '../general/claim-limit-fields';
|
||||
|
||||
export type SubscriptionClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
||||
|
||||
type SubscriptionClaimFormProps = {
|
||||
@@ -49,10 +53,22 @@ export const SubscriptionClaimForm = ({
|
||||
teamCount: subscriptionClaim.teamCount,
|
||||
memberCount: subscriptionClaim.memberCount,
|
||||
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||
recipientCount: subscriptionClaim.recipientCount,
|
||||
flags: subscriptionClaim.flags,
|
||||
documentRateLimits: subscriptionClaim.documentRateLimits,
|
||||
documentQuota: subscriptionClaim.documentQuota,
|
||||
emailRateLimits: subscriptionClaim.emailRateLimits,
|
||||
emailQuota: subscriptionClaim.emailQuota,
|
||||
apiRateLimits: subscriptionClaim.apiRateLimits,
|
||||
apiQuota: subscriptionClaim.apiQuota,
|
||||
emailTransportId: subscriptionClaim.emailTransportId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: transportsData } = trpc.admin.emailTransport.find.useQuery({ perPage: 100 });
|
||||
const transports = transportsData?.data ?? [];
|
||||
const NONE_VALUE = '__none__';
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
@@ -145,6 +161,30 @@ export const SubscriptionClaimForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipientCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
<Trans>Feature Flags</Trans>
|
||||
@@ -203,6 +243,42 @@ export const SubscriptionClaimForm = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ClaimLimitFields control={form.control} disabled={form.formState.isSubmitting} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailTransportId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email transport</Trans>
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value ?? NONE_VALUE}
|
||||
onValueChange={(value) => field.onChange(value === NONE_VALUE ? null : value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Default (system mailer)`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_VALUE}>{t`Default (system mailer)`}</SelectItem>
|
||||
{transports.map((transport) => (
|
||||
<SelectItem key={transport.id} value={transport.id}>
|
||||
{transport.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
<Trans>Plans without a transport use the system default mailer.</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{formSubmitTrigger}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
|
||||
type TSiteSettingsEmailBlocklistSchema,
|
||||
} from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZEmailBlocklistFormSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
domains: z.string(),
|
||||
});
|
||||
|
||||
type TEmailBlocklistFormSchema = z.infer<typeof ZEmailBlocklistFormSchema>;
|
||||
|
||||
/**
|
||||
* Splits a comma-separated string into a normalised list of domains.
|
||||
* Normalisation (trim, lowercase, strip leading "@", dedupe) is applied
|
||||
* server-side by the schema as well — this is for display consistency.
|
||||
*/
|
||||
const parseDomainsInput = (value: string): string[] => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ''))
|
||||
.filter((entry) => entry.length > 0),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
type AdminEmailBlocklistSectionProps = {
|
||||
emailBlocklist: TSiteSettingsEmailBlocklistSchema | undefined;
|
||||
};
|
||||
|
||||
export const AdminEmailBlocklistSection = ({ emailBlocklist }: AdminEmailBlocklistSectionProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const form = useForm<TEmailBlocklistFormSchema>({
|
||||
resolver: zodResolver(ZEmailBlocklistFormSchema),
|
||||
defaultValues: {
|
||||
enabled: emailBlocklist?.enabled ?? false,
|
||||
domains: (emailBlocklist?.data?.domains ?? []).join(', '),
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = form.watch('enabled');
|
||||
|
||||
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
|
||||
trpcReact.admin.updateSiteSetting.useMutation();
|
||||
|
||||
const onBlocklistUpdate = async ({ enabled, domains }: TEmailBlocklistFormSchema) => {
|
||||
try {
|
||||
const parsedDomains = parseDomainsInput(domains);
|
||||
|
||||
await updateSiteSetting({
|
||||
id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
|
||||
enabled,
|
||||
data: {
|
||||
domains: parsedDomains,
|
||||
},
|
||||
});
|
||||
|
||||
// Reflect the normalised value back in the form.
|
||||
form.reset({
|
||||
enabled,
|
||||
domains: parsedDomains.join(', '),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Email Blocklist Updated`),
|
||||
description: _(msg`The email blocklist has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to update the email blocklist. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
<Trans>Email Blocklist</Trans>
|
||||
</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Block signups from additional email domains on top of the bundled disposable email list. Subdomains are
|
||||
matched automatically (e.g. blocking "bad.com" also blocks "foo.bad.com").
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBlocklistUpdate)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Enabled</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<fieldset className="mt-4" disabled={!enabled} aria-disabled={!enabled}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domains"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Blocked Domains</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="h-32 resize-none" placeholder="bad.com, spam.net, throwaway.io" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Comma-separated list of email domains to block from signing up.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
|
||||
<Trans>Update Blocklist</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
SITE_SETTINGS_BANNER_ID,
|
||||
type TSiteSettingsBannerSchema,
|
||||
ZSiteSettingsBannerSchema,
|
||||
} from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
|
||||
|
||||
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
|
||||
|
||||
type AdminSiteBannerSectionProps = {
|
||||
banner: TSiteSettingsBannerSchema | undefined;
|
||||
};
|
||||
|
||||
export const AdminSiteBannerSection = ({ banner }: AdminSiteBannerSectionProps) => {
|
||||
const nonce = useCspNonce();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const form = useForm<TBannerFormSchema>({
|
||||
resolver: zodResolver(ZBannerFormSchema),
|
||||
defaultValues: {
|
||||
id: SITE_SETTINGS_BANNER_ID,
|
||||
enabled: banner?.enabled ?? false,
|
||||
data: {
|
||||
content: banner?.data?.content ?? '',
|
||||
bgColor: banner?.data?.bgColor ?? '#000000',
|
||||
textColor: banner?.data?.textColor ?? '#FFFFFF',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = form.watch('enabled');
|
||||
|
||||
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
|
||||
trpcReact.admin.updateSiteSetting.useMutation();
|
||||
|
||||
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
|
||||
try {
|
||||
await updateSiteSetting({
|
||||
id,
|
||||
enabled,
|
||||
data,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Banner Updated`),
|
||||
description: _(msg`Your banner has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
<Trans>Site Banner</Trans>
|
||||
</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
The site banner is a message that is shown at the top of the site. It can be used to display important
|
||||
information to your users.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBannerUpdate)}>
|
||||
<div className="mt-4 flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Enabled</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<fieldset className="flex flex-col gap-4 md:flex-row" disabled={!enabled} aria-disabled={!enabled}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.bgColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Background Color</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} nonce={nonce} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.textColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Text Color</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} nonce={nonce} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={!enabled} aria-disabled={!enabled}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Content</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="h-32 resize-none" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The content to show in the banner, HTML is allowed</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
|
||||
<Trans>Update Banner</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -54,7 +54,6 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||
if (plan[interval] && plan[interval].isVisibleInApp) {
|
||||
prices.push({
|
||||
...plan[interval],
|
||||
memberCount: plan.memberCount,
|
||||
claim: plan.id,
|
||||
});
|
||||
}
|
||||
@@ -120,12 +119,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||
<Trans>Subscribe</Trans>
|
||||
</IndividualPersonalLayoutCheckoutButton>
|
||||
) : (
|
||||
<BillingDialog
|
||||
priceId={price.id}
|
||||
planName={price.product.name}
|
||||
memberCount={price.memberCount}
|
||||
claim={price.claim}
|
||||
/>
|
||||
<BillingDialog priceId={price.id} planName={price.product.name} claim={price.claim} />
|
||||
)}
|
||||
</CardContent>
|
||||
</MotionCard>
|
||||
@@ -136,16 +130,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const BillingDialog = ({
|
||||
priceId,
|
||||
planName,
|
||||
claim,
|
||||
}: {
|
||||
priceId: string;
|
||||
planName: string;
|
||||
memberCount: number;
|
||||
claim: string;
|
||||
}) => {
|
||||
const BillingDialog = ({ priceId, planName, claim }: { priceId: string; planName: string; claim: string }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Control, FieldValues, Path } from 'react-hook-form';
|
||||
|
||||
import { RateLimitArrayInput } from './rate-limit-array-input';
|
||||
|
||||
type ClaimLimitFieldsProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
|
||||
prefix?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const ClaimLimitFields = <T extends FieldValues>({
|
||||
control,
|
||||
prefix = '',
|
||||
disabled,
|
||||
}: ClaimLimitFieldsProps<T>) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const name = (key: string) => `${prefix}${key}` as Path<T>;
|
||||
|
||||
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name(key)}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
disabled={disabled}
|
||||
value={field.value === null || field.value === undefined ? '' : field.value}
|
||||
placeholder={t`Unlimited`}
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{description}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderRateLimitField = (key: string, label: ReactNode) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name(key)}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<FormLabel>
|
||||
<Trans>Limits</Trans>
|
||||
</FormLabel>
|
||||
|
||||
{renderQuotaField(
|
||||
'documentQuota',
|
||||
<Trans>Monthly document quota</Trans>,
|
||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
||||
)}
|
||||
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
|
||||
|
||||
{renderQuotaField(
|
||||
'emailQuota',
|
||||
<Trans>Monthly email quota</Trans>,
|
||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
||||
)}
|
||||
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
|
||||
|
||||
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
|
||||
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
@@ -17,6 +18,7 @@ import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { DirectTemplateConfigureForm, type TDirectTemplateConfigureFormSchema } from './direct-template-configure-form';
|
||||
import { type DirectTemplateLocalField, DirectTemplateSigningForm } from './direct-template-signing-form';
|
||||
@@ -120,9 +122,12 @@ export const DirectTemplatePageView = ({
|
||||
await navigate(`/sign/${token}/complete`);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
|
||||
+10
-1
@@ -50,6 +50,11 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||
|
||||
type DocumentSigningBranding = {
|
||||
brandingEnabled: boolean;
|
||||
brandingLogo: string;
|
||||
};
|
||||
|
||||
export type DocumentSigningPageViewV1Props = {
|
||||
recipient: RecipientWithFields;
|
||||
document: DocumentAndSender;
|
||||
@@ -57,6 +62,7 @@ export type DocumentSigningPageViewV1Props = {
|
||||
completedFields: CompletedField[];
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
branding: DocumentSigningBranding;
|
||||
includeSenderDetails: boolean;
|
||||
};
|
||||
|
||||
@@ -68,6 +74,7 @@ export const DocumentSigningPageViewV1 = ({
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
includeSenderDetails,
|
||||
branding,
|
||||
}: DocumentSigningPageViewV1Props) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
@@ -168,10 +175,12 @@ export const DocumentSigningPageViewV1 = ({
|
||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||
const hasPendingFields = pendingFields.length > 0;
|
||||
|
||||
const hasCustomBrandingLogo = branding.brandingEnabled && Boolean(branding.brandingLogo);
|
||||
|
||||
return (
|
||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||
{document.team.teamGlobalSettings.brandingEnabled && document.team.teamGlobalSettings.brandingLogo && (
|
||||
{hasCustomBrandingLogo && (
|
||||
<img
|
||||
src={`/api/branding/logo/team/${document.teamId}`}
|
||||
alt={`${document.team.name}'s Logo`}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { TDocument } from '@documenso/lib/types/document';
|
||||
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
@@ -25,9 +26,9 @@ import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
export type DocumentEditFormProps = {
|
||||
className?: string;
|
||||
@@ -387,9 +388,12 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
const errorMessage = getDistributeErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while sending the document.`),
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
||||
@@ -20,9 +20,9 @@ import { EnvelopeType } from '@prisma/client';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { FileRejection } from 'react-dropzone';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { getUploadErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
export type DocumentUploadButtonLegacyProps = {
|
||||
className?: string;
|
||||
@@ -130,30 +130,11 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
|
||||
|
||||
console.error(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs.`)
|
||||
.with(
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.with(
|
||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||
() => msg`You have reached the limit of the number of files per envelope.`,
|
||||
)
|
||||
.with('UNSUPPORTED_FILE_TYPE', () => msg`This file type isn't supported. Please upload a PDF or Word document.`)
|
||||
.with(
|
||||
'CONVERSION_SERVICE_UNAVAILABLE',
|
||||
() => msg`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
|
||||
)
|
||||
.with(
|
||||
'CONVERSION_FAILED',
|
||||
() => msg`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
|
||||
)
|
||||
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||
const errorMessage = getUploadErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(errorMessage),
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
+103
-2
@@ -14,13 +14,22 @@ import {
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { CommandDialog } from '@documenso/ui/primitives/command';
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import type { FieldType } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
||||
import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
|
||||
import { CopyPlusIcon, ShapesIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||
@@ -470,6 +479,22 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
}
|
||||
};
|
||||
|
||||
const changeSelectedFieldsType = (type: FieldType) => {
|
||||
const fields = selectedKonvaFieldGroups
|
||||
.map((field) => editorFields.getFieldByFormId(field.id()))
|
||||
.filter((field) => field !== undefined);
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.type !== type) {
|
||||
editorFields.updateFieldByFormId(field.formId, {
|
||||
type,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
|
||||
id: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const duplicatedSelectedFields = () => {
|
||||
const fields = selectedKonvaFieldGroups
|
||||
.map((field) => editorFields.getFieldByFormId(field.id()))
|
||||
@@ -554,6 +579,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
|
||||
handleDeleteSelectedFields={deletedSelectedFields}
|
||||
handleChangeRecipient={changeSelectedFieldsRecipients}
|
||||
handleChangeFieldType={changeSelectedFieldsType}
|
||||
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -602,6 +628,7 @@ type FieldActionButtonsProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
handleDuplicateSelectedFieldsOnAllPages: () => void;
|
||||
handleDeleteSelectedFields: () => void;
|
||||
handleChangeRecipient: (recipientId: number) => void;
|
||||
handleChangeFieldType: (type: FieldType) => void;
|
||||
selectedFieldFormId: string[];
|
||||
};
|
||||
|
||||
@@ -610,15 +637,40 @@ const FieldActionButtons = ({
|
||||
handleDuplicateSelectedFieldsOnAllPages,
|
||||
handleDeleteSelectedFields,
|
||||
handleChangeRecipient,
|
||||
handleChangeFieldType,
|
||||
selectedFieldFormId,
|
||||
...props
|
||||
}: FieldActionButtonsProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [showRecipientSelector, setShowRecipientSelector] = useState(false);
|
||||
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
|
||||
|
||||
const { editorFields, envelope } = useCurrentEnvelopeEditor();
|
||||
|
||||
/**
|
||||
* Decide the preselected field type in the command input.
|
||||
*
|
||||
* If all fields share the same type, use that as the default selection.
|
||||
* Otherwise show no preselection.
|
||||
*/
|
||||
const preselectedFieldType = useMemo(() => {
|
||||
if (selectedFieldFormId.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields = editorFields.localFields.filter((field) => selectedFieldFormId.includes(field.formId));
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstType = fields[0].type;
|
||||
const isTypesSame = fields.every((field) => field.type === firstType);
|
||||
|
||||
return isTypesSame ? firstType : null;
|
||||
}, [editorFields.localFields, selectedFieldFormId]);
|
||||
|
||||
/**
|
||||
* Decide the preselected recipient in the command input.
|
||||
*
|
||||
@@ -656,6 +708,7 @@ const FieldActionButtons = ({
|
||||
<div className="flex flex-col items-center" {...props}>
|
||||
<div className="group flex w-fit items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title={t`Change Recipient`}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={() => setShowRecipientSelector(true)}
|
||||
@@ -665,6 +718,17 @@ const FieldActionButtons = ({
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={t`Change Field Type`}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={() => setShowFieldTypeSelector(true)}
|
||||
onTouchEnd={() => setShowFieldTypeSelector(true)}
|
||||
>
|
||||
<ShapesIcon className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={t`Duplicate`}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={handleDuplicateSelectedFields}
|
||||
@@ -674,6 +738,7 @@ const FieldActionButtons = ({
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={t`Duplicate on all pages`}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={handleDuplicateSelectedFieldsOnAllPages}
|
||||
@@ -683,6 +748,7 @@ const FieldActionButtons = ({
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={t`Remove`}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={handleDeleteSelectedFields}
|
||||
@@ -705,6 +771,41 @@ const FieldActionButtons = ({
|
||||
fields={envelope.fields}
|
||||
/>
|
||||
</CommandDialog>
|
||||
|
||||
<CommandDialog position="start" open={showFieldTypeSelector} onOpenChange={setShowFieldTypeSelector}>
|
||||
<Command defaultValue={preselectedFieldType ? t(FRIENDLY_FIELD_TYPE[preselectedFieldType]) : undefined}>
|
||||
<CommandInput placeholder={t`Select a field type`} />
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<span className="inline-block px-4 text-muted-foreground">
|
||||
{t`No field type matching this description was found.`}
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{fieldButtonList.map((field) => {
|
||||
const FieldIcon = field.icon;
|
||||
const label = t(FRIENDLY_FIELD_TYPE[field.type]);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={field.type}
|
||||
className="px-2"
|
||||
onSelect={() => {
|
||||
handleChangeFieldType(field.type);
|
||||
setShowFieldTypeSelector(false);
|
||||
}}
|
||||
>
|
||||
<FieldIcon className="mr-2 h-4 w-4" />
|
||||
<span className="truncate">{label}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
@@ -563,6 +564,9 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
const recipientCountLimit = organisation.organisationClaim.recipientCount;
|
||||
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
|
||||
|
||||
return (
|
||||
<Card backdropBlur={false} className="border">
|
||||
<CardHeader className="flex flex-row justify-between">
|
||||
@@ -627,6 +631,17 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isOverRecipientLimit && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
This envelope cannot have more than {recipientCountLimit} recipients. Please contact support if you need
|
||||
more.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<div
|
||||
className={cn('-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4', {
|
||||
|
||||
+42
-10
@@ -9,7 +9,7 @@ import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZDocumentAccessAuthTypesSchema, ZDocumentActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { DocumentEmailEvents, ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import {
|
||||
type TDocumentMetaDateFormat,
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
@@ -39,6 +39,7 @@ import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expira
|
||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||
import { TemplateTypeSelect, TemplateTypeTooltip } from '@documenso/ui/components/template/template-type-select';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
@@ -114,7 +115,7 @@ export const ZAddSettingsFormSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'email' | 'security';
|
||||
type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'notifications' | 'security';
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -130,10 +131,10 @@ const tabs = [
|
||||
description: msg`Configure signing reminder settings for the document.`,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: msg`Email`,
|
||||
id: 'notifications',
|
||||
title: msg`Notifications`,
|
||||
icon: MailIcon,
|
||||
description: msg`Configure email settings for the document.`,
|
||||
description: msg`Configure notification settings for the document.`,
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
@@ -143,6 +144,18 @@ const tabs = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
// Recipient-facing notification events. These are suppressed at send time
|
||||
// when distributionMethod is not EMAIL (see extractDerivedDocumentEmailSettings),
|
||||
// so the UI mirrors that by disabling the matching checkboxes.
|
||||
const RECIPIENT_EMAIL_EVENTS = [
|
||||
DocumentEmailEvents.RecipientSigningRequest,
|
||||
DocumentEmailEvents.RecipientRemoved,
|
||||
DocumentEmailEvents.RecipientSigned,
|
||||
DocumentEmailEvents.DocumentPending,
|
||||
DocumentEmailEvents.DocumentCompleted,
|
||||
DocumentEmailEvents.DocumentDeleted,
|
||||
] as const;
|
||||
|
||||
type TAddSettingsFormSchema = z.infer<typeof ZAddSettingsFormSchema>;
|
||||
|
||||
type EnvelopeEditorSettingsDialogProps = {
|
||||
@@ -205,6 +218,8 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
);
|
||||
|
||||
const emailSettings = form.watch('meta.emailSettings');
|
||||
const distributionMethod = form.watch('meta.distributionMethod');
|
||||
const isEmailDistribution = distributionMethod === DocumentDistributionMethod.EMAIL;
|
||||
|
||||
const { data: emailData, isLoading: isLoadingEmails } = trpc.enterprise.organisation.email.find.useQuery(
|
||||
{
|
||||
@@ -334,7 +349,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
|
||||
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
|
||||
{tabs.map((tab) => {
|
||||
if (tab.id === 'email' && !settings.allowConfigureDistribution) {
|
||||
if (tab.id === 'notifications' && !settings.allowConfigureDistribution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -730,7 +745,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
)}
|
||||
/>
|
||||
))
|
||||
.with({ activeTab: 'email', settings: { allowConfigureDistribution: true } }, () => (
|
||||
.with({ activeTab: 'notifications', settings: { allowConfigureDistribution: true } }, () => (
|
||||
<>
|
||||
{settings.allowConfigureEmailSender && organisation.organisationClaim.flags.emailDomains && (
|
||||
<FormField
|
||||
@@ -747,6 +762,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
disabled={!isEmailDistribution}
|
||||
>
|
||||
<SelectTrigger loading={isLoadingEmails} className="bg-background">
|
||||
<SelectValue />
|
||||
@@ -783,7 +799,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} disabled={!isEmailDistribution} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@@ -804,7 +820,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} disabled={!isEmailDistribution} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@@ -832,7 +848,11 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="h-16 resize-none bg-background" {...field} />
|
||||
<Textarea
|
||||
className="h-16 resize-none bg-background"
|
||||
{...field}
|
||||
disabled={!isEmailDistribution}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@@ -843,7 +863,19 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
<DocumentEmailCheckboxes
|
||||
value={emailSettings}
|
||||
onChange={(value) => form.setValue('meta.emailSettings', value)}
|
||||
hiddenEvents={isEmailDistribution ? undefined : RECIPIENT_EMAIL_EVENTS}
|
||||
/>
|
||||
|
||||
{!isEmailDistribution && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Email distribution needs to be enabled in the general settings tab to configure recipient
|
||||
email related settings.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
.with({ activeTab: 'security' }, () => (
|
||||
|
||||
@@ -27,27 +27,25 @@ export const EnvelopeSignerHeader = () => {
|
||||
const { envelopeData, envelope, recipientFieldsRemaining, recipient } = useRequiredEnvelopeSigningContext();
|
||||
|
||||
const isEmbedSigning = useEmbedSigningContext() !== null;
|
||||
const hasCustomBrandingLogo = envelopeData.settings.brandingEnabled && Boolean(envelopeData.settings.brandingLogo);
|
||||
|
||||
return (
|
||||
<nav className="embed--DocumentWidgetHeader flex max-w-screen flex-row justify-between border-border border-b bg-background px-4 py-3 md:px-6">
|
||||
{/* Left side - Logo and title */}
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
|
||||
{!isEmbedSigning && (
|
||||
<Link to="/" className="flex-shrink-0">
|
||||
{envelopeData.settings.brandingEnabled && envelopeData.settings.brandingLogo ? (
|
||||
<img
|
||||
src={`/api/branding/logo/team/${envelope.teamId}`}
|
||||
alt={`${envelope.team.name}'s Logo`}
|
||||
className="h-6 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<BrandingLogo className="hidden h-6 w-auto md:block" />
|
||||
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
{!isEmbedSigning &&
|
||||
(hasCustomBrandingLogo ? (
|
||||
<img
|
||||
src={`/api/branding/logo/team/${envelope.teamId}`}
|
||||
alt={`${envelope.team.name}'s Logo`}
|
||||
className="h-6 w-auto flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<Link to="/" className="flex-shrink-0">
|
||||
<BrandingLogo className="hidden h-6 w-auto md:block" />
|
||||
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<h1 title={envelope.title} className="min-w-0 truncate font-semibold text-base text-foreground md:hidden">
|
||||
{envelope.title}
|
||||
|
||||
+98
-43
@@ -9,6 +9,10 @@ import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
||||
import {
|
||||
createFieldCanvasStyleCache,
|
||||
type FieldCanvasStyleCache,
|
||||
} from '@documenso/lib/universal/field-renderer/field-canvas-style';
|
||||
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
@@ -22,7 +26,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { type Field, FieldType, type Recipient, RecipientRole, type Signature, SigningStatus } from '@prisma/client';
|
||||
import type Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
@@ -57,17 +61,31 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
recipientFieldsRemaining,
|
||||
showPendingFieldTooltip,
|
||||
signField: signFieldInternal,
|
||||
email,
|
||||
email: emailState,
|
||||
setEmail,
|
||||
fullName,
|
||||
fullName: fullNameState,
|
||||
setFullName,
|
||||
signature,
|
||||
signature: signatureState,
|
||||
setSignature,
|
||||
selectedAssistantRecipientFields,
|
||||
selectedAssistantRecipient,
|
||||
isDirectTemplate,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
|
||||
// Note: We're using refs here due to the closure within the signField function.
|
||||
const fullName = useRef(fullNameState);
|
||||
const email = useRef(emailState);
|
||||
const signature = useRef(signatureState);
|
||||
|
||||
useEffect(() => {
|
||||
fullName.current = fullNameState;
|
||||
email.current = emailState;
|
||||
signature.current = signatureState;
|
||||
}, [fullNameState, emailState, signatureState]);
|
||||
|
||||
const cachedRenderFields = useRef<Map<number, Field & { signature?: Signature | null }>>(new Map());
|
||||
const prevShowPendingFieldTooltip = useRef(showPendingFieldTooltip);
|
||||
|
||||
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
|
||||
|
||||
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
|
||||
@@ -121,7 +139,10 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
});
|
||||
}, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]);
|
||||
|
||||
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||
const unsafeRenderFieldOnLayer = (
|
||||
unparsedField: Field & { signature?: Signature | null },
|
||||
fieldCanvasStyleCache: FieldCanvasStyleCache,
|
||||
) => {
|
||||
if (!pageLayer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
return;
|
||||
@@ -129,11 +150,9 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
|
||||
const fieldToRender = ZFullFieldSchema.parse(unparsedField);
|
||||
|
||||
const color = fieldToRender.fieldMeta?.readOnly
|
||||
? 'readOnly'
|
||||
: showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)
|
||||
? 'orange'
|
||||
: 'green';
|
||||
const isValidating = showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender);
|
||||
|
||||
const color = fieldToRender.fieldMeta?.readOnly ? 'readOnly' : isValidating ? 'orange' : 'green';
|
||||
|
||||
const { fieldGroup } = renderField({
|
||||
scale,
|
||||
@@ -145,6 +164,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
height: Number(fieldToRender.height),
|
||||
positionX: Number(fieldToRender.positionX),
|
||||
positionY: Number(fieldToRender.positionY),
|
||||
isValidating,
|
||||
signature: unparsedField.signature,
|
||||
},
|
||||
translations: getClientSideFieldTranslations(i18n),
|
||||
@@ -152,6 +172,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
pageHeight: unscaledViewport.height,
|
||||
color,
|
||||
mode: 'sign',
|
||||
fieldCanvasStyleCache,
|
||||
});
|
||||
|
||||
const handleFieldGroupClick = (e: KonvaEventObject<Event>) => {
|
||||
@@ -169,8 +190,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
return;
|
||||
}
|
||||
|
||||
let localEmail: string | null = email;
|
||||
let localFullName: string | null = fullName;
|
||||
let localEmail: string | null = email.current;
|
||||
let localFullName: string | null = fullName.current;
|
||||
let placeholderEmail: string | null = null;
|
||||
|
||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||
@@ -180,7 +201,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
|
||||
// Allows us let the user set a different email than their current logged in email.
|
||||
if (isDirectTemplate) {
|
||||
placeholderEmail = sessionData?.user?.email || email || recipient.email;
|
||||
placeholderEmail = sessionData?.user?.email || email.current || recipient.email;
|
||||
|
||||
if (!placeholderEmail || placeholderEmail === DIRECT_TEMPLATE_RECIPIENT_EMAIL) {
|
||||
placeholderEmail = null;
|
||||
@@ -205,7 +226,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
return;
|
||||
}
|
||||
|
||||
handleCheckboxFieldClick({ field, clickedCheckboxIndex })
|
||||
void handleCheckboxFieldClick({ field, clickedCheckboxIndex })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
@@ -243,7 +264,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
* NUMBER FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.NUMBER }, (field) => {
|
||||
handleNumberFieldClick({ field, number: null })
|
||||
void handleNumberFieldClick({ field, number: null })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
@@ -258,7 +279,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
* TEXT FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.TEXT }, (field) => {
|
||||
handleTextFieldClick({ field, text: null })
|
||||
void handleTextFieldClick({ field, text: null })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
@@ -273,7 +294,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
* EMAIL FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.EMAIL }, (field) => {
|
||||
handleEmailFieldClick({ field, email: localEmail, placeholderEmail })
|
||||
void handleEmailFieldClick({ field, email: localEmail, placeholderEmail })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
@@ -294,7 +315,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
.with({ type: FieldType.INITIALS }, (field) => {
|
||||
const initials = localFullName ? extractInitials(localFullName) : null;
|
||||
|
||||
handleInitialsFieldClick({ field, initials })
|
||||
void handleInitialsFieldClick({ field, initials })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
@@ -309,7 +330,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
* NAME FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.NAME }, (field) => {
|
||||
handleNameFieldClick({ field, name: localFullName })
|
||||
void handleNameFieldClick({ field, name: localFullName })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
@@ -328,7 +349,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
* DROPDOWN FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.DROPDOWN }, (field) => {
|
||||
handleDropdownFieldClick({ field, text: null })
|
||||
void handleDropdownFieldClick({ field, text: null })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
@@ -356,32 +377,34 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
* SIGNATURE FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.SIGNATURE }, (field) => {
|
||||
handleSignatureFieldClick({
|
||||
void handleSignatureFieldClick({
|
||||
field,
|
||||
fullName,
|
||||
signature,
|
||||
fullName: fullName.current,
|
||||
signature: signature.current,
|
||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
|
||||
})
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.value) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => {
|
||||
await signField(field.id, payload, authOptions);
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
|
||||
loadingSpinnerGroup.destroy();
|
||||
},
|
||||
actionTarget: field.type,
|
||||
});
|
||||
if (payload.value) {
|
||||
await executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => {
|
||||
await signField(field.id, payload, authOptions);
|
||||
|
||||
setSignature(payload.value);
|
||||
} else {
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
loadingSpinnerGroup.destroy();
|
||||
},
|
||||
actionTarget: field.type,
|
||||
});
|
||||
|
||||
setSignature(payload.value);
|
||||
} else {
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -395,9 +418,12 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
fieldGroup.on('pointerdown', handleFieldGroupClick);
|
||||
};
|
||||
|
||||
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||
const renderFieldOnLayer = (
|
||||
unparsedField: Field & { signature?: Signature | null },
|
||||
fieldCanvasStyleCache: FieldCanvasStyleCache,
|
||||
) => {
|
||||
try {
|
||||
unsafeRenderFieldOnLayer(unparsedField);
|
||||
unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setRenderError(true);
|
||||
@@ -410,15 +436,28 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
return;
|
||||
}
|
||||
|
||||
// Render current recipient fields.
|
||||
const fieldCanvasStyleCache = createFieldCanvasStyleCache();
|
||||
|
||||
// Render current recipient fields which have changed or are not currently rendered.
|
||||
for (const field of localPageFields) {
|
||||
renderFieldOnLayer(field);
|
||||
const existingCachedField = cachedRenderFields.current.get(field.id);
|
||||
const isFieldCurrentlyRendered = pageLayer.current.findOne(`#${field.id}`);
|
||||
|
||||
if (
|
||||
!isFieldCurrentlyRendered ||
|
||||
!existingCachedField ||
|
||||
existingCachedField.inserted !== field.inserted ||
|
||||
existingCachedField.customText !== field.customText
|
||||
) {
|
||||
renderFieldOnLayer(field, fieldCanvasStyleCache);
|
||||
cachedRenderFields.current.set(field.id, field);
|
||||
}
|
||||
}
|
||||
|
||||
// Render other recipient signed and inserted fields.
|
||||
for (const field of localPageOtherRecipientFields) {
|
||||
try {
|
||||
renderField({
|
||||
const { fieldGroup } = renderField({
|
||||
scale,
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
@@ -436,7 +475,13 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
color: 'readOnly',
|
||||
editable: false,
|
||||
mode: 'sign',
|
||||
fieldCanvasStyleCache,
|
||||
});
|
||||
|
||||
// Other-recipient fields are display-only — they have no click handlers
|
||||
// and shouldn't intercept events meant for the current recipient's
|
||||
// fields. Disable hit detection on the entire group.
|
||||
fieldGroup.listening(false);
|
||||
} catch (err) {
|
||||
console.error('Unable to render one or more fields belonging to other recipients.');
|
||||
console.error(err);
|
||||
@@ -488,10 +533,19 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
return;
|
||||
}
|
||||
|
||||
// When the pending-field tooltip toggles, all unsigned required fields need to
|
||||
// be re-rendered so their stroke color updates (green <-> orange). Field-level
|
||||
// properties like `inserted` and `customText` haven't changed, so the cache
|
||||
// would otherwise skip them — clear it to force a fresh render.
|
||||
if (prevShowPendingFieldTooltip.current !== showPendingFieldTooltip) {
|
||||
cachedRenderFields.current.clear();
|
||||
prevShowPendingFieldTooltip.current = showPendingFieldTooltip;
|
||||
}
|
||||
|
||||
renderFields();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
|
||||
}, [localPageFields, showPendingFieldTooltip]);
|
||||
|
||||
/**
|
||||
* Rerender the whole page if the selected assistant recipient changes.
|
||||
@@ -503,6 +557,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
|
||||
// Rerender the whole page.
|
||||
pageLayer.current.destroyChildren();
|
||||
cachedRenderFields.current.clear();
|
||||
|
||||
renderFields();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getAllowedUploadMimeTypes } from '@documenso/lib/constants/document-conversion';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -20,9 +20,9 @@ import { Loader } from 'lucide-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { getUploadErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
export interface EnvelopeDropZoneWrapperProps {
|
||||
children: ReactNode;
|
||||
@@ -109,27 +109,11 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
|
||||
.with(
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
|
||||
.with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`)
|
||||
.with(
|
||||
'CONVERSION_SERVICE_UNAVAILABLE',
|
||||
() => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
|
||||
)
|
||||
.with(
|
||||
'CONVERSION_FAILED',
|
||||
() => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
|
||||
)
|
||||
.otherwise(() => t`An error occurred during upload.`);
|
||||
const errorMessage = getUploadErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: errorMessage,
|
||||
title: i18n._(errorMessage.title),
|
||||
description: i18n._(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
@@ -17,9 +17,9 @@ import { EnvelopeType } from '@prisma/client';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { getUploadErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
export type EnvelopeUploadButtonProps = {
|
||||
className?: string;
|
||||
@@ -112,27 +112,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
|
||||
console.error(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
|
||||
.with(
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
|
||||
.with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`)
|
||||
.with(
|
||||
'CONVERSION_SERVICE_UNAVAILABLE',
|
||||
() => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
|
||||
)
|
||||
.with(
|
||||
'CONVERSION_FAILED',
|
||||
() => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
|
||||
)
|
||||
.otherwise(() => t`An error occurred while uploading your document.`);
|
||||
const errorMessage = getUploadErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: errorMessage,
|
||||
title: i18n._(errorMessage.title),
|
||||
description: i18n._(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import { Progress } from '@documenso/ui/primitives/progress';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
||||
import { useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
|
||||
|
||||
type OrganisationUsagePanelProps = {
|
||||
organisationId: string;
|
||||
monthlyStats: Pick<
|
||||
OrganisationMonthlyStat,
|
||||
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
|
||||
>[];
|
||||
organisationClaim: OrganisationClaim;
|
||||
};
|
||||
|
||||
export const OrganisationUsagePanel = ({
|
||||
organisationId,
|
||||
monthlyStats,
|
||||
organisationClaim,
|
||||
}: OrganisationUsagePanelProps) => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
|
||||
|
||||
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
|
||||
|
||||
// Resetting a counter only affects the current month (the server hardcodes the
|
||||
// current period), so only offer the reset action when viewing the current month.
|
||||
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
|
||||
|
||||
const rows = [
|
||||
{
|
||||
counter: 'document' as const,
|
||||
label: <Trans>Documents</Trans>,
|
||||
used: selectedStat?.documentCount ?? 0,
|
||||
effectiveLimit: organisationClaim.documentQuota,
|
||||
},
|
||||
{
|
||||
counter: 'email' as const,
|
||||
label: <Trans>Emails</Trans>,
|
||||
used: selectedStat?.emailCount ?? 0,
|
||||
effectiveLimit: organisationClaim.emailQuota,
|
||||
},
|
||||
{
|
||||
counter: 'api' as const,
|
||||
label: <Trans>API requests</Trans>,
|
||||
used: selectedStat?.apiCount ?? 0,
|
||||
effectiveLimit: organisationClaim.apiQuota,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-sm">
|
||||
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
|
||||
</h3>
|
||||
|
||||
{monthlyStats.length > 0 && (
|
||||
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthlyStats.map((stat) => (
|
||||
<SelectItem key={stat.period} value={stat.period}>
|
||||
{stat.period}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rows.map((row) => {
|
||||
const percent =
|
||||
row.effectiveLimit && row.effectiveLimit > 0
|
||||
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={row.counter} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{row.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{row.used} /{' '}
|
||||
{match(row.effectiveLimit)
|
||||
.with(null, () => <Trans>Unlimited</Trans>)
|
||||
.with(0, () => <Trans>Blocked</Trans>)
|
||||
.otherwise(String)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
|
||||
|
||||
{selectedStat && isCurrentPeriod && (
|
||||
<div className="flex w-full justify-end pt-1">
|
||||
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>
|
||||
<Trans>Reports</Trans>
|
||||
</span>
|
||||
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useRevalidator } from 'react-router';
|
||||
|
||||
type OrganisationUsageResetButtonProps = {
|
||||
organisationId: string;
|
||||
counter: 'document' | 'email' | 'api';
|
||||
};
|
||||
|
||||
export const OrganisationUsageResetButton = ({ organisationId, counter }: OrganisationUsageResetButtonProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const { mutateAsync: reset, isPending } = trpc.admin.organisation.stats.reset.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({ title: t`Counter reset.` });
|
||||
await revalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t`Failed to reset counter.`, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={isPending}
|
||||
onClick={() => reset({ organisationId, counter })}
|
||||
>
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
||||
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -53,9 +54,9 @@ export const OrganisationBillingBanner = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const subscriptionStatus = organisation?.subscription?.status;
|
||||
const bannerVariant = getBannerVariant(organisation);
|
||||
|
||||
if (!organisation || subscriptionStatus === undefined || subscriptionStatus === SubscriptionStatus.ACTIVE) {
|
||||
if (!organisation || bannerVariant === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -63,27 +64,28 @@ export const OrganisationBillingBanner = () => {
|
||||
<>
|
||||
<div
|
||||
className={cn({
|
||||
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': subscriptionStatus === SubscriptionStatus.PAST_DUE,
|
||||
'bg-destructive text-destructive-foreground': subscriptionStatus === SubscriptionStatus.INACTIVE,
|
||||
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': bannerVariant === 'PAST_DUE',
|
||||
'bg-destructive text-destructive-foreground':
|
||||
bannerVariant === 'INACTIVE' || bannerVariant === 'PENDING_PAYMENT',
|
||||
})}
|
||||
>
|
||||
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 font-medium text-sm">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||
|
||||
{match(subscriptionStatus)
|
||||
.with(SubscriptionStatus.PAST_DUE, () => <Trans>Payment overdue</Trans>)
|
||||
.with(SubscriptionStatus.INACTIVE, () => <Trans>Restricted Access</Trans>)
|
||||
{match(bannerVariant)
|
||||
.with('PAST_DUE', () => <Trans>Payment overdue</Trans>)
|
||||
.with('INACTIVE', () => <Trans>Restricted Access</Trans>)
|
||||
.with('PENDING_PAYMENT', () => <Trans>Payment required</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn({
|
||||
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
|
||||
subscriptionStatus === SubscriptionStatus.PAST_DUE,
|
||||
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500': bannerVariant === 'PAST_DUE',
|
||||
'text-destructive-foreground hover:bg-destructive hover:text-white':
|
||||
subscriptionStatus === SubscriptionStatus.INACTIVE,
|
||||
bannerVariant === 'INACTIVE' || bannerVariant === 'PENDING_PAYMENT',
|
||||
})}
|
||||
disabled={isPending}
|
||||
onClick={() => setIsOpen(true)}
|
||||
@@ -95,8 +97,8 @@ export const OrganisationBillingBanner = () => {
|
||||
</div>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
|
||||
{match(subscriptionStatus)
|
||||
.with(SubscriptionStatus.PAST_DUE, () => (
|
||||
{match(bannerVariant)
|
||||
.with('PAST_DUE', () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -117,7 +119,7 @@ export const OrganisationBillingBanner = () => {
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with(SubscriptionStatus.INACTIVE, () => (
|
||||
.with('INACTIVE', () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -153,8 +155,66 @@ export const OrganisationBillingBanner = () => {
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
.with('PENDING_PAYMENT', () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Payment required</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>This organisation is awaiting payment. Complete checkout to unlock it.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
If there is any issue with your subscription, please contact us at{' '}
|
||||
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button asChild>
|
||||
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||
<Trans>Manage Billing</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type BannerVariant = 'PAST_DUE' | 'INACTIVE' | 'PENDING_PAYMENT';
|
||||
|
||||
const getBannerVariant = (organisation: ReturnType<typeof useOptionalCurrentOrganisation>): BannerVariant | null => {
|
||||
if (!organisation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isOrganisationPendingPayment(organisation)) {
|
||||
return 'PENDING_PAYMENT';
|
||||
}
|
||||
|
||||
const subscriptionStatus = organisation.subscription?.status;
|
||||
|
||||
if (subscriptionStatus === SubscriptionStatus.PAST_DUE) {
|
||||
return 'PAST_DUE';
|
||||
}
|
||||
|
||||
if (subscriptionStatus === SubscriptionStatus.INACTIVE) {
|
||||
return 'INACTIVE';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const OrganisationQuotaBanner = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const organisation = useOptionalCurrentOrganisation();
|
||||
|
||||
const { data: quotaFlags } = trpc.organisation.getQuotaFlags.useQuery(
|
||||
{ organisationId: organisation?.id ?? '' },
|
||||
{
|
||||
enabled: Boolean(organisation),
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
refetchInterval: 1000 * 60,
|
||||
refetchIntervalInBackground: false,
|
||||
},
|
||||
);
|
||||
|
||||
const isAnyQuotaExceeded = Boolean(
|
||||
quotaFlags?.isDocumentQuotaExceeded || quotaFlags?.isEmailQuotaExceeded || quotaFlags?.isApiQuotaExceeded,
|
||||
);
|
||||
|
||||
// Every member of the organisation sees the banner when a quota is exhausted.
|
||||
// Note: Skipping free plan banner for now because their quota can incorrectly show as exceeded.
|
||||
if (
|
||||
!organisation ||
|
||||
!quotaFlags ||
|
||||
!isAnyQuotaExceeded ||
|
||||
organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-yellow-200 text-yellow-900 dark:bg-yellow-400">
|
||||
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 font-medium text-sm">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||
|
||||
<Trans>Your organisation has exceeded a fair use limit</Trans>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500"
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Trans>Learn more</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Fair use limit exceeded</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Your organisation has exceeded a fair use limit. Please contact{' '}
|
||||
<a className="text-primary" href={`mailto:${SUPPORT_EMAIL}`}>
|
||||
support
|
||||
</a>{' '}
|
||||
to review your plan's limits.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
{quotaFlags.isDocumentQuotaExceeded && (
|
||||
<li className="list-disc">
|
||||
<Trans>Document creation has been temporarily paused</Trans>
|
||||
</li>
|
||||
)}
|
||||
{quotaFlags.isEmailQuotaExceeded && (
|
||||
<li className="list-disc">
|
||||
<Trans>Email sending has been temporarily paused</Trans>
|
||||
</li>
|
||||
)}
|
||||
{quotaFlags.isApiQuotaExceeded && (
|
||||
<li className="list-disc">
|
||||
<Trans>API requests have been temporarily paused</Trans>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react';
|
||||
|
||||
type RateLimitEntryValue = { window: string; max: number };
|
||||
|
||||
type RateLimitArrayInputProps = {
|
||||
value: RateLimitEntryValue[];
|
||||
onChange: (value: RateLimitEntryValue[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
|
||||
const entries = value ?? [];
|
||||
|
||||
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
|
||||
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
onChange(entries.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
onChange([...entries, { window: '5m', max: 100 }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-24"
|
||||
placeholder="5m"
|
||||
value={entry.window}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateEntry(index, { window: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="w-32"
|
||||
type="number"
|
||||
min={1}
|
||||
value={entry.max}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Add rate limit</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EditIcon, MoreHorizontalIcon, SendIcon, Trash2Icon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { EmailTransportDeleteDialog } from '../dialogs/email-transport-delete-dialog';
|
||||
import { EmailTransportSendTestDialog } from '../dialogs/email-transport-send-test-dialog';
|
||||
import { EmailTransportUpdateDialog } from '../dialogs/email-transport-update-dialog';
|
||||
|
||||
export const AdminEmailTransportsTable = () => {
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.emailTransport.find.useQuery({
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
});
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 20,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: t`Name`,
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: t`Type`,
|
||||
accessorKey: 'type',
|
||||
},
|
||||
{
|
||||
header: t`From`,
|
||||
cell: ({ row }) => `${row.original.fromName} <${row.original.fromAddress}>`,
|
||||
},
|
||||
{
|
||||
header: t`Used by claims`,
|
||||
cell: ({ row }) => row.original._count.subscriptionClaims + row.original._count.organisationClaims,
|
||||
},
|
||||
{
|
||||
header: t`Created`,
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Actions</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<EmailTransportUpdateDialog
|
||||
transport={row.original}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<EditIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<EmailTransportSendTestDialog
|
||||
transportId={row.original.id}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<SendIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Send test</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<EmailTransportDeleteDialog
|
||||
transportId={row.original.id}
|
||||
transportName={row.original.name}
|
||||
subscriptionClaimCount={row.original._count.subscriptionClaims}
|
||||
organisationClaimCount={row.original._count.organisationClaims}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell className="py-4 pr-4">
|
||||
<Skeleton className="h-4 w-24 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-40 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-end space-x-2">
|
||||
<Skeleton className="h-2 w-6 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
|
||||
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
|
||||
type OrderByDirection = 'asc' | 'desc';
|
||||
|
||||
const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => {
|
||||
if (
|
||||
value === 'documentCount' ||
|
||||
value === 'emailCount' ||
|
||||
value === 'apiCount' ||
|
||||
value === 'emailReports' ||
|
||||
value === 'totalCount'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseOrderByDirection = (value: string | undefined): OrderByDirection => {
|
||||
return value === 'asc' ? 'asc' : 'desc';
|
||||
};
|
||||
|
||||
/**
|
||||
* Number of days to divide the period's usage by to get a per-day average.
|
||||
*
|
||||
* For the in-progress (current) month we divide by today's UTC day-of-month so the
|
||||
* average reflects elapsed days only. For a fully-elapsed past month we divide by the
|
||||
* total number of days in that month.
|
||||
*/
|
||||
const getPeriodDivisor = (period: string): number => {
|
||||
if (period === currentMonthlyPeriod()) {
|
||||
return new Date().getUTCDate();
|
||||
}
|
||||
|
||||
const [yearStr, monthStr] = period.split('-');
|
||||
const year = Number(yearStr);
|
||||
const month = Number(monthStr);
|
||||
|
||||
if (Number.isNaN(year) || Number.isNaN(month)) {
|
||||
return new Date().getUTCDate();
|
||||
}
|
||||
|
||||
// Day 0 of the following month resolves to the last day of `month`.
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
};
|
||||
|
||||
export type OrganisationStatsDisplayMode = 'usage' | 'quotas' | 'averages';
|
||||
|
||||
type AdminOrganisationStatsTableProps = {
|
||||
displayMode?: OrganisationStatsDisplayMode;
|
||||
};
|
||||
|
||||
export const AdminOrganisationStatsTable = ({ displayMode = 'usage' }: AdminOrganisationStatsTableProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
// Default to the current month.
|
||||
const period = searchParams?.get('period') ?? currentMonthlyPeriod();
|
||||
const claimId = searchParams?.get('claimId') || undefined;
|
||||
const orderByColumn = parseOrderByColumn(searchParams?.get('orderByColumn') ?? undefined);
|
||||
const orderByDirection = parseOrderByDirection(searchParams?.get('orderByDirection') ?? undefined);
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.organisation.stats.find.useQuery({
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
period,
|
||||
claimId,
|
||||
orderByColumn,
|
||||
orderByDirection,
|
||||
});
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const handleColumnSort = (column: OrderByColumn) => {
|
||||
const nextDirection = orderByColumn === column && orderByDirection === 'desc' ? 'asc' : 'desc';
|
||||
|
||||
// Use the functional updater so we merge onto the latest params. Reading the
|
||||
// captured `searchParams` here would drop filters (e.g. claimId) that changed
|
||||
// after this handler was memoised into the column definitions.
|
||||
setSearchParams((previous) => {
|
||||
const next = new URLSearchParams(previous);
|
||||
|
||||
next.set('orderByColumn', column);
|
||||
next.set('orderByDirection', nextDirection);
|
||||
next.set('page', '1');
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const divisor = getPeriodDivisor(period);
|
||||
|
||||
const formatPerDay = (used: number) => {
|
||||
const perDay = divisor > 0 ? used / divisor : 0;
|
||||
const rounded = Math.round(perDay * 10) / 10;
|
||||
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
||||
};
|
||||
|
||||
const renderUsageCell = (used: number, quota: number | null) => {
|
||||
if (displayMode === 'averages') {
|
||||
return formatPerDay(used);
|
||||
}
|
||||
|
||||
if (displayMode === 'quotas') {
|
||||
return (
|
||||
<span>
|
||||
{used}/{quota === null ? '∞' : quota}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{used}</span>;
|
||||
};
|
||||
|
||||
const sortableHeader = (label: string, column: OrderByColumn) => (
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center whitespace-nowrap"
|
||||
onClick={() => handleColumnSort(column)}
|
||||
>
|
||||
{label}
|
||||
{orderByColumn === column ? (
|
||||
orderByDirection === 'asc' ? (
|
||||
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
header: t`Organisation`,
|
||||
accessorKey: 'organisationName',
|
||||
cell: ({ row }) => (
|
||||
<Link to={`/admin/organisations/${row.original.organisationId}`} className="text-sm hover:underline">
|
||||
{row.original.organisationName}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t`Claim`,
|
||||
accessorKey: 'originalClaimId',
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-sm">{row.original.originalClaimId ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
header: t`Period`,
|
||||
accessorKey: 'period',
|
||||
cell: ({ row }) => <span className="text-sm">{row.original.period}</span>,
|
||||
},
|
||||
{
|
||||
header: () => sortableHeader(t`Documents`, 'documentCount'),
|
||||
accessorKey: 'documentCount',
|
||||
cell: ({ row }) => renderUsageCell(row.original.documentCount, row.original.documentQuota),
|
||||
},
|
||||
{
|
||||
header: () => sortableHeader(t`Emails`, 'emailCount'),
|
||||
accessorKey: 'emailCount',
|
||||
cell: ({ row }) => renderUsageCell(row.original.emailCount, row.original.emailQuota),
|
||||
},
|
||||
{
|
||||
header: () => sortableHeader(t`API`, 'apiCount'),
|
||||
accessorKey: 'apiCount',
|
||||
cell: ({ row }) => renderUsageCell(row.original.apiCount, row.original.apiQuota),
|
||||
},
|
||||
{
|
||||
header: () => sortableHeader(t`Reports`, 'emailReports'),
|
||||
accessorKey: 'emailReports',
|
||||
cell: ({ row }) => row.original.emailReports,
|
||||
},
|
||||
{
|
||||
header: () => sortableHeader(t`Total`, 'totalCount'),
|
||||
accessorKey: 'totalCount',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.totalCount}</span>,
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
// `searchParams` must be a dependency: `handleColumnSort` closes over `setSearchParams`,
|
||||
// whose functional updater is bound to the `searchParams` captured at creation time.
|
||||
// Without this, changing a filter (e.g. claimId) wouldn't refresh the memoised handler,
|
||||
// and sorting would merge onto stale params and drop the active filter.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [t, orderByColumn, orderByDirection, period, displayMode, searchParams]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
rowClassName="text-sm"
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 5,
|
||||
component: (
|
||||
<>
|
||||
<TableCell className="py-4 pr-4">
|
||||
<Skeleton className="h-4 w-32 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-10 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-10 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-10 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-10 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-10 rounded-full" />
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -207,7 +207,7 @@ export const OrganisationInsightsTable = ({
|
||||
<SummaryCard
|
||||
icon={TrendingUp}
|
||||
title={_(msg`Documents Completed`)}
|
||||
value={insights.summary.volumeThisPeriod}
|
||||
value={`${insights.summary.volumeThisPeriod}/${insights.summary.documentsThisPeriod}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -269,7 +269,7 @@ const SummaryCard = ({
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
value: number;
|
||||
value: number | string;
|
||||
subtitle?: string;
|
||||
}) => (
|
||||
<div className="flex items-start gap-x-2 rounded-lg border bg-card px-4 py-3">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
@@ -21,8 +22,8 @@ export const UserBillingOrganisationsTable = () => {
|
||||
return organisations.filter((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole));
|
||||
}, [organisations]);
|
||||
|
||||
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
|
||||
return match(status)
|
||||
const getSubscriptionStatusDisplay = (organisation: (typeof billingOrganisations)[number]) => {
|
||||
return match(organisation.subscription?.status)
|
||||
.with(SubscriptionStatus.ACTIVE, () => ({
|
||||
label: t({ message: `Active`, context: `Subscription status` }),
|
||||
variant: 'default' as const,
|
||||
@@ -35,10 +36,19 @@ export const UserBillingOrganisationsTable = () => {
|
||||
label: t({ message: `Inactive`, context: `Subscription status` }),
|
||||
variant: 'neutral' as const,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
label: t({ message: `Free`, context: `Subscription status` }),
|
||||
variant: 'neutral' as const,
|
||||
}));
|
||||
.otherwise(() => {
|
||||
if (isOrganisationPendingPayment(organisation)) {
|
||||
return {
|
||||
label: t({ message: `Free (Pending)`, context: `Subscription status` }),
|
||||
variant: 'warning' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: t({ message: `Free`, context: `Subscription status` }),
|
||||
variant: 'neutral' as const,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
@@ -62,9 +72,7 @@ export const UserBillingOrganisationsTable = () => {
|
||||
header: t`Subscription Status`,
|
||||
accessorKey: 'subscription',
|
||||
cell: ({ row }) => {
|
||||
const subscription = row.original.subscription;
|
||||
const status = subscription?.status;
|
||||
const { label, variant } = getSubscriptionStatusDisplay(status);
|
||||
const { label, variant } = getSubscriptionStatusDisplay(row.original);
|
||||
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AppBanner } from '~/components/general/app-banner';
|
||||
import { Header } from '~/components/general/app-header';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { OrganisationBillingBanner } from '~/components/general/organisations/organisation-billing-banner';
|
||||
import { OrganisationQuotaBanner } from '~/components/general/organisations/organisation-quota-banner';
|
||||
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
|
||||
import { TeamProvider } from '~/providers/team';
|
||||
|
||||
@@ -109,6 +110,8 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
||||
<TeamProvider team={currentTeam || null}>
|
||||
<OrganisationBillingBanner />
|
||||
|
||||
<OrganisationQuotaBanner />
|
||||
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
|
||||
{banner && !hideHeader && <AppBanner banner={banner} />}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
BarChart3,
|
||||
Building2Icon,
|
||||
FileStack,
|
||||
LineChartIcon,
|
||||
MailIcon,
|
||||
Settings,
|
||||
Trophy,
|
||||
@@ -128,6 +129,17 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/email-transports') && 'bg-secondary')}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/email-transports">
|
||||
<MailIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Email Transports</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/email-domains') && 'bg-secondary')}
|
||||
@@ -153,6 +165,20 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/organisation-stats') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/organisation-stats">
|
||||
<LineChartIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Organisation Stats</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/site-settings') && 'bg-secondary')}
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function AdminDocumentsPage() {
|
||||
<div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search by document title`)}
|
||||
placeholder={_(msg`Search by document title, team:123 or user:123`)}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
|
||||
import { EmailTransportCreateDialog } from '~/components/dialogs/email-transport-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { AdminEmailTransportsTable } from '~/components/tables/admin-email-transports-table';
|
||||
|
||||
export default function AdminEmailTransportsPage() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={t`Email Transports`} subtitle={t`Manage all email transports`} hideDivider>
|
||||
<EmailTransportCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t`Search by name or from address`}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<AdminEmailTransportsTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import {
|
||||
AdminOrganisationStatsTable,
|
||||
type OrganisationStatsDisplayMode,
|
||||
} from '~/components/tables/admin-organisation-stats-table';
|
||||
|
||||
const ALL_CLAIMS_VALUE = 'all';
|
||||
|
||||
/**
|
||||
* The earliest UTC calendar month for which stats exist (the month the feature launched).
|
||||
* Months before this never have data, so there's no point offering them in the filter.
|
||||
*/
|
||||
const EARLIEST_PERIOD = { year: 2026, month: 5 };
|
||||
|
||||
/**
|
||||
* Generate every UTC calendar month from `EARLIEST_PERIOD` up to the current month as
|
||||
* `YYYY-MM` strings, newest first.
|
||||
*/
|
||||
const generatePeriodOptions = (): string[] => {
|
||||
const periods: string[] = [];
|
||||
const now = new Date();
|
||||
|
||||
let year = now.getUTCFullYear();
|
||||
let month = now.getUTCMonth() + 1;
|
||||
|
||||
while (year > EARLIEST_PERIOD.year || (year === EARLIEST_PERIOD.year && month >= EARLIEST_PERIOD.month)) {
|
||||
periods.push(`${year}-${String(month).padStart(2, '0')}`);
|
||||
|
||||
month -= 1;
|
||||
|
||||
if (month === 0) {
|
||||
month = 12;
|
||||
year -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return periods;
|
||||
};
|
||||
|
||||
export default function OrganisationStats() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const [displayMode, setDisplayMode] = useState<OrganisationStatsDisplayMode>('usage');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
const periodOptions = useMemo(() => generatePeriodOptions(), []);
|
||||
|
||||
const selectedPeriod = searchParams?.get('period') ?? currentMonthlyPeriod();
|
||||
const selectedClaim = searchParams?.get('claimId') ?? ALL_CLAIMS_VALUE;
|
||||
|
||||
const { data: claimsData, isLoading: isLoadingClaims } = trpc.admin.claims.find.useQuery({
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
const claimOptions = claimsData?.data ?? [];
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
if ((searchParams?.get('query') || '') !== debouncedSearchQuery) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
|
||||
const onPeriodChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('period', value);
|
||||
params.delete('page');
|
||||
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const onClaimChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
if (value === ALL_CLAIMS_VALUE) {
|
||||
params.delete('claimId');
|
||||
} else {
|
||||
params.set('claimId', value);
|
||||
}
|
||||
|
||||
params.delete('page');
|
||||
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Organisation Stats`}
|
||||
subtitle={t`View, sort and filter monthly usage stats across organisations`}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4 sm:flex-row">
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t`Search by organisation name, URL or ID`}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<Select value={selectedClaim} onValueChange={onClaimChange}>
|
||||
<SelectTrigger className="w-full sm:w-48" loading={isLoadingClaims}>
|
||||
<SelectValue placeholder={t`All claims`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_CLAIMS_VALUE}>{t`All claims`}</SelectItem>
|
||||
{claimOptions.map((claim) => (
|
||||
<SelectItem key={claim.id} value={claim.id}>
|
||||
{claim.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={selectedPeriod} onValueChange={onPeriodChange}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder={t`Period`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{periodOptions.map((period) => (
|
||||
<SelectItem key={period} value={period}>
|
||||
{period}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<AdminOrganisationStatsTable displayMode={displayMode} />
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
value={displayMode}
|
||||
onValueChange={(value) =>
|
||||
setDisplayMode(value === 'quotas' ? 'quotas' : value === 'averages' ? 'averages' : 'usage')
|
||||
}
|
||||
className="mt-4 flex flex-col gap-3 rounded-lg border border-border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="display-usage" value="usage" />
|
||||
<label htmlFor="display-usage" className="text-muted-foreground text-sm">
|
||||
<Trans>Show usage</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="display-quotas" value="quotas" />
|
||||
<label htmlFor="display-quotas" className="text-muted-foreground text-sm">
|
||||
<Trans>Show usage with quotas</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="display-averages" value="averages" />
|
||||
<label htmlFor="display-averages" className="text-muted-foreground text-sm">
|
||||
<Trans>Show daily averages for documents, emails and API usages</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<Alert variant="neutral" className="mt-4">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Documents, emails and api values may not be accurate since they record the amount of times the action was
|
||||
attempted. Meaning these values may go over the actual quota, get rejected, and will still be recorded.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -40,9 +41,12 @@ import type { z } from 'zod';
|
||||
import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organisation-delete-dialog';
|
||||
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
|
||||
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
|
||||
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { OrganisationUsagePanel } from '~/components/general/organisation-usage-panel';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
import type { Route } from './+types/organisations.$id';
|
||||
@@ -293,6 +297,14 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<OrganisationUsagePanel
|
||||
organisationId={organisation.id}
|
||||
monthlyStats={organisation.monthlyStats}
|
||||
organisationClaim={organisation.organisationClaim}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg border p-4">
|
||||
@@ -367,7 +379,16 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
)}
|
||||
|
||||
{organisation.subscription && (
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<AdminOrganisationSyncSubscriptionDialog
|
||||
organisationId={organisationId}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Trans>Sync Stripe subscription</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
@@ -552,6 +573,10 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
|
||||
const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation();
|
||||
|
||||
const { data: transportsData } = trpc.admin.emailTransport.find.useQuery({ perPage: 100 });
|
||||
const transports = transportsData?.data ?? [];
|
||||
const NONE_VALUE = '__none__';
|
||||
|
||||
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
|
||||
@@ -565,7 +590,24 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
teamCount: organisation.organisationClaim.teamCount,
|
||||
memberCount: organisation.organisationClaim.memberCount,
|
||||
envelopeItemCount: organisation.organisationClaim.envelopeItemCount,
|
||||
recipientCount: organisation.organisationClaim.recipientCount,
|
||||
flags: organisation.organisationClaim.flags,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
documentRateLimits: organisation.organisationClaim.documentRateLimits as NonNullable<
|
||||
TUpdateOrganisationBillingFormSchema['claims']
|
||||
>['documentRateLimits'],
|
||||
documentQuota: organisation.organisationClaim.documentQuota,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
emailRateLimits: organisation.organisationClaim.emailRateLimits as NonNullable<
|
||||
TUpdateOrganisationBillingFormSchema['claims']
|
||||
>['emailRateLimits'],
|
||||
emailQuota: organisation.organisationClaim.emailQuota,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
apiRateLimits: organisation.organisationClaim.apiRateLimits as NonNullable<
|
||||
TUpdateOrganisationBillingFormSchema['claims']
|
||||
>['apiRateLimits'],
|
||||
apiQuota: organisation.organisationClaim.apiQuota,
|
||||
emailTransportId: organisation.organisationClaim.emailTransportId ?? null,
|
||||
},
|
||||
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
|
||||
},
|
||||
@@ -745,6 +787,30 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.recipientCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
<Trans>Feature Flags</Trans>
|
||||
@@ -803,6 +869,42 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ClaimLimitFields control={form.control} prefix="claims." />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.emailTransportId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email transport</Trans>
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value ?? NONE_VALUE}
|
||||
onValueChange={(value) => field.onChange(value === NONE_VALUE ? null : value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Default (system mailer)`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_VALUE}>{t`Default (system mailer)`}</SelectItem>
|
||||
{transports.map((transport) => (
|
||||
<SelectItem key={transport.id} value={transport.id}>
|
||||
{transport.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
<Trans>Organisations without a transport use the system default mailer.</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
|
||||
@@ -1,210 +1,36 @@
|
||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||
import {
|
||||
SITE_SETTINGS_BANNER_ID,
|
||||
ZSiteSettingsBannerSchema,
|
||||
} from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
import { SITE_SETTINGS_EMAIL_BLOCKLIST_ID } from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { AdminEmailBlocklistSection } from '~/components/general/admin-email-blocklist-section';
|
||||
import { AdminSiteBannerSection } from '~/components/general/admin-site-banner-section';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
import type { Route } from './+types/site-settings';
|
||||
|
||||
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
|
||||
|
||||
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
|
||||
|
||||
export async function loader() {
|
||||
const banner = await getSiteSettings().then((settings) =>
|
||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||
);
|
||||
const settings = await getSiteSettings();
|
||||
|
||||
return { banner };
|
||||
const banner = settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID);
|
||||
const emailBlocklist = settings.find((setting) => setting.id === SITE_SETTINGS_EMAIL_BLOCKLIST_ID);
|
||||
|
||||
return { banner, emailBlocklist };
|
||||
}
|
||||
|
||||
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
|
||||
const { banner } = loaderData;
|
||||
export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { banner, emailBlocklist } = loaderData;
|
||||
|
||||
const nonce = useCspNonce();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const form = useForm<TBannerFormSchema>({
|
||||
resolver: zodResolver(ZBannerFormSchema),
|
||||
defaultValues: {
|
||||
id: SITE_SETTINGS_BANNER_ID,
|
||||
enabled: banner?.enabled ?? false,
|
||||
data: {
|
||||
content: banner?.data?.content ?? '',
|
||||
bgColor: banner?.data?.bgColor ?? '#000000',
|
||||
textColor: banner?.data?.textColor ?? '#FFFFFF',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = form.watch('enabled');
|
||||
|
||||
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
|
||||
trpcReact.admin.updateSiteSetting.useMutation();
|
||||
|
||||
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
|
||||
try {
|
||||
await updateSiteSetting({
|
||||
id,
|
||||
enabled,
|
||||
data,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Banner Updated`),
|
||||
description: _(msg`Your banner has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={_(msg`Site Settings`)} subtitle={_(msg`Manage your site settings here`)} />
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
<Trans>Site Banner</Trans>
|
||||
</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
The site banner is a message that is shown at the top of the site. It can be used to display important
|
||||
information to your users.
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="mt-8 space-y-12">
|
||||
<AdminSiteBannerSection banner={banner} />
|
||||
|
||||
<Form {...form}>
|
||||
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBannerUpdate)}>
|
||||
<div className="mt-4 flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Enabled</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<fieldset className="flex flex-col gap-4 md:flex-row" disabled={!enabled} aria-disabled={!enabled}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.bgColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Background Color</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} nonce={nonce} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.textColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Text Color</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} nonce={nonce} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={!enabled} aria-disabled={!enabled}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Content</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="h-32 resize-none" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The content to show in the banner, HTML is allowed</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
|
||||
<Trans>Update Banner</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import type Stripe from 'stripe';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
@@ -23,12 +25,51 @@ export default function TeamsSettingBillingPage() {
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
|
||||
trpc.enterprise.billing.subscription.get.useQuery({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
if (isLoadingSubscription || !subscriptionQuery) {
|
||||
const { mutateAsync: syncSubscription, isPending: isSyncingSubscription } =
|
||||
trpc.enterprise.billing.subscription.sync.useMutation();
|
||||
|
||||
const hasTriggeredCheckoutSyncRef = useRef(false);
|
||||
|
||||
const isCheckoutSuccess = searchParams.get('success') === 'true';
|
||||
|
||||
/**
|
||||
* Eagerly sync the subscription from Stripe when returning from a successful
|
||||
* checkout, since the webhook may not have arrived yet.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isCheckoutSuccess || hasTriggeredCheckoutSyncRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriggeredCheckoutSyncRef.current = true;
|
||||
|
||||
void syncSubscription({ organisationId: organisation.id })
|
||||
.catch(() => {
|
||||
// Non-fatal, webhooks will converge the subscription state shortly.
|
||||
})
|
||||
.finally(() => {
|
||||
void utils.enterprise.billing.invalidate();
|
||||
|
||||
setSearchParams(
|
||||
(params) => {
|
||||
params.delete('success');
|
||||
|
||||
return params;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
}, [isCheckoutSuccess, organisation.id]);
|
||||
|
||||
if (isLoadingSubscription || !subscriptionQuery || isSyncingSubscription) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -21,7 +22,11 @@ export default function Layout() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (organisation?.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) {
|
||||
const isRestricted =
|
||||
(organisation.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) ||
|
||||
isOrganisationPendingPayment(organisation);
|
||||
|
||||
if (isRestricted) {
|
||||
return {
|
||||
quota: {
|
||||
documents: 0,
|
||||
@@ -42,7 +47,7 @@ export default function Layout() {
|
||||
remaining: PAID_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
};
|
||||
}, [organisation?.subscription]);
|
||||
}, [organisation]);
|
||||
|
||||
if (!team) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Route } from './+types/report.$token';
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Only validate the token on GET. The report itself is performed by an explicit
|
||||
// mutation (triggered by the recipient clicking the button), so an automated email
|
||||
// link scanner / prefetcher cannot register a report simply by fetching the URL.
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: { token },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ReportSenderPage({ loaderData }: Route.ComponentProps) {
|
||||
const { token } = loaderData;
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isReported, setIsReported] = useState(false);
|
||||
|
||||
const { mutate: reportSender, isPending } = trpc.envelope.recipient.report.useMutation({
|
||||
onSuccess: () => setIsReported(true),
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We were unable to report this sender at this time. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isReported) {
|
||||
return (
|
||||
<div className="-mx-4 flex flex-col items-center px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28">
|
||||
<h1 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl">
|
||||
<Trans>Sender reported</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 max-w-[60ch] text-center text-muted-foreground leading-normal">
|
||||
<Trans>
|
||||
Thank you for letting us know, we have flagged this sender for review. If you have any concerns please feel
|
||||
free to reach out to our{' '}
|
||||
<a className="text-documenso-700 underline" href={`mailto:${SUPPORT_EMAIL}`}>
|
||||
support team
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="-mx-4 flex flex-col items-center px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28">
|
||||
<h1 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl">
|
||||
<Trans>Report this sender?</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 max-w-[60ch] text-center text-muted-foreground leading-normal">
|
||||
<Trans>
|
||||
If you did not expect this email or believe it is spam, you can report the sender to our team for review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button className="mt-6" loading={isPending} onClick={() => reportSender({ token })}>
|
||||
<Trans>Report sender</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -164,6 +164,10 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
recipientSignature,
|
||||
isRecipientsTurn,
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
branding: {
|
||||
brandingEnabled: settings.brandingEnabled,
|
||||
brandingLogo: settings.brandingLogo,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -338,6 +342,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
|
||||
isRecipientsTurn,
|
||||
allRecipients,
|
||||
includeSenderDetails,
|
||||
branding,
|
||||
recipientWithFields,
|
||||
} = data;
|
||||
|
||||
@@ -360,8 +365,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>
|
||||
<span className="mt-1.5 block">"{document.title}"</span>
|
||||
is no longer available to sign
|
||||
<span className="mt-1.5 block">"{document.title}"</span> is no longer available to sign
|
||||
</Trans>
|
||||
</h2>
|
||||
|
||||
@@ -410,6 +414,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
includeSenderDetails={includeSenderDetails}
|
||||
branding={branding}
|
||||
/>
|
||||
</div>
|
||||
</DocumentSigningAuthProvider>
|
||||
@@ -446,8 +451,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>
|
||||
<span className="mt-1.5 block">"{envelope.title}"</span>
|
||||
is no longer available to sign
|
||||
<span className="mt-1.5 block">"{envelope.title}"</span> is no longer available to sign
|
||||
</Trans>
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -1,98 +1,15 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
import { Link } from 'react-router';
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import type { Route } from './+types/organisation.decline.$token';
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
export function loader({ params }: Route.LoaderArgs) {
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
state: 'InvalidLink',
|
||||
} as const;
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
organisation: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationMemberInvite) {
|
||||
return {
|
||||
state: 'InvalidLink',
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (organisationMemberInvite.status !== OrganisationMemberInviteStatus.DECLINED) {
|
||||
await prisma.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: OrganisationMemberInviteStatus.DECLINED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'Success',
|
||||
organisationName: organisationMemberInvite.organisation.name,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default function DeclineInvitationPage({ loaderData }: Route.ComponentProps) {
|
||||
const data = loaderData;
|
||||
|
||||
if (data.state === 'InvalidLink') {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invalid token</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>This token is invalid or has expired. No action is needed.</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invitation declined</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have declined the invitation from <strong>{data.organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Return to Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
// Declining now happens on the invite page via tRPC. Redirect there with the
|
||||
// `action=decline` flag so it renders the decline-only view (no accept).
|
||||
throw redirect(`/organisation/invite/${token}?action=decline`);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { Route } from './+types/organisation.invite.$token';
|
||||
|
||||
@@ -37,6 +43,22 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
const organisationName = organisationMemberInvite.organisation.name;
|
||||
|
||||
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
|
||||
return {
|
||||
state: 'AlreadyAccepted',
|
||||
organisationName,
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED) {
|
||||
return {
|
||||
state: 'AlreadyDeclined',
|
||||
organisationName,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
@@ -49,26 +71,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
},
|
||||
});
|
||||
|
||||
// Directly convert the team member invite to a team member if they already have an account.
|
||||
if (user) {
|
||||
await acceptOrganisationInvitation({ token: organisationMemberInvite.token });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
state: 'LoginRequired',
|
||||
email: organisationMemberInvite.email,
|
||||
organisationName: organisationMemberInvite.organisation.name,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
||||
|
||||
return {
|
||||
state: 'Success',
|
||||
state: 'Pending',
|
||||
token: organisationMemberInvite.token,
|
||||
email: organisationMemberInvite.email,
|
||||
organisationName: organisationMemberInvite.organisation.name,
|
||||
isSessionUserTheInvitedUser,
|
||||
organisationName,
|
||||
userExists: user !== null,
|
||||
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -97,57 +106,253 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
||||
);
|
||||
}
|
||||
|
||||
if (data.state === 'LoginRequired') {
|
||||
if (data.state === 'AlreadyAccepted') {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Organisation invitation</Trans>
|
||||
</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invitation already accepted</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have been invited by <strong>{data.organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You are already a member of <strong>{data.organisationName}</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>To accept this invitation you must create an account.</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to={`/signup#email=${encodeURIComponent(data.email)}`}>
|
||||
<Trans>Create account</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.state === 'AlreadyDeclined') {
|
||||
return <InvitationDeclined organisationName={data.organisationName} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invitation accepted!</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have accepted an invitation from <strong>{data.organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{data.isSessionUserTheInvitedUser ? (
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link to={`/signin#email=${encodeURIComponent(data.email)}`}>
|
||||
<Trans>Continue to login</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<PendingInvitation
|
||||
token={data.token}
|
||||
email={data.email}
|
||||
organisationName={data.organisationName}
|
||||
userExists={data.userExists}
|
||||
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type PendingInvitationProps = {
|
||||
token: string;
|
||||
email: string;
|
||||
organisationName: string;
|
||||
userExists: boolean;
|
||||
isSessionUserTheInvitedUser: boolean;
|
||||
};
|
||||
|
||||
type InvitationResult = 'idle' | 'accepted' | 'declined';
|
||||
|
||||
type AcceptFailureReason = 'CapExceeded' | 'SubscriptionInactive' | 'Unknown';
|
||||
|
||||
const PendingInvitation = ({
|
||||
token,
|
||||
email,
|
||||
organisationName,
|
||||
userExists,
|
||||
isSessionUserTheInvitedUser,
|
||||
}: PendingInvitationProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useOptionalSession();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const actionIsDecline = searchParams.get('action') === 'decline';
|
||||
|
||||
const [result, setResult] = useState<InvitationResult>('idle');
|
||||
const [acceptFailureReason, setAcceptFailureReason] = useState<AcceptFailureReason | null>(null);
|
||||
|
||||
const acceptInvitation = trpc.organisation.member.invite.accept.useMutation({
|
||||
onSuccess: async () => {
|
||||
await refreshSession();
|
||||
|
||||
setResult('accepted');
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const failureReason = match(error.code)
|
||||
.with(AppErrorCode.LIMIT_EXCEEDED, () => 'CapExceeded' as const)
|
||||
.with('SUBSCRIPTION_INACTIVE', () => 'SubscriptionInactive' as const)
|
||||
.otherwise(() => 'Unknown' as const);
|
||||
|
||||
setAcceptFailureReason(failureReason);
|
||||
},
|
||||
});
|
||||
|
||||
const declineInvitation = trpc.organisation.member.invite.decline.useMutation({
|
||||
onSuccess: async () => {
|
||||
await refreshSession();
|
||||
|
||||
setResult('declined');
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`Unable to decline this invitation at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (result === 'accepted') {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invitation accepted!</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have accepted an invitation from <strong>{organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{isSessionUserTheInvitedUser ? (
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link to={`/signin#email=${encodeURIComponent(email)}`}>
|
||||
<Trans>Continue to login</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result === 'declined') {
|
||||
return <InvitationDeclined organisationName={organisationName} />;
|
||||
}
|
||||
|
||||
// Accepting requires an account (acceptance keys off the invited email).
|
||||
// Declining does not, so we only gate account creation on the accept flow.
|
||||
if (!actionIsDecline && !userExists) {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Organisation invitation</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have been invited by <strong>{organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>To accept this invitation you must create an account.</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to={`/signup#email=${encodeURIComponent(email)}`}>
|
||||
<Trans>Create account</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPending = acceptInvitation.isPending || declineInvitation.isPending;
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Organisation invitation</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have been invited to join <strong>{organisationName}</strong> on Documenso.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{acceptFailureReason && (
|
||||
<p className="mt-2 mb-4 text-destructive text-sm">
|
||||
{match(acceptFailureReason)
|
||||
.with('CapExceeded', () => (
|
||||
<Trans>
|
||||
<strong>{organisationName}</strong> has reached its member limit. Please contact the organisation
|
||||
administrator to upgrade their plan before accepting this invitation.
|
||||
</Trans>
|
||||
))
|
||||
.with('SubscriptionInactive', () => (
|
||||
<Trans>
|
||||
<strong>{organisationName}</strong> does not have an active subscription. Please contact the
|
||||
organisation administrator to renew their plan before accepting this invitation.
|
||||
</Trans>
|
||||
))
|
||||
.with('Unknown', () => (
|
||||
<Trans>
|
||||
We were unable to add you to <strong>{organisationName}</strong> at this time. Please try again later,
|
||||
or contact the organisation administrator.
|
||||
</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => declineInvitation.mutateAsync({ token })}
|
||||
loading={declineInvitation.isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trans>Decline</Trans>
|
||||
</Button>
|
||||
|
||||
{!actionIsDecline && (
|
||||
<Button
|
||||
onClick={async () => acceptInvitation.mutateAsync({ token })}
|
||||
loading={acceptInvitation.isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trans>Accept</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InvitationDeclined = ({ organisationName }: { organisationName: string }) => {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invitation declined</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have declined the invitation from <strong>{organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -298,10 +298,10 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
presignToken: token,
|
||||
mode: 'edit' as const,
|
||||
onUpdate: async (envelope: TEditorEnvelope) => updateEmbeddedEnvelope(envelope),
|
||||
brandingLogo,
|
||||
customBrandingLogo: Boolean(brandingLogo),
|
||||
user: embedAuthoringOptions.user,
|
||||
}),
|
||||
[token],
|
||||
[token, brandingLogo, embedAuthoringOptions.user],
|
||||
);
|
||||
|
||||
const editorConfig = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type ToastMessageDescriptor = {
|
||||
title: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
};
|
||||
|
||||
export const RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE = {
|
||||
title: msg`Too many recipients`,
|
||||
description: msg`This document has too many recipients. Please remove some recipients or contact support if you need more.`,
|
||||
};
|
||||
|
||||
export const FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE = {
|
||||
title: msg`Fair use limit exceeded`,
|
||||
description: msg`Your organisation has reached its plan's fair use limit. Please contact your organisation administrator or support to continue.`,
|
||||
};
|
||||
|
||||
export const getDistributeErrorMessage = (code: string): ToastMessageDescriptor => {
|
||||
return match(code)
|
||||
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||
.otherwise(() => ({
|
||||
title: msg`Something went wrong`,
|
||||
description: msg`An error occurred while distributing the document.`,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescriptor => {
|
||||
return match(code)
|
||||
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||
.otherwise(() => ({
|
||||
title: msg`Something went wrong`,
|
||||
description: msg`We were unable to submit this document at this time. Please try again later.`,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
|
||||
return match(code)
|
||||
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||
.with('INVALID_DOCUMENT_FILE', () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`You cannot upload encrypted PDFs.`,
|
||||
}))
|
||||
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
}))
|
||||
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`You have reached the limit of the number of files per envelope.`,
|
||||
}))
|
||||
.with('UNSUPPORTED_FILE_TYPE', () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`This file type isn't supported. Please upload a PDF or Word document.`,
|
||||
}))
|
||||
.with('CONVERSION_SERVICE_UNAVAILABLE', () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
|
||||
}))
|
||||
.with('CONVERSION_FAILED', () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
title: msg`Error`,
|
||||
description: msg`An error occurred while uploading your document.`,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor => {
|
||||
return match(code)
|
||||
.with('DOCUMENT_SEND_FAILED', () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`The document was created but could not be sent to recipients.`,
|
||||
}))
|
||||
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
|
||||
}))
|
||||
.with(AppErrorCode.NOT_FOUND, () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`The template or one of its recipients could not be found.`,
|
||||
}))
|
||||
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`You have reached your document limit for this plan. Please upgrade your plan.`,
|
||||
}))
|
||||
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||
.otherwise(() => ({
|
||||
title: msg`Error`,
|
||||
description: msg`An error occurred while creating document from template.`,
|
||||
}));
|
||||
};
|
||||
Generated
+12
@@ -22308,6 +22308,15 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mailchecker": {
|
||||
"version": "6.0.20",
|
||||
"resolved": "https://registry.npmjs.org/mailchecker/-/mailchecker-6.0.20.tgz",
|
||||
"integrity": "sha512-mZ3kmtfXzGj06prtNm6d8an7D++Kf1G4jEkPZ1QQyhknYNLkmGoMtfaNPNHJU6E8J+Bm3AcZlIIfq5D6L4MS2g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/map-stream": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
|
||||
@@ -30932,6 +30941,7 @@
|
||||
"@vvo/tzdb": "^6.196.0",
|
||||
"ai": "^5.0.104",
|
||||
"bullmq": "^5.71.1",
|
||||
"colord": "^2.9.3",
|
||||
"csv-parse": "^6.1.0",
|
||||
"inngest": "^3.54.0",
|
||||
"ioredis": "^5.10.1",
|
||||
@@ -30939,6 +30949,7 @@
|
||||
"konva": "^10.0.9",
|
||||
"kysely": "0.29.2",
|
||||
"luxon": "^3.7.2",
|
||||
"mailchecker": "^6.0.20",
|
||||
"nanoid": "^5.1.6",
|
||||
"oslo": "^0.17.0",
|
||||
"p-map": "^7.0.4",
|
||||
@@ -31113,6 +31124,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^1.2.1",
|
||||
"cmdk": "^0.2.1",
|
||||
"colord": "^2.9.3",
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.554.0",
|
||||
"luxon": "^3.7.2",
|
||||
|
||||
@@ -95,13 +95,22 @@ export const authenticatedMiddleware = <
|
||||
{ metadata, logger: apiLogger },
|
||||
);
|
||||
} catch (err) {
|
||||
console.log({ err });
|
||||
|
||||
apiLogger.info(infoToLog);
|
||||
apiLogger.info({
|
||||
...infoToLog,
|
||||
error: err,
|
||||
});
|
||||
|
||||
let message = 'Unauthorized';
|
||||
|
||||
if (err instanceof AppError) {
|
||||
if (err.code === AppErrorCode.TOO_MANY_REQUESTS) {
|
||||
return {
|
||||
status: 429,
|
||||
body: { message: err.message },
|
||||
headers: err.headers,
|
||||
} as const;
|
||||
}
|
||||
|
||||
message = err.message;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { encryptEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
|
||||
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Locator, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Transports seeded by the current test, deleted afterwards. Deleting a transport
|
||||
// referenced by a claim is safe: the FK is `onDelete: SetNull`.
|
||||
const transportIdsToCleanup: string[] = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (transportIdsToCleanup.length > 0) {
|
||||
await prisma.emailTransport.deleteMany({ where: { id: { in: transportIdsToCleanup } } });
|
||||
transportIdsToCleanup.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const seedTransport = async (label: string) => {
|
||||
const transport = await prisma.emailTransport.create({
|
||||
data: {
|
||||
id: generateDatabaseId('email_transport'),
|
||||
name: `e2e-transport-${label}-${nanoid()}`,
|
||||
type: 'RESEND',
|
||||
fromName: 'Seeded Transport',
|
||||
fromAddress: 'seeded@example.com',
|
||||
config: encryptEmailTransportConfig({ type: 'RESEND', apiKey: `re_${nanoid()}` }),
|
||||
},
|
||||
});
|
||||
|
||||
transportIdsToCleanup.push(transport.id);
|
||||
|
||||
return transport;
|
||||
};
|
||||
|
||||
const seedSubscriptionClaim = (name: string) =>
|
||||
prisma.subscriptionClaim.create({
|
||||
data: {
|
||||
name,
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
envelopeItemCount: 10,
|
||||
recipientCount: 10,
|
||||
flags: {},
|
||||
documentRateLimits: [],
|
||||
emailRateLimits: [],
|
||||
apiRateLimits: [],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Seeds an organisation whose `OrganisationClaim` is descended (via
|
||||
* `originalSubscriptionClaimId`) from the supplied subscription claim. This is
|
||||
* the relationship the backport `updateMany` keys on.
|
||||
*/
|
||||
const seedOrgForClaim = async (subscriptionClaimId: string) => {
|
||||
const { organisation } = await seedUser();
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: {
|
||||
originalSubscriptionClaimId: subscriptionClaimId,
|
||||
emailTransportId: null,
|
||||
},
|
||||
});
|
||||
|
||||
return organisation;
|
||||
};
|
||||
|
||||
const openClaimUpdateDialog = async (page: Page, claimName: string) => {
|
||||
// The update dialog lives inside the table row. Wait for the debounced search
|
||||
// refetch to land BEFORE opening it, otherwise the table re-renders mid-flow
|
||||
// and unmounts the dialog.
|
||||
const searchSettled = page
|
||||
.waitForResponse((r) => r.url().includes('claims.find') && r.url().includes(claimName), { timeout: 15_000 })
|
||||
.catch(() => undefined);
|
||||
|
||||
await page.getByPlaceholder('Search by claim ID or name').fill(claimName);
|
||||
await searchSettled;
|
||||
|
||||
const row = page.getByRole('row', { name: claimName });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// The actions dropdown trigger is the last button in the row (the first is the
|
||||
// ID copy button).
|
||||
await row.getByRole('button').last().click();
|
||||
await page.getByRole('menuitem', { name: 'Update' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('heading', { name: 'Update Subscription Claim' })).toBeVisible();
|
||||
|
||||
return dialog;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks an option from an open Radix Select listbox. The email-transport list is
|
||||
* populated by a `find` query that can keep re-rendering (it loads up to 100
|
||||
* transports), so the target option's box may still be shifting — wait for it,
|
||||
* best-effort scroll it into view, then force the click.
|
||||
*/
|
||||
const chooseOption = async (page: Page, name: string) => {
|
||||
const option = page.getByRole('option', { name });
|
||||
await option.waitFor({ state: 'visible' });
|
||||
await option.scrollIntoViewIfNeeded().catch(() => undefined);
|
||||
await option.click({ force: true });
|
||||
};
|
||||
|
||||
const selectEmailTransport = async (page: Page, dialog: Locator, transportName: string) => {
|
||||
await dialog.getByRole('combobox').filter({ hasText: 'Default (system mailer)' }).click();
|
||||
await chooseOption(page, transportName);
|
||||
};
|
||||
|
||||
// ─── Subscription claim: NO backport ─────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating a subscription claim WITHOUT backport does not touch organisation claims', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('no-backport');
|
||||
const claimName = `e2e-claim-no-backport-${nanoid()}`;
|
||||
const claim = await seedSubscriptionClaim(claimName);
|
||||
const organisation = await seedOrgForClaim(claim.id);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/claims' });
|
||||
|
||||
const dialog = await openClaimUpdateDialog(page, claimName);
|
||||
|
||||
await selectEmailTransport(page, dialog, transport.name);
|
||||
|
||||
// Backport checkbox left UNCHECKED.
|
||||
await expect(dialog.getByRole('checkbox', { name: 'Backport email transport' })).not.toBeChecked();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Update Claim' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// The subscription claim itself was updated (proves the mutation ran).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updated = await prisma.subscriptionClaim.findUniqueOrThrow({ where: { id: claim.id } });
|
||||
return updated.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
|
||||
// The organisation claim was NOT backported.
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
expect(orgClaim.emailTransportId).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Subscription claim: WITH backport ───────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating a subscription claim WITH backport propagates to organisation claims', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('backport');
|
||||
const claimName = `e2e-claim-backport-${nanoid()}`;
|
||||
const claim = await seedSubscriptionClaim(claimName);
|
||||
const organisation = await seedOrgForClaim(claim.id);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/claims' });
|
||||
|
||||
const dialog = await openClaimUpdateDialog(page, claimName);
|
||||
|
||||
await selectEmailTransport(page, dialog, transport.name);
|
||||
|
||||
// Enable backporting.
|
||||
const backportCheckbox = dialog.getByRole('checkbox', { name: 'Backport email transport' });
|
||||
await backportCheckbox.click();
|
||||
await expect(backportCheckbox).toBeChecked();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Update Claim' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// Both the subscription claim AND the descendant organisation claim are updated.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updated = await prisma.subscriptionClaim.findUniqueOrThrow({ where: { id: claim.id } });
|
||||
return updated.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
return orgClaim.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
});
|
||||
|
||||
// ─── Organisation claim transport (set directly on the org page) ─────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: setting the email transport on an organisation claim persists', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('org-claim');
|
||||
const { organisation } = await seedUser();
|
||||
|
||||
// Ensure a known starting point.
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: { emailTransportId: null },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: `/admin/organisations/${organisation.id}` });
|
||||
|
||||
// Scope to the billing/claims form (the one containing the "Email transport" field);
|
||||
// the page has a second form (name/url) with its own "Update" button.
|
||||
const billingForm = page.locator('form', { has: page.getByText('Email transport', { exact: true }) });
|
||||
|
||||
await billingForm.getByRole('combobox').filter({ hasText: 'Default (system mailer)' }).click();
|
||||
await chooseOption(page, transport.name);
|
||||
|
||||
await billingForm.getByRole('button', { name: 'Update', exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
return orgClaim.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
});
|
||||
|
||||
// ─── Organisation claim transport can be reset to the system mailer ──────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: clearing an organisation claim transport resets it to the system mailer', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('org-clear');
|
||||
const { organisation } = await seedUser();
|
||||
|
||||
// Start with the transport already assigned.
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: { emailTransportId: transport.id },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: `/admin/organisations/${organisation.id}` });
|
||||
|
||||
const billingForm = page.locator('form', { has: page.getByText('Email transport', { exact: true }) });
|
||||
|
||||
// The select currently shows the transport name; switch back to the default.
|
||||
await billingForm.getByRole('combobox').filter({ hasText: transport.name }).click();
|
||||
await chooseOption(page, 'Default (system mailer)');
|
||||
|
||||
await billingForm.getByRole('button', { name: 'Update', exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
return orgClaim.emailTransportId;
|
||||
})
|
||||
.toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { decryptEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Locator, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Transport names created by the current test, deleted afterwards so the global
|
||||
// email-transports table doesn't accumulate rows across runs.
|
||||
const transportNamesToCleanup: string[] = [];
|
||||
|
||||
const trackTransport = (name: string) => {
|
||||
transportNamesToCleanup.push(name);
|
||||
return name;
|
||||
};
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (transportNamesToCleanup.length > 0) {
|
||||
await prisma.emailTransport.deleteMany({ where: { name: { in: transportNamesToCleanup } } });
|
||||
transportNamesToCleanup.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const getTransportFromDbOrThrow = async (name: string) => {
|
||||
await expect
|
||||
.poll(async () => prisma.emailTransport.findFirst({ where: { name }, select: { id: true } }), {
|
||||
message: `transport "${name}" was not persisted in time`,
|
||||
timeout: 10_000,
|
||||
intervals: [200, 400, 800],
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
return prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
};
|
||||
|
||||
const openCreateDialog = async (page: Page) => {
|
||||
await page.getByRole('button', { name: 'Add transport' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('heading', { name: 'Add Email Transport' })).toBeVisible();
|
||||
|
||||
return dialog;
|
||||
};
|
||||
|
||||
const selectTransportType = async (page: Page, dialog: Locator, optionName: string) => {
|
||||
// The transport-type Select is the only combobox inside the create/edit dialog.
|
||||
await dialog.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: optionName, exact: true }).click();
|
||||
};
|
||||
|
||||
const searchForTransport = async (page: Page, name: string) => {
|
||||
// The row-level Edit/Delete dialogs live inside the table row. Wait for the
|
||||
// debounced search refetch to land before interacting, otherwise a late
|
||||
// re-render can unmount a freshly-opened dialog.
|
||||
const searchSettled = page
|
||||
.waitForResponse((r) => r.url().includes('emailTransport.find') && r.url().includes(name), { timeout: 15_000 })
|
||||
.catch(() => undefined);
|
||||
|
||||
await page.getByPlaceholder('Search by name or from address').fill(name);
|
||||
await searchSettled;
|
||||
|
||||
await expect(page.getByRole('row', { name })).toBeVisible();
|
||||
};
|
||||
|
||||
const openRowAction = async (page: Page, name: string, action: 'Edit' | 'Send test' | 'Delete') => {
|
||||
await searchForTransport(page, name);
|
||||
// The transports table row has exactly one button: the actions dropdown trigger.
|
||||
await page.getByRole('row', { name }).getByRole('button').click();
|
||||
await page.getByRole('menuitem', { name: action }).click();
|
||||
};
|
||||
|
||||
// ─── Create: RESEND (round-trips the secret through encrypt/decrypt) ─────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: create a RESEND transport encrypts the secret and round-trips correctly', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-resend-${nanoid()}`);
|
||||
const apiKey = `re_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const dialog = await openCreateDialog(page);
|
||||
|
||||
await dialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await dialog.getByLabel('From name', { exact: true }).fill('Acme Mailer');
|
||||
await dialog.getByLabel('From address', { exact: true }).fill('sender@example.com');
|
||||
await selectTransportType(page, dialog, 'Resend');
|
||||
await dialog.getByLabel('API key', { exact: true }).fill(apiKey);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
const row = await getTransportFromDbOrThrow(name);
|
||||
|
||||
// The stored blob must NOT contain the plaintext secret.
|
||||
expect(row.config).not.toContain(apiKey);
|
||||
expect(row.type).toBe('RESEND');
|
||||
expect(row.fromName).toBe('Acme Mailer');
|
||||
expect(row.fromAddress).toBe('sender@example.com');
|
||||
|
||||
// Decrypting yields the original config (proves encrypt → store → decrypt works).
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
expect(config).toEqual({ type: 'RESEND', apiKey });
|
||||
});
|
||||
|
||||
// ─── Create: SMTP_AUTH (non-secret + secret fields) ─────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: create an SMTP_AUTH transport stores host/port/username and encrypts the password', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-smtp-${nanoid()}`);
|
||||
const password = `pw_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const dialog = await openCreateDialog(page);
|
||||
|
||||
await dialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await dialog.getByLabel('From name', { exact: true }).fill('SMTP Sender');
|
||||
await dialog.getByLabel('From address', { exact: true }).fill('smtp-sender@example.com');
|
||||
// Default type is SMTP_AUTH, so the host/port/username/password fields are already shown.
|
||||
await dialog.getByLabel('Host', { exact: true }).fill('smtp.example.com');
|
||||
await dialog.getByLabel('Port', { exact: true }).fill('587');
|
||||
await dialog.getByLabel('Username', { exact: true }).fill('smtp-user');
|
||||
await dialog.getByLabel('Password', { exact: true }).fill(password);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
const row = await getTransportFromDbOrThrow(name);
|
||||
|
||||
expect(row.config).not.toContain(password);
|
||||
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
expect(config).toEqual({
|
||||
type: 'SMTP_AUTH',
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
ignoreTLS: false,
|
||||
username: 'smtp-user',
|
||||
password,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Update without a secret preserves the existing secret ───────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating without a secret keeps the existing secret intact', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-keep-${nanoid()}`);
|
||||
const originalApiKey = `re_keep_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
// Create the transport with a secret.
|
||||
const createDialog = await openCreateDialog(page);
|
||||
await createDialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await createDialog.getByLabel('From name', { exact: true }).fill('Keep Original');
|
||||
await createDialog.getByLabel('From address', { exact: true }).fill('keep@example.com');
|
||||
await selectTransportType(page, createDialog, 'Resend');
|
||||
await createDialog.getByLabel('API key', { exact: true }).fill(originalApiKey);
|
||||
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(createDialog).not.toBeVisible();
|
||||
|
||||
await getTransportFromDbOrThrow(name);
|
||||
|
||||
// Edit: change a non-secret field, leave the API key blank.
|
||||
await openRowAction(page, name, 'Edit');
|
||||
|
||||
const editDialog = page.getByRole('dialog');
|
||||
await expect(editDialog.getByRole('heading', { name: 'Edit Email Transport' })).toBeVisible();
|
||||
|
||||
// The secret field stays blank (we never re-enter it).
|
||||
await expect(editDialog.getByLabel('API key', { exact: true })).toHaveValue('');
|
||||
await editDialog.getByLabel('From name', { exact: true }).fill('Renamed Sender');
|
||||
await editDialog.getByRole('button', { name: 'Save changes' }).click();
|
||||
await expect(editDialog).not.toBeVisible();
|
||||
|
||||
// The update ran (fromName changed) but the original secret is preserved.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
return row.fromName;
|
||||
})
|
||||
.toBe('Renamed Sender');
|
||||
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
expect(config).toEqual({ type: 'RESEND', apiKey: originalApiKey });
|
||||
});
|
||||
|
||||
// ─── Update with a new secret correctly replaces it ──────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating with a new secret replaces the stored secret', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-replace-${nanoid()}`);
|
||||
const originalApiKey = `re_old_${nanoid()}`;
|
||||
const newApiKey = `re_new_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const createDialog = await openCreateDialog(page);
|
||||
await createDialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await createDialog.getByLabel('From name', { exact: true }).fill('Replace Secret');
|
||||
await createDialog.getByLabel('From address', { exact: true }).fill('replace@example.com');
|
||||
await selectTransportType(page, createDialog, 'Resend');
|
||||
await createDialog.getByLabel('API key', { exact: true }).fill(originalApiKey);
|
||||
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(createDialog).not.toBeVisible();
|
||||
|
||||
await getTransportFromDbOrThrow(name);
|
||||
|
||||
await openRowAction(page, name, 'Edit');
|
||||
|
||||
const editDialog = page.getByRole('dialog');
|
||||
await editDialog.getByLabel('API key', { exact: true }).fill(newApiKey);
|
||||
await editDialog.getByRole('button', { name: 'Save changes' }).click();
|
||||
await expect(editDialog).not.toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
return config.type === 'RESEND' ? config.apiKey : null;
|
||||
})
|
||||
.toBe(newApiKey);
|
||||
|
||||
// And it definitely no longer decrypts to the old secret.
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
expect(row.config).not.toContain(originalApiKey);
|
||||
});
|
||||
|
||||
// ─── Delete ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: delete removes the transport', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-delete-${nanoid()}`);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const createDialog = await openCreateDialog(page);
|
||||
await createDialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await createDialog.getByLabel('From name', { exact: true }).fill('To Delete');
|
||||
await createDialog.getByLabel('From address', { exact: true }).fill('delete@example.com');
|
||||
await selectTransportType(page, createDialog, 'Resend');
|
||||
await createDialog.getByLabel('API key', { exact: true }).fill(`re_${nanoid()}`);
|
||||
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(createDialog).not.toBeVisible();
|
||||
|
||||
const row = await getTransportFromDbOrThrow(name);
|
||||
|
||||
await openRowAction(page, name, 'Delete');
|
||||
|
||||
const deleteDialog = page.getByRole('dialog');
|
||||
await expect(deleteDialog.getByRole('heading', { name: 'Delete Email Transport' })).toBeVisible();
|
||||
await deleteDialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(deleteDialog).not.toBeVisible();
|
||||
|
||||
await expect.poll(async () => prisma.emailTransport.findUnique({ where: { id: row.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Access control ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: a non-admin cannot access the email transports page', async ({ page }) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
|
||||
await apiSignin({ page, email: nonAdminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Add transport' })).not.toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,715 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||
import type { Organisation, Team, User } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Dynamic organisation rate-limit & quota tests — API **v1** edition.
|
||||
*
|
||||
* This is the v1 counterpart to `../v2/organisation-rate-limits.spec.ts`. It
|
||||
* covers the feature added in `feat: add dynamic rate limits`:
|
||||
* - Three counters: `api`, `document`, `email`.
|
||||
* - Two enforcement stages per counter:
|
||||
* 1. Windowed rate limits (`*RateLimits`) — a 429 distinguished by its
|
||||
* message. NOTE: in v2 this 429 also carries `X-RateLimit-*` headers, but
|
||||
* v1 does NOT surface them (the ts-rest handler drops the headers the
|
||||
* middleware returns — see the windowed test), so v1 tells the windowed
|
||||
* stage apart from the quota stage by the MESSAGE alone.
|
||||
* 2. Monthly quota (`*Quota`) — 429 WITHOUT rate-limit headers; a `null`
|
||||
* quota means unlimited and a `0` quota is a hard block.
|
||||
*
|
||||
* --- WHAT THIS V1 SUITE COVERS (and what it intentionally does NOT) ---
|
||||
* api -> every authenticated v1 request (get-api-token-by-token). Ported
|
||||
* 1:1 from the v2 suite against `GET /api/v1/documents`.
|
||||
* email -> resend (`POST /api/v1/documents/:id/resend`) consumes
|
||||
* `recipientsToRemind.length` SYNCHRONOUSLY (resend-document), so we
|
||||
* can assert on the HTTP response rather than racing async jobs.
|
||||
* IMPORTANT V1 DIVERGENCE: the v1 `resendDocument` handler catches
|
||||
* EVERY error and returns a generic HTTP 500
|
||||
* (`{ message: 'An error has occured while resending the document' }`)
|
||||
* — it does NOT surface the org limiter's 429 / `X-RateLimit-*`
|
||||
* headers like the v2 `redistribute` endpoint does. These tests
|
||||
* therefore assert the v1 reality: a blocked resend returns 500 and
|
||||
* the monthly counter advances exactly as documented.
|
||||
* document -> INTENTIONALLY OMITTED. v1's `POST /api/v1/documents` create path
|
||||
* requires S3 upload transport (createEnvelope), which the local E2E
|
||||
* environment generally does not provide, so it cannot be exercised
|
||||
* deterministically here. Document-counter enforcement is covered by
|
||||
* the v2 suite (envelope/create).
|
||||
*
|
||||
* --- WHY THIS TEST IS SKIPPED IN CI ---
|
||||
* CI runs E2E with `DANGEROUS_BYPASS_RATE_LIMITS=true`, which short-circuits BOTH
|
||||
* the per-org assertion and the global IP limiter, making every assertion here
|
||||
* meaningless. The test therefore skips itself in that mode and is intended to be
|
||||
* run deliberately and locally with the bypass OFF.
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
|
||||
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
* 429 is shaped `{ message }` — `expectOrgLimited()` asserts the 429 status AND
|
||||
* that we hit the org limiter rather than the global one.
|
||||
*/
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v1 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
// bypass flag, so skip there; run it locally with the bypass turned off.
|
||||
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Test skipped because bypass rate limits is enabled.');
|
||||
|
||||
const WINDOWED_LIMIT_MESSAGE = /contact support if you require higher limits/i;
|
||||
const NO_QUOTA_MESSAGE = /request could not be completed at this time/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claim / usage control (direct Prisma) — mirrors recipient-count-limit.spec.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RateLimitEntry = { window: `${number}${'s' | 'm' | 'h' | 'd'}`; max: number };
|
||||
|
||||
type ClaimLimits = {
|
||||
apiRateLimits?: RateLimitEntry[];
|
||||
apiQuota?: number | null;
|
||||
documentRateLimits?: RateLimitEntry[];
|
||||
documentQuota?: number | null;
|
||||
emailRateLimits?: RateLimitEntry[];
|
||||
emailQuota?: number | null;
|
||||
};
|
||||
|
||||
const currentMonthlyPeriod = (): string => {
|
||||
const now = new Date();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
|
||||
return `${now.getUTCFullYear()}-${month}`;
|
||||
};
|
||||
|
||||
const getOrganisationClaim = async (team: Team) =>
|
||||
prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { organisation: { id: team.organisationId } },
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply a clean set of limits to the org's claim. Any counter not provided is
|
||||
* reset to "unlimited" (empty windows + null quota) so scenarios never leak into
|
||||
* each other.
|
||||
*/
|
||||
const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
||||
const claim = await getOrganisationClaim(team);
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: claim.id },
|
||||
data: {
|
||||
apiRateLimits: limits.apiRateLimits ?? [],
|
||||
apiQuota: limits.apiQuota === undefined ? null : limits.apiQuota,
|
||||
documentRateLimits: limits.documentRateLimits ?? [],
|
||||
documentQuota: limits.documentQuota === undefined ? null : limits.documentQuota,
|
||||
emailRateLimits: limits.emailRateLimits ?? [],
|
||||
emailQuota: limits.emailQuota === undefined ? null : limits.emailQuota,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the monthly quota counters, the org windowed rate-limit buckets AND the
|
||||
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
|
||||
*
|
||||
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
|
||||
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
|
||||
* Across the suite (and especially across repeated local runs within the same
|
||||
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||
* limit under test, producing a `{ error }` 429 instead of the org `{ message }`
|
||||
* one. Since this suite runs deliberately in isolation (it skips in CI), we
|
||||
* clear that bucket here so the global limiter never masks the org assertion.
|
||||
*/
|
||||
const resetUsage = async (organisation: Organisation) => {
|
||||
const period = currentMonthlyPeriod();
|
||||
|
||||
await prisma.organisationMonthlyStat.updateMany({
|
||||
where: { organisationId: organisation.id, period },
|
||||
data: {
|
||||
documentCount: 0,
|
||||
emailCount: 0,
|
||||
apiCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.rateLimit.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: `ip:org:${organisation.id}` }, { action: 'api.v1' }],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type MonthlyCounter = 'documentCount' | 'emailCount' | 'apiCount';
|
||||
|
||||
const getMonthlyStat = async (organisation: Organisation) =>
|
||||
prisma.organisationMonthlyStat.findUnique({
|
||||
where: {
|
||||
organisationId_period: { organisationId: organisation.id, period: currentMonthlyPeriod() },
|
||||
},
|
||||
select: { documentCount: true, emailCount: true, apiCount: true },
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert the live OrganisationMonthlyStat counter equals `expected`.
|
||||
*
|
||||
* The DB counter is the source of truth for quota enforcement, so checking its
|
||||
* exact value (not just the HTTP response) proves the documented increment
|
||||
* semantics in check-monthly-quota.ts:
|
||||
* - quota === null -> unlimited: never blocks, but the request is STILL
|
||||
* counted (the upsert now runs before the null return)
|
||||
* - quota === 0 -> throws BEFORE increment (stays 0)
|
||||
* - quota > 0 -> incremented by `count` BEFORE the over-quota check, so
|
||||
* even the request that gets rejected still advances it
|
||||
* - windowed limit -> trips BEFORE the quota stage, so the counter is untouched
|
||||
*/
|
||||
const expectMonthlyCounter = async (organisation: Organisation, counter: MonthlyCounter, expected: number) => {
|
||||
const stat = await getMonthlyStat(organisation);
|
||||
|
||||
expect(stat?.[counter] ?? 0, `${counter} should be exactly ${expected}`).toBe(expected);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sleep until just after the next windowed-limit bucket boundary.
|
||||
*
|
||||
* The limiter (createRateLimit -> getBucket) buckets time as
|
||||
* `now - (now % windowMs)` aligned to the epoch. A windowed exhaustion test must
|
||||
* land all of its MAX+1 requests inside ONE bucket; if the requests straddle a
|
||||
* boundary the counter resets mid-test and the expected 429 never happens. We
|
||||
* share the server's clock (same host), so aligning to a fresh bucket here makes
|
||||
* the exhaustion deterministic.
|
||||
*/
|
||||
const alignToFreshWindowBucket = async (windowSeconds: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msUntilNextBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, msUntilNextBucket + 100));
|
||||
};
|
||||
|
||||
/**
|
||||
* Guarantee at least `requiredHeadroomMs` remain in the current bucket so a burst
|
||||
* of MAX+1 requests completes inside ONE window. Without this, a burst that
|
||||
* happens to cross a bucket boundary would have its count reset mid-test and the
|
||||
* expected 429 would never fire. Unlike `alignToFreshWindowBucket`, this only
|
||||
* sleeps when we are actually near a boundary, so for long (e.g. 1m) windows it
|
||||
* is almost always a no-op.
|
||||
*/
|
||||
const ensureWindowHeadroom = async (windowSeconds: number, requiredHeadroomMs: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msLeftInBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
if (msLeftInBucket < requiredHeadroomMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, msLeftInBucket + 100));
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ApiErrorBody = { message?: string; error?: string };
|
||||
|
||||
/**
|
||||
* Non-throwing predicate: true when the response is an ORG-level 429
|
||||
* (`{ message }`), not the global IP 429 (`{ error }`). Used by the preflight,
|
||||
* which needs a boolean to decide whether to skip rather than fail.
|
||||
*/
|
||||
const isOrgLimited = async (res: APIResponse): Promise<boolean> => {
|
||||
if (res.status() !== 429) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = (await res.json().catch(() => ({}))) as ApiErrorBody;
|
||||
|
||||
// Global limiter returns `{ error }`; org limiter returns `{ message }`.
|
||||
return body.message !== undefined && body.error === undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the response is an ORG-level 429 and return its parsed body.
|
||||
*
|
||||
* Checks the status code EXPLICITLY so a wrong 200/4xx fails with a clear
|
||||
* "Expected 429, got <status>: <body>" message instead of an opaque
|
||||
* `expected true, received false`. Also asserts the body is the org limiter's
|
||||
* `{ message }` shape and not the global limiter's `{ error }` shape, so a
|
||||
* global-IP 429 can never be mistaken for the org limit under test.
|
||||
*/
|
||||
const expectOrgLimited = async (res: APIResponse): Promise<ApiErrorBody> => {
|
||||
const bodyText = await res.text();
|
||||
|
||||
expect(res.status(), `Expected an org 429 but got ${res.status()} with body: ${bodyText}`).toBe(429);
|
||||
|
||||
let body: ApiErrorBody = {};
|
||||
|
||||
try {
|
||||
body = JSON.parse(bodyText) as ApiErrorBody;
|
||||
} catch {
|
||||
throw new Error(`Expected a JSON error body, got: ${bodyText}`);
|
||||
}
|
||||
|
||||
expect(
|
||||
body.message !== undefined && body.error === undefined,
|
||||
`429 should be the ORG limiter ({ message }), not the global limiter ({ error }). Got: ${bodyText}`,
|
||||
).toBeTruthy();
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert NO org rate-limit header was surfaced — the GLOBAL /api/v1 middleware
|
||||
* still stamps a single `X-RateLimit-Limit: 100`, so "no org header" means the
|
||||
* value is either absent or exactly the lone global `100` (i.e. it does not
|
||||
* contain a second, org-specific entry).
|
||||
*
|
||||
* In v1 this holds for BOTH stages: quota rejections intentionally omit
|
||||
* rate-limit headers, AND windowed rejections lose theirs because the ts-rest
|
||||
* handler ignores the `headers` the middleware returns (see the windowed test).
|
||||
*/
|
||||
const expectNoOrgRateLimitHeader = (res: APIResponse) => {
|
||||
const header = res.headers()['x-ratelimit-limit'];
|
||||
|
||||
if (header === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = header.split(',').map((v) => v.trim());
|
||||
|
||||
expect(values, `Quota rejection should not add an org X-RateLimit-Limit, got "${header}"`).toEqual(['100']);
|
||||
};
|
||||
|
||||
/** Guard against the global limiter silently masking an org assertion. */
|
||||
const expectNotGlobalLimited = async (res: APIResponse) => {
|
||||
if (res.status() === 429) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
expect(
|
||||
'error' in body && !('message' in body),
|
||||
'Hit the GLOBAL /api/v1 IP limiter, not the org limiter. Re-run this suite in isolation.',
|
||||
).toBeFalsy();
|
||||
}
|
||||
};
|
||||
|
||||
/** Cheap read endpoint — consumes exactly one `api` counter, no document/email. */
|
||||
const findDocuments = (request: APIRequestContext, token: string): Promise<APIResponse> =>
|
||||
request.get(`${baseUrl}/documents?page=1&perPage=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
/**
|
||||
* Resend (remind) the given recipients. This runs the SYNCHRONOUS email assertion
|
||||
* in resend-document with `count = recipients.length`.
|
||||
*
|
||||
* NOTE: unlike the v2 `redistribute` endpoint, the v1 `resendDocument` handler
|
||||
* wraps everything in a try/catch and returns a generic HTTP 500 on ANY error
|
||||
* (including the org limiter's TOO_MANY_REQUESTS AppError). So when the email
|
||||
* limit/quota is exceeded this resolves to a 500, NOT a 429.
|
||||
*/
|
||||
const resendDocument = (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
documentId: number,
|
||||
recipientIds: number[],
|
||||
): Promise<APIResponse> =>
|
||||
request.post(`${baseUrl}/documents/${documentId}/resend`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { recipients: recipientIds },
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert a resend was blocked by the org email limiter.
|
||||
*
|
||||
* v1's handler masks the limiter's 429 as a generic HTTP 500 (see `resendDocument`
|
||||
* above), so the only signal available on the HTTP layer is the 500 status. The
|
||||
* accompanying `expectMonthlyCounter` assertions in each test prove WHICH stage
|
||||
* blocked it (windowed leaves the counter untouched; quota advances it).
|
||||
*/
|
||||
const expectResendBlocked = async (res: APIResponse) => {
|
||||
const bodyText = await res.text();
|
||||
|
||||
expect(
|
||||
res.status(),
|
||||
`Expected the v1 resend to be blocked (masked as HTTP 500) but got ${res.status()} with body: ${bodyText}`,
|
||||
).toBe(500);
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed a PENDING document with `recipientCount` NOT_SIGNED signer recipients (each
|
||||
* carrying a signature field) created directly via Prisma — so no async signing
|
||||
* emails are fanned out and the monthly email counter starts clean. Returns the
|
||||
* legacy document id (for the resend endpoint) and the recipient ids to remind.
|
||||
*/
|
||||
const seedRemindableDocument = async ({
|
||||
owner,
|
||||
team,
|
||||
recipientCount,
|
||||
}: {
|
||||
owner: User;
|
||||
team: Team;
|
||||
recipientCount: number;
|
||||
}): Promise<{ documentId: number; recipientIds: number[] }> => {
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner,
|
||||
teamId: team.id,
|
||||
recipients: Array.from(
|
||||
{ length: recipientCount },
|
||||
(_, i) => `rl-${Date.now()}-${i}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||
),
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
return {
|
||||
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
recipientIds: recipients.map((recipient) => recipient.id),
|
||||
};
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
test.describe('Organisation dynamic rate limits & quotas (API v1)', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let organisation: Organisation;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
const seeded = await seedUser();
|
||||
user = seeded.user;
|
||||
team = seeded.team;
|
||||
organisation = seeded.organisation;
|
||||
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-org-rate-limits',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
// Preflight: the `test.skip` above only sees the PLAYWRIGHT process env. The
|
||||
// value that actually matters is the env the SERVER was started with — if the
|
||||
// server has `DANGEROUS_BYPASS_RATE_LIMITS=true`, every assertion here would
|
||||
// fail confusingly instead of skipping. Prove enforcement is live by setting a
|
||||
// quota of 0 (instant hard block) and confirming the server rejects. If it
|
||||
// doesn't, the server is bypassing limits, so skip with a clear message.
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const preflight = await findDocuments(request, token);
|
||||
const enforced = await isOrgLimited(preflight);
|
||||
|
||||
// Reset back to a clean slate before the real scenario runs.
|
||||
await setClaimLimits(team, {});
|
||||
await resetUsage(organisation);
|
||||
|
||||
test.skip(
|
||||
!enforced,
|
||||
'Server is not enforcing organisation rate limits (likely started with DANGEROUS_BYPASS_RATE_LIMITS=true). Restart the server with the flag unset/false to run this suite.',
|
||||
);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — windowed rate limit
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api rate limit (windowed)', () => {
|
||||
test('allows requests up to the limit then 429s with rate-limit headers', async ({ request }) => {
|
||||
const MAX = 4;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure the MAX+1 request burst lands inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
// Each request (including these GETs) consumes one api counter.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is over the windowed limit.
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
// The windowed limit uses a message distinct from the global limiter — and
|
||||
// in v1 the MESSAGE is the only signal we get (see note below), so it is how
|
||||
// we tell a windowed rejection apart from a quota one.
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
|
||||
// V1 DIVERGENCE: unlike v2, v1's ts-rest handler does not propagate the org
|
||||
// limiter's `X-RateLimit-*` headers. `authenticatedMiddleware` returns them
|
||||
// on the body object (`headers: err.headers`), which `@ts-rest/serverless`
|
||||
// ignores (custom headers must be written to the `responseHeaders` Headers
|
||||
// object). So only the global middleware's lone `X-RateLimit-Limit: 100`
|
||||
// survives — the org `max` and `Retry-After`/`Remaining` never reach the
|
||||
// client. We therefore assert no org-specific header is surfaced.
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// The windowed stage blocks the (MAX+1)th request before the quota upsert,
|
||||
// but each of the MAX allowed requests still records usage (null quota now
|
||||
// tracks instead of skipping), so the counter equals MAX.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', MAX);
|
||||
});
|
||||
|
||||
test('a single allowed request succeeds when the limit is 1', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: 1 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure both requests land inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
const okRes = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
|
||||
// The one allowed request is counted (null quota still tracks); the blocked
|
||||
// request trips the window before the quota upsert, so the counter is 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 1);
|
||||
});
|
||||
|
||||
test('the windowed limit RESETS once the window elapses (429 -> wait -> 200)', async ({ request }) => {
|
||||
const MAX = 2;
|
||||
const WINDOW_SECONDS = 3;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: `${WINDOW_SECONDS}s`, max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Land at the start of a fresh bucket so all MAX+1 requests below fall in
|
||||
// the SAME window (otherwise a mid-exhaustion boundary would reset the count).
|
||||
await alignToFreshWindowBucket(WINDOW_SECONDS);
|
||||
|
||||
// Exhaust the window.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is blocked by the window.
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// Wait out the window using the server-provided Retry-After (plus a small
|
||||
// buffer to be sure we've crossed into the next time bucket). Crucially we
|
||||
// do NOT reset usage here — the limiter must recover on its own as the
|
||||
// bucket rolls over.
|
||||
const retryAfterHeader = limitedRes.headers()['retry-after'] ?? String(WINDOW_SECONDS);
|
||||
const retryAfterSeconds = Number.parseInt(retryAfterHeader.split(',')[0]?.trim() ?? '', 10) || WINDOW_SECONDS;
|
||||
await new Promise((resolve) => setTimeout(resolve, (retryAfterSeconds + 1) * 1000));
|
||||
|
||||
// Window has elapsed: the same org can make requests again without any
|
||||
// manual intervention — the bucket rolled over on its own.
|
||||
const afterReset = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(afterReset);
|
||||
expect(afterReset.status(), 'request after the window elapsed should be allowed').toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — monthly quota
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api quota (monthly)', () => {
|
||||
test('null quota allows unlimited requests', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: null });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// A null quota means "unlimited" (never blocks), but every request is now
|
||||
// recorded so usage is visible on unlimited plans — so the counter is 6.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 6);
|
||||
});
|
||||
|
||||
test('exhausting the quota 429s without rate-limit headers and keeps counting', async ({ request }) => {
|
||||
const QUOTA = 3;
|
||||
await setClaimLimits(team, { apiQuota: QUOTA });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be within quota`).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// Quota rejections deliberately omit rate-limit headers (it isn't a window).
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// The atomic increment runs even on the rejected request: QUOTA allowed
|
||||
// requests + the one rejected request = exactly QUOTA + 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
|
||||
test('quota of exactly 1 allows one request then blocks', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 1 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const okRes = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// One allowed + one rejected, both incremented => exactly 2.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 2);
|
||||
});
|
||||
|
||||
test('quota of 0 is a hard block with a "no quota available" message', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — windowed rate limit (via synchronous resend)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email rate limit (windowed)', () => {
|
||||
test('resend is allowed when recipient count is within the email window', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 2 });
|
||||
|
||||
// Window allows 5/min; reminding 2 recipients is fine. Reset usage so the
|
||||
// seeding above doesn't count against this window.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 5 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `resend should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The windowed pass is now recorded even though the quota is null, so the
|
||||
// counter advances by the batch size (recipientIds.length).
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('resend is blocked when recipient count exceeds the email window', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 3 });
|
||||
|
||||
// Window only allows 2 emails per minute; reminding 3 at once exceeds it.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 2 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
// v1 masks the org 429 as a generic HTTP 500.
|
||||
await expectResendBlocked(res);
|
||||
|
||||
// Windowed limit trips BEFORE the quota stage, so the counter is untouched.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — monthly quota (via synchronous resend)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email quota (monthly)', () => {
|
||||
test('resend within the remaining email quota succeeds', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 2 });
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 10 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `resend should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The synchronous assertion consumed exactly `recipientIds.length` of quota.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('resend that would exceed the email quota is blocked', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 3 });
|
||||
|
||||
// Quota of 2 but reminding 3 recipients in one synchronous call.
|
||||
await setClaimLimits(team, { emailQuota: 2 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
// v1 masks the org 429 as a generic HTTP 500.
|
||||
await expectResendBlocked(res);
|
||||
|
||||
// The count (3) is added BEFORE the over-quota check throws, so the counter
|
||||
// advances by the full batch even though the request was rejected.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('email quota of 0 hard-blocks reminders', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 1 });
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
// v1 masks the org 429 as a generic HTTP 500.
|
||||
await expectResendBlocked(res);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Stage interaction — quota binds before a looser window
|
||||
// =========================================================================
|
||||
|
||||
test.describe('stage interaction', () => {
|
||||
test('the quota trips before a looser windowed limit', async ({ request }) => {
|
||||
const WINDOW_MAX = 50; // generous window
|
||||
const QUOTA = 2; // strict quota — should bind first
|
||||
await setClaimLimits(team, {
|
||||
apiRateLimits: [{ window: '1m', max: WINDOW_MAX }],
|
||||
apiQuota: QUOTA,
|
||||
});
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
|
||||
// It must be the QUOTA that bound, not the window: the message is the quota
|
||||
// one (not the windowed-limit message) and there are no rate-limit headers.
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
expect(String(body.message)).not.toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// Quota bound at QUOTA + 1; the looser window (50) was never the limiter.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,917 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||
import type { Organisation, Team, User } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Dynamic organisation rate-limit & quota tests.
|
||||
*
|
||||
* Covers the feature added in `feat: add dynamic rate limits`:
|
||||
* - Three counters: `api`, `document`, `email`.
|
||||
* - Two enforcement stages per counter:
|
||||
* 1. Windowed rate limits (`*RateLimits`) — 429 WITH `X-RateLimit-*` headers.
|
||||
* 2. Monthly quota (`*Quota`) — 429 WITHOUT rate-limit headers; a `null`
|
||||
* quota means unlimited and a `0` quota is a hard block.
|
||||
*
|
||||
* Where each counter is consumed:
|
||||
* api -> every authenticated v2 request (get-api-token-by-token).
|
||||
* document -> envelope create where type === DOCUMENT (count 1).
|
||||
* email -> redistribute/remind consumes `recipientsToRemind.length`
|
||||
* SYNCHRONOUSLY (resend-document), so we can assert on the HTTP
|
||||
* response rather than racing async signing-email jobs.
|
||||
*
|
||||
* --- WHY THIS TEST IS SKIPPED IN CI ---
|
||||
* CI runs E2E with `DANGEROUS_BYPASS_RATE_LIMITS=true`, which short-circuits BOTH
|
||||
* the per-org assertion and the global IP limiter, making every assertion here
|
||||
* meaningless. The test therefore skips itself in that mode and is intended to be
|
||||
* run deliberately and locally with the bypass OFF.
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
|
||||
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
* 429 is shaped `{ message }` — `expectOrgLimited()` asserts the 429 status AND
|
||||
* that we hit the org limiter rather than the global one.
|
||||
*/
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v2 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
// bypass flag, so skip there; run it locally with the bypass turned off.
|
||||
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Test skipped because bypass rate limits is enabled.');
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||
|
||||
const WINDOWED_LIMIT_MESSAGE = /contact support if you require higher limits/i;
|
||||
const NO_QUOTA_MESSAGE = /request could not be completed at this time/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claim / usage control (direct Prisma) — mirrors recipient-count-limit.spec.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RateLimitEntry = { window: `${number}${'s' | 'm' | 'h' | 'd'}`; max: number };
|
||||
|
||||
type ClaimLimits = {
|
||||
apiRateLimits?: RateLimitEntry[];
|
||||
apiQuota?: number | null;
|
||||
documentRateLimits?: RateLimitEntry[];
|
||||
documentQuota?: number | null;
|
||||
emailRateLimits?: RateLimitEntry[];
|
||||
emailQuota?: number | null;
|
||||
};
|
||||
|
||||
const currentMonthlyPeriod = (): string => {
|
||||
const now = new Date();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
|
||||
return `${now.getUTCFullYear()}-${month}`;
|
||||
};
|
||||
|
||||
const getOrganisationClaim = async (team: Team) =>
|
||||
prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { organisation: { id: team.organisationId } },
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply a clean set of limits to the org's claim. Any counter not provided is
|
||||
* reset to "unlimited" (empty windows + null quota) so scenarios never leak into
|
||||
* each other.
|
||||
*/
|
||||
const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
||||
const claim = await getOrganisationClaim(team);
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: claim.id },
|
||||
data: {
|
||||
apiRateLimits: limits.apiRateLimits ?? [],
|
||||
apiQuota: limits.apiQuota === undefined ? null : limits.apiQuota,
|
||||
documentRateLimits: limits.documentRateLimits ?? [],
|
||||
documentQuota: limits.documentQuota === undefined ? null : limits.documentQuota,
|
||||
emailRateLimits: limits.emailRateLimits ?? [],
|
||||
emailQuota: limits.emailQuota === undefined ? null : limits.emailQuota,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the monthly quota counters, the org windowed rate-limit buckets AND the
|
||||
* GLOBAL /api/v2 IP bucket so a fresh scenario starts from zero.
|
||||
*
|
||||
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV2RateLimit, 100/min
|
||||
* per IP, action `api.v2`) is shared by EVERY v2 request from this test client.
|
||||
* Across the suite (and especially across repeated local runs within the same
|
||||
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||
* limit under test, producing a `{ error }` 429 instead of the org `{ message }`
|
||||
* one. Since this suite runs deliberately in isolation (it skips in CI), we
|
||||
* clear that bucket here so the global limiter never masks the org assertion.
|
||||
*/
|
||||
const resetUsage = async (organisation: Organisation) => {
|
||||
const period = currentMonthlyPeriod();
|
||||
|
||||
await prisma.organisationMonthlyStat.updateMany({
|
||||
where: { organisationId: organisation.id, period },
|
||||
data: {
|
||||
documentCount: 0,
|
||||
emailCount: 0,
|
||||
apiCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.rateLimit.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: `ip:org:${organisation.id}` }, { action: 'api.v2' }],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type MonthlyCounter = 'documentCount' | 'emailCount' | 'apiCount';
|
||||
|
||||
const getMonthlyStat = async (organisation: Organisation) =>
|
||||
prisma.organisationMonthlyStat.findUnique({
|
||||
where: {
|
||||
organisationId_period: { organisationId: organisation.id, period: currentMonthlyPeriod() },
|
||||
},
|
||||
select: { documentCount: true, emailCount: true, apiCount: true },
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert the live OrganisationMonthlyStat counter equals `expected`.
|
||||
*
|
||||
* The DB counter is the source of truth for quota enforcement, so checking its
|
||||
* exact value (not just the HTTP response) proves the documented increment
|
||||
* semantics in check-monthly-quota.ts:
|
||||
* - quota === null -> unlimited: never blocks, but the request is STILL
|
||||
* counted (the upsert now runs before the null return)
|
||||
* - quota === 0 -> throws BEFORE increment (stays 0)
|
||||
* - quota > 0 -> incremented by `count` BEFORE the over-quota check, so
|
||||
* even the request that gets rejected still advances it
|
||||
* - windowed limit -> trips BEFORE the quota stage, so the counter is untouched
|
||||
*/
|
||||
const expectMonthlyCounter = async (organisation: Organisation, counter: MonthlyCounter, expected: number) => {
|
||||
const stat = await getMonthlyStat(organisation);
|
||||
|
||||
expect(stat?.[counter] ?? 0, `${counter} should be exactly ${expected}`).toBe(expected);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait until a monthly counter reaches `atLeast` and then stops changing.
|
||||
*
|
||||
* `distribute` fans out one async signing-request email job per recipient (the
|
||||
* local job runner fires them via fire-and-forget HTTP, so they complete after
|
||||
* the call returns). Each job increments emailCount. We poll until the counter
|
||||
* has reached the expected floor AND is stable across consecutive reads, which
|
||||
* guarantees no late job will increment the counter after the caller resets
|
||||
* usage — making the subsequent (synchronous) redistribute assertions exact.
|
||||
*/
|
||||
const waitForCounterToSettle = async (
|
||||
organisation: Organisation,
|
||||
counter: MonthlyCounter,
|
||||
atLeast: number,
|
||||
timeoutMs = 20_000,
|
||||
): Promise<number> => {
|
||||
const start = Date.now();
|
||||
let previous = -1;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const stat = await getMonthlyStat(organisation);
|
||||
const current = stat?.[counter] ?? 0;
|
||||
|
||||
if (current >= atLeast && current === previous) {
|
||||
return current;
|
||||
}
|
||||
|
||||
previous = current;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${counter} to settle at >= ${atLeast}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sleep until just after the next windowed-limit bucket boundary.
|
||||
*
|
||||
* The limiter (createRateLimit -> getBucket) buckets time as
|
||||
* `now - (now % windowMs)` aligned to the epoch. A windowed exhaustion test must
|
||||
* land all of its MAX+1 requests inside ONE bucket; if the requests straddle a
|
||||
* boundary the counter resets mid-test and the expected 429 never happens. We
|
||||
* share the server's clock (same host), so aligning to a fresh bucket here makes
|
||||
* the exhaustion deterministic.
|
||||
*/
|
||||
const alignToFreshWindowBucket = async (windowSeconds: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msUntilNextBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, msUntilNextBucket + 100));
|
||||
};
|
||||
|
||||
/**
|
||||
* Guarantee at least `requiredHeadroomMs` remain in the current bucket so a burst
|
||||
* of MAX+1 requests completes inside ONE window. Without this, a burst that
|
||||
* happens to cross a bucket boundary would have its count reset mid-test and the
|
||||
* expected 429 would never fire. Unlike `alignToFreshWindowBucket`, this only
|
||||
* sleeps when we are actually near a boundary, so for long (e.g. 1m) windows it
|
||||
* is almost always a no-op.
|
||||
*/
|
||||
const ensureWindowHeadroom = async (windowSeconds: number, requiredHeadroomMs: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msLeftInBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
if (msLeftInBucket < requiredHeadroomMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, msLeftInBucket + 100));
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ApiErrorBody = { message?: string; error?: string };
|
||||
|
||||
/**
|
||||
* Non-throwing predicate: true when the response is an ORG-level 429
|
||||
* (`{ message }`), not the global IP 429 (`{ error }`). Used by the preflight,
|
||||
* which needs a boolean to decide whether to skip rather than fail.
|
||||
*/
|
||||
const isOrgLimited = async (res: APIResponse): Promise<boolean> => {
|
||||
if (res.status() !== 429) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = (await res.json().catch(() => ({}))) as ApiErrorBody;
|
||||
|
||||
// Global limiter returns `{ error }`; org limiter returns `{ message }`.
|
||||
return body.message !== undefined && body.error === undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the response is an ORG-level 429 and return its parsed body.
|
||||
*
|
||||
* Checks the status code EXPLICITLY so a wrong 200/4xx fails with a clear
|
||||
* "Expected 429, got <status>: <body>" message instead of an opaque
|
||||
* `expected true, received false`. Also asserts the body is the org limiter's
|
||||
* `{ message }` shape and not the global limiter's `{ error }` shape, so a
|
||||
* global-IP 429 can never be mistaken for the org limit under test.
|
||||
*/
|
||||
const expectOrgLimited = async (res: APIResponse): Promise<ApiErrorBody> => {
|
||||
const bodyText = await res.text();
|
||||
|
||||
expect(res.status(), `Expected an org 429 but got ${res.status()} with body: ${bodyText}`).toBe(429);
|
||||
|
||||
let body: ApiErrorBody = {};
|
||||
|
||||
try {
|
||||
body = JSON.parse(bodyText) as ApiErrorBody;
|
||||
} catch {
|
||||
throw new Error(`Expected a JSON error body, got: ${bodyText}`);
|
||||
}
|
||||
|
||||
expect(
|
||||
body.message !== undefined && body.error === undefined,
|
||||
`429 should be the ORG limiter ({ message }), not the global limiter ({ error }). Got: ${bodyText}`,
|
||||
).toBeTruthy();
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the org windowed-limit value is present in `X-RateLimit-Limit`.
|
||||
*
|
||||
* Two limiters set this header: the GLOBAL /api/v2 middleware (max 100) sets it
|
||||
* first, then the org limiter's AppError sets it to the org `max`. Playwright
|
||||
* surfaces duplicate headers joined by ", " (e.g. "100, 4"), so we assert the
|
||||
* org value is one of the comma-separated entries rather than an exact match.
|
||||
*/
|
||||
const expectRateLimitHeaderToInclude = (res: APIResponse, expectedMax: number) => {
|
||||
const header = res.headers()['x-ratelimit-limit'] ?? '';
|
||||
const values = header.split(',').map((v) => v.trim());
|
||||
|
||||
expect(values, `X-RateLimit-Limit "${header}" should include the org max ${expectedMax}`).toContain(
|
||||
String(expectedMax),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert NO org rate-limit header was added — used for quota rejections, which
|
||||
* intentionally omit rate-limit headers (a quota isn't a window). The GLOBAL
|
||||
* middleware still stamps a single `X-RateLimit-Limit: 100`, so "no org header"
|
||||
* means the value is either absent or exactly the lone global `100` (i.e. it does
|
||||
* not contain a second, org-specific entry).
|
||||
*/
|
||||
const expectNoOrgRateLimitHeader = (res: APIResponse) => {
|
||||
const header = res.headers()['x-ratelimit-limit'];
|
||||
|
||||
if (header === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = header.split(',').map((v) => v.trim());
|
||||
|
||||
expect(values, `Quota rejection should not add an org X-RateLimit-Limit, got "${header}"`).toEqual(['100']);
|
||||
};
|
||||
|
||||
/** Guard against the global limiter silently masking an org assertion. */
|
||||
const expectNotGlobalLimited = async (res: APIResponse) => {
|
||||
if (res.status() === 429) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
expect(
|
||||
'error' in body && !('message' in body),
|
||||
'Hit the GLOBAL /api/v2 IP limiter, not the org limiter. Re-run this suite in isolation.',
|
||||
).toBeFalsy();
|
||||
}
|
||||
};
|
||||
|
||||
/** Cheap read endpoint — consumes exactly one `api` counter, no document/email. */
|
||||
const findEnvelopes = (request: APIRequestContext, token: string): Promise<APIResponse> =>
|
||||
request.get(`${baseUrl}/envelope?perPage=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a DOCUMENT envelope. Consumes one `api` counter and, when
|
||||
* `type === DOCUMENT`, one `document` counter. Optionally seeds SIGNER recipients
|
||||
* (each with a signature field) so the envelope can later be distributed.
|
||||
*/
|
||||
const createEnvelope = async (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
options: { recipientCount?: number } = {},
|
||||
): Promise<APIResponse> => {
|
||||
const { recipientCount = 0 } = options;
|
||||
|
||||
const payload: TCreateEnvelopePayload = {
|
||||
title: `Rate limit test ${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
recipients:
|
||||
recipientCount > 0
|
||||
? Array.from({ length: recipientCount }, (_, i) => ({
|
||||
email: `rl-${Date.now()}-${i}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||
name: `Recipient ${i}`,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: i + 1,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
fieldMeta: { type: 'signature', overflow: 'crop' },
|
||||
identifier: 0,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 80,
|
||||
width: 20,
|
||||
height: 5,
|
||||
},
|
||||
],
|
||||
}))
|
||||
: undefined,
|
||||
meta: {
|
||||
subject: 'Rate limit test',
|
||||
message: 'Automated rate-limit test. Ignore.',
|
||||
distributionMethod: 'EMAIL',
|
||||
},
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
return request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
};
|
||||
|
||||
/** Distribute an envelope to all of its recipients via EMAIL. */
|
||||
const distributeEnvelope = (request: APIRequestContext, token: string, envelopeId: string): Promise<APIResponse> =>
|
||||
request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
meta: { distributionMethod: 'EMAIL', subject: 'Rate limit test', message: 'Rate limit test' },
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Redistribute (remind) the given recipients. This runs the SYNCHRONOUS email
|
||||
* assertion in resend-document with `count = recipients.length`, returning a 429
|
||||
* directly when the email limit/quota is exceeded.
|
||||
*/
|
||||
const redistributeEnvelope = (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
envelopeId: string,
|
||||
recipientIds: number[],
|
||||
): Promise<APIResponse> =>
|
||||
request.post(`${baseUrl}/envelope/redistribute`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId, recipients: recipientIds },
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a fully-distributed envelope and return its NOT_SIGNED recipient IDs so a
|
||||
* subsequent redistribute can exercise the synchronous email assertion.
|
||||
*
|
||||
* Setup uses a GENEROUS email quota so the async signing-request emails fanned out
|
||||
* by `distribute` are counted, then waits for that counter to settle. This drains
|
||||
* the background jobs BEFORE the caller resets usage, so they can't pollute
|
||||
* emailCount mid-test. The caller then configures the email limit/quota under test
|
||||
* and resets usage, so only the (synchronous, deterministic) redistribute counts.
|
||||
*/
|
||||
const seedDistributedEnvelope = async ({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount,
|
||||
}: {
|
||||
request: APIRequestContext;
|
||||
token: string;
|
||||
team: Team;
|
||||
organisation: Organisation;
|
||||
recipientCount: number;
|
||||
}): Promise<{ envelopeId: string; recipientIds: number[] }> => {
|
||||
await setClaimLimits(team, { emailQuota: 1000 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const createRes = await createEnvelope(request, token, { recipientCount });
|
||||
expect(createRes.ok(), `create failed: ${await createRes.text()}`).toBeTruthy();
|
||||
const { id: envelopeId } = (await createRes.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
const distributeRes = await distributeEnvelope(request, token, envelopeId);
|
||||
expect(distributeRes.ok(), `distribute failed: ${await distributeRes.text()}`).toBeTruthy();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Drain the async signing-request email jobs (one per recipient) so a late job
|
||||
// cannot increment emailCount after the caller's resetUsage.
|
||||
await waitForCounterToSettle(organisation, 'emailCount', recipientCount);
|
||||
|
||||
return { envelopeId, recipientIds: recipients.map((r) => r.id) };
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
test.describe('Organisation dynamic rate limits & quotas', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let organisation: Organisation;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
const seeded = await seedUser();
|
||||
user = seeded.user;
|
||||
team = seeded.team;
|
||||
organisation = seeded.organisation;
|
||||
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-org-rate-limits',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
// Preflight: the `test.skip` above only sees the PLAYWRIGHT process env. The
|
||||
// value that actually matters is the env the SERVER was started with — if the
|
||||
// server has `DANGEROUS_BYPASS_RATE_LIMITS=true`, every assertion here would
|
||||
// fail confusingly instead of skipping. Prove enforcement is live by setting a
|
||||
// quota of 0 (instant hard block) and confirming the server rejects. If it
|
||||
// doesn't, the server is bypassing limits, so skip with a clear message.
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const preflight = await findEnvelopes(request, token);
|
||||
const enforced = await isOrgLimited(preflight);
|
||||
|
||||
// Reset back to a clean slate before the real scenario runs.
|
||||
await setClaimLimits(team, {});
|
||||
await resetUsage(organisation);
|
||||
|
||||
test.skip(
|
||||
!enforced,
|
||||
'Server is not enforcing organisation rate limits (likely started with DANGEROUS_BYPASS_RATE_LIMITS=true). Restart the server with the flag unset/false to run this suite.',
|
||||
);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — windowed rate limit
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api rate limit (windowed)', () => {
|
||||
test('allows requests up to the limit then 429s with rate-limit headers', async ({ request }) => {
|
||||
const MAX = 4;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure the MAX+1 request burst lands inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
// Each request (including these GETs) consumes one api counter.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is over the windowed limit.
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
// The windowed limit uses a message distinct from the global limiter.
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectRateLimitHeaderToInclude(limitedRes, MAX);
|
||||
expect(limitedRes.headers()['x-ratelimit-remaining']).toContain('0');
|
||||
expect(limitedRes.headers()['retry-after']).toBeTruthy();
|
||||
|
||||
// The windowed stage blocks the (MAX+1)th request before the quota upsert,
|
||||
// but each of the MAX allowed requests still records usage (null quota now
|
||||
// tracks instead of skipping), so the counter equals MAX.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', MAX);
|
||||
});
|
||||
|
||||
test('a single allowed request succeeds when the limit is 1', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: 1 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure both requests land inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
const okRes = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
|
||||
// The one allowed request is counted (null quota still tracks); the blocked
|
||||
// request trips the window before the quota upsert, so the counter is 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 1);
|
||||
});
|
||||
|
||||
test('the windowed limit RESETS once the window elapses (429 -> wait -> 200)', async ({ request }) => {
|
||||
const MAX = 2;
|
||||
const WINDOW_SECONDS = 3;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: `${WINDOW_SECONDS}s`, max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Land at the start of a fresh bucket so all MAX+1 requests below fall in
|
||||
// the SAME window (otherwise a mid-exhaustion boundary would reset the count).
|
||||
await alignToFreshWindowBucket(WINDOW_SECONDS);
|
||||
|
||||
// Exhaust the window.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is blocked by the window.
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// Wait out the window using the server-provided Retry-After (plus a small
|
||||
// buffer to be sure we've crossed into the next time bucket). Crucially we
|
||||
// do NOT reset usage here — the limiter must recover on its own as the
|
||||
// bucket rolls over.
|
||||
const retryAfterHeader = limitedRes.headers()['retry-after'] ?? String(WINDOW_SECONDS);
|
||||
const retryAfterSeconds = Number.parseInt(retryAfterHeader.split(',')[0]?.trim() ?? '', 10) || WINDOW_SECONDS;
|
||||
await new Promise((resolve) => setTimeout(resolve, (retryAfterSeconds + 1) * 1000));
|
||||
|
||||
// Window has elapsed: the same org can make requests again without any
|
||||
// manual intervention — the bucket rolled over on its own.
|
||||
const afterReset = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(afterReset);
|
||||
expect(afterReset.status(), 'request after the window elapsed should be allowed').toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — monthly quota
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api quota (monthly)', () => {
|
||||
test('null quota allows unlimited requests', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: null });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// A null quota means "unlimited" (never blocks), but every request is now
|
||||
// recorded so usage is visible on unlimited plans — so the counter is 6.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 6);
|
||||
});
|
||||
|
||||
test('exhausting the quota 429s without rate-limit headers and keeps counting', async ({ request }) => {
|
||||
const QUOTA = 3;
|
||||
await setClaimLimits(team, { apiQuota: QUOTA });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be within quota`).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// Quota rejections deliberately omit rate-limit headers (it isn't a window).
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// The atomic increment runs even on the rejected request: QUOTA allowed
|
||||
// requests + the one rejected request = exactly QUOTA + 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
|
||||
test('quota of exactly 1 allows one request then blocks', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 1 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const okRes = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// One allowed + one rejected, both incremented => exactly 2.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 2);
|
||||
});
|
||||
|
||||
test('quota of 0 is a hard block with a "no quota available" message', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Document counter — windowed rate limit
|
||||
// =========================================================================
|
||||
|
||||
test.describe('document rate limit (windowed)', () => {
|
||||
test('allows creates up to the limit then 429s with headers', async ({ request }) => {
|
||||
const MAX = 3;
|
||||
// Keep api unlimited so only the document stage can trip.
|
||||
await setClaimLimits(team, { documentRateLimits: [{ window: '1m', max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure the MAX+1 create burst lands inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await createEnvelope(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `create #${i + 1} should succeed`).toBeTruthy();
|
||||
}
|
||||
|
||||
const limitedRes = await createEnvelope(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectRateLimitHeaderToInclude(limitedRes, MAX);
|
||||
expect(limitedRes.headers()['retry-after']).toBeTruthy();
|
||||
|
||||
// The (MAX+1)th create trips the window before the quota upsert, but each of
|
||||
// the MAX allowed creates still records usage (null quota now tracks).
|
||||
await expectMonthlyCounter(organisation, 'documentCount', MAX);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Document counter — monthly quota
|
||||
// =========================================================================
|
||||
|
||||
test.describe('document quota (monthly)', () => {
|
||||
test('exhausting the document quota blocks further creates', async ({ request }) => {
|
||||
const QUOTA = 2;
|
||||
await setClaimLimits(team, { documentQuota: QUOTA });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await createEnvelope(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `create #${i + 1} should be within quota`).toBeTruthy();
|
||||
}
|
||||
|
||||
const limitedRes = await createEnvelope(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// QUOTA successful creates + the rejected one (incremented before throwing).
|
||||
await expectMonthlyCounter(organisation, 'documentCount', QUOTA + 1);
|
||||
});
|
||||
|
||||
test('document quota of 0 hard-blocks creation', async ({ request }) => {
|
||||
await setClaimLimits(team, { documentQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const limitedRes = await createEnvelope(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'documentCount', 0);
|
||||
});
|
||||
|
||||
test('null document quota allows creation', async ({ request }) => {
|
||||
await setClaimLimits(team, { documentQuota: null });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await createEnvelope(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
// A null quota is unlimited (never blocks) but is now still recorded, so the
|
||||
// single create advances the counter to 1.
|
||||
await expectMonthlyCounter(organisation, 'documentCount', 1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — windowed rate limit (via synchronous redistribute)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email rate limit (windowed)', () => {
|
||||
test('redistribute is allowed when recipient count is within the email window', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
// Window allows 5/min; reminding 2 recipients is fine. Reset usage so the
|
||||
// create/distribute consumption above doesn't count against this window.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 5 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The windowed pass is now recorded even though the quota is null, so the
|
||||
// counter advances by the batch size (recipientIds.length).
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('redistribute is blocked when recipient count exceeds the email window', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 3,
|
||||
});
|
||||
|
||||
// Window only allows 2 emails per minute; reminding 3 at once exceeds it.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 2 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
const body = await expectOrgLimited(res);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectRateLimitHeaderToInclude(res, 2);
|
||||
|
||||
// Windowed limit trips BEFORE the quota stage, so the counter is untouched.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — monthly quota (via synchronous redistribute)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email quota (monthly)', () => {
|
||||
test('redistribute within the remaining email quota succeeds', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 10 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The synchronous assertion consumed exactly `recipientIds.length` of quota.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('redistribute that would exceed the email quota is blocked', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 3,
|
||||
});
|
||||
|
||||
// Quota of 2 but reminding 3 recipients in one synchronous call.
|
||||
await setClaimLimits(team, { emailQuota: 2 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
const body = await expectOrgLimited(res);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// Quota rejection carries no rate-limit headers.
|
||||
expectNoOrgRateLimitHeader(res);
|
||||
|
||||
// The count (3) is added BEFORE the over-quota check throws, so the counter
|
||||
// advances by the full batch even though the request was rejected.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('email quota of 0 hard-blocks reminders', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 1,
|
||||
});
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
const body = await expectOrgLimited(res);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Stage interaction — quota binds before a looser window
|
||||
// =========================================================================
|
||||
|
||||
test.describe('stage interaction', () => {
|
||||
test('the quota trips before a looser windowed limit', async ({ request }) => {
|
||||
const WINDOW_MAX = 50; // generous window
|
||||
const QUOTA = 2; // strict quota — should bind first
|
||||
await setClaimLimits(team, {
|
||||
apiRateLimits: [{ window: '1m', max: WINDOW_MAX }],
|
||||
apiQuota: QUOTA,
|
||||
});
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
|
||||
// It must be the QUOTA that bound, not the window: the message is the quota
|
||||
// one (not the windowed-limit message) and there are no rate-limit headers.
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
expect(String(body.message)).not.toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// Quota bound at QUOTA + 1; the looser window (50) was never the limiter.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||
|
||||
/**
|
||||
* Set the `recipientCount` limit on the organisation that owns the seeded team.
|
||||
*
|
||||
* A value of `0` means unlimited recipients are allowed.
|
||||
*/
|
||||
const setOrganisationRecipientCount = async (team: Team, recipientCount: number) => {
|
||||
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: {
|
||||
organisation: {
|
||||
id: team.organisationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
recipientCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
|
||||
const payload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Recipient Count Limit Test',
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return (await res.json()) as TCreateEnvelopeResponse;
|
||||
};
|
||||
|
||||
const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
|
||||
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return (await res.json()) as TGetEnvelopeResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an envelope with exactly `recipientCount` SIGNER recipients, each with
|
||||
* their own signature field, then attempt to distribute it.
|
||||
*
|
||||
* Returns the raw distribute response so the caller can assert on the status.
|
||||
*/
|
||||
const buildAndDistributeEnvelopeWithRecipients = async ({
|
||||
request,
|
||||
authToken,
|
||||
recipientCount,
|
||||
}: {
|
||||
request: APIRequestContext;
|
||||
authToken: string;
|
||||
recipientCount: number;
|
||||
}): Promise<{ envelopeId: string; distributeRes: APIResponse }> => {
|
||||
const envelope = await createEnvelope(request, authToken);
|
||||
|
||||
// Create N SIGNER recipients in a single request.
|
||||
const recipientData = Array.from({ length: recipientCount }).map((_, index) => ({
|
||||
email: `recipient-${index}-${Date.now()}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||
name: `Recipient ${index}`,
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
}));
|
||||
|
||||
const recipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: recipientData,
|
||||
} satisfies TCreateEnvelopeRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(recipientsRes.ok()).toBeTruthy();
|
||||
|
||||
const recipients = (await recipientsRes.json()).data;
|
||||
|
||||
// Resolve the envelope item ID to place fields on.
|
||||
const envelopeData = await getEnvelope(request, authToken, envelope.id);
|
||||
const envelopeItemId = envelopeData.envelopeItems[0].id;
|
||||
|
||||
// Each SIGNER must have a signature field, otherwise distribution fails for
|
||||
// a reason unrelated to the recipient count.
|
||||
const fieldData = recipients.map((recipient: { id: number }) => ({
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 100,
|
||||
width: 50,
|
||||
height: 50,
|
||||
}));
|
||||
|
||||
const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: fieldData,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fieldsRes.ok()).toBeTruthy();
|
||||
|
||||
// Attempt to distribute the envelope.
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
return { envelopeId: envelope.id, distributeRes };
|
||||
};
|
||||
|
||||
const expectEnvelopeStatus = async (envelopeId: string, status: DocumentStatus) => {
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelopeId },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(status);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Recipient count limit on distribute', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-recipient-count-limit',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Limit = 3. Edge cases around the boundary: 2 (under), 3 (at), 4 (over).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should allow distribution when recipient count is below the limit', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 3);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('should allow distribution when recipient count is exactly at the limit', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 3);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 3,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('should deny distribution when recipient count is one over the limit', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 3);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 4,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeFalsy();
|
||||
expect(distributeRes.status()).toBe(400);
|
||||
|
||||
// The envelope must remain a DRAFT — distribution was rejected.
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Limit = 1. The smallest non-unlimited boundary.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should allow distribution with a single recipient when the limit is 1', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 1);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 1,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('should deny distribution with two recipients when the limit is 1', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 1);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeFalsy();
|
||||
expect(distributeRes.status()).toBe(400);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Limit = 0 means unlimited recipients are allowed.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should allow distribution with many recipients when the limit is 0 (unlimited)', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 0);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 10,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
});
|
||||
@@ -621,6 +621,145 @@ const assertDuplicateDeleteFieldPersistedInDatabase = async ({
|
||||
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
};
|
||||
|
||||
// --- Change field type flow ---
|
||||
|
||||
type TChangeFieldTypeFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const FIELD_A_POSITION = { x: 150, y: 150 };
|
||||
const FIELD_B_POSITION = { x: 150, y: 250 };
|
||||
|
||||
const changeFieldTypeViaToolbar = async (root: Page, newTypeLabel: FieldButtonName) => {
|
||||
await expect(root.locator('button[title="Change Field Type"]')).toBeVisible();
|
||||
await root.locator('button[title="Change Field Type"]').click();
|
||||
|
||||
// The CommandDialog uses role="option" for items; sidebar palette buttons use role="button".
|
||||
const option = root.getByRole('option', { name: newTypeLabel, exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
|
||||
// Wait for the CommandDialog to close (selection persists so the toolbar remains).
|
||||
await expect(root.getByRole('dialog')).toHaveCount(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Multi-select fields on the konva canvas by drawing a marquee selection rectangle.
|
||||
*
|
||||
* The editor's stage mousedown/mousemove/mouseup handlers create a Konva selection
|
||||
* rectangle when the user drags on empty stage area. All field groups that intersect
|
||||
* the rectangle are selected at once. This is the canonical multi-select gesture.
|
||||
*/
|
||||
const marqueeSelectFieldsOnCanvas = async (
|
||||
root: Page,
|
||||
start: { x: number; y: number },
|
||||
end: { x: number; y: number },
|
||||
) => {
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
if (!box) {
|
||||
throw new Error('Canvas bounding box not available for marquee selection.');
|
||||
}
|
||||
|
||||
// The marquee gesture must start on empty stage (not on a field) and pass through
|
||||
// intermediate points so the editor's mousemove handler can grow the rectangle.
|
||||
await root.mouse.move(box.x + start.x, box.y + start.y);
|
||||
await root.mouse.down();
|
||||
await root.mouse.move(box.x + (start.x + end.x) / 2, box.y + (start.y + end.y) / 2, { steps: 5 });
|
||||
await root.mouse.move(box.x + end.x, box.y + end.y, { steps: 5 });
|
||||
await root.mouse.up();
|
||||
};
|
||||
|
||||
const runChangeFieldTypeFlow = async (surface: TEnvelopeEditorSurface): Promise<TChangeFieldTypeFlowResult> => {
|
||||
const externalId = `e2e-change-type-${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 two fields of different types: Signature (A) and Name (B).
|
||||
await placeFieldOnPdf(root, 'Signature', FIELD_A_POSITION);
|
||||
await placeFieldOnPdf(root, 'Name', FIELD_B_POSITION);
|
||||
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// --- Phase 1: single field type change ---
|
||||
// Select field A (Signature) and change it to Text via the toolbar.
|
||||
await selectFieldOnCanvas(root, FIELD_A_POSITION);
|
||||
await changeFieldTypeViaToolbar(root, 'Text');
|
||||
|
||||
// Field count must remain stable -- changing type doesn't add/remove fields.
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Navigate away and back to verify the change is persisted in local state.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// --- Phase 2: multi-field type change ---
|
||||
// Use a marquee drag-selection rectangle to capture both fields at once.
|
||||
// Fields are at (150, 150) and (150, 250) with default dims ~90x30; drag from
|
||||
// (50, 100) to (260, 290) encloses both with margin.
|
||||
await marqueeSelectFieldsOnCanvas(root, { x: 50, y: 100 }, { x: 260, y: 290 });
|
||||
|
||||
// With mixed-type selection (Text + Name), change both to Date.
|
||||
await changeFieldTypeViaToolbar(root, 'Date');
|
||||
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Navigate away and back to verify persistence.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertChangeFieldTypePersistedInDatabase = 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 },
|
||||
});
|
||||
|
||||
// Started with Signature + Name, then both were converted to Date.
|
||||
// Use sorted .map() in the assertion so any failure prints which types were found.
|
||||
const actualTypes = envelope.fields.map((field) => field.type).sort();
|
||||
const expectedTypes = [FieldType.DATE, FieldType.DATE];
|
||||
|
||||
expect(envelope.fields).toHaveLength(2);
|
||||
expect(actualTypes).toEqual(expectedTypes);
|
||||
|
||||
// Each field's meta must have been reset to the new type's defaults.
|
||||
const actualMetaTypes = envelope.fields.map((field) => getFieldMetaType(field.fieldMeta)).sort();
|
||||
expect(actualMetaTypes).toEqual(['date', 'date']);
|
||||
};
|
||||
|
||||
// --- Test describe blocks ---
|
||||
|
||||
test.describe('document editor', () => {
|
||||
@@ -663,6 +802,16 @@ test.describe('document editor', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
@@ -705,6 +854,16 @@ test.describe('template editor', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
@@ -767,6 +926,21 @@ test.describe('embedded create', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-change-type',
|
||||
});
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
@@ -833,4 +1007,20 @@ test.describe('embedded edit', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-change-type',
|
||||
});
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,21 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
|
||||
|
||||
await root.locator('input[name="externalId"]').fill(externalId);
|
||||
await root.locator('input[name="meta.redirectUrl"]').fill(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
await root.getByRole('button', { name: 'Notifications' }).click();
|
||||
// Fill email-content fields and toggle recipient-facing notification checkboxes
|
||||
// while distributionMethod is still EMAIL. After it flips to NONE below, these
|
||||
// controls are disabled because no email is sent to recipients.
|
||||
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
|
||||
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
|
||||
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
|
||||
await root.locator('#recipientSigned').click();
|
||||
await root.locator('#recipientSigningRequest').click();
|
||||
await root.locator('#recipientRemoved').click();
|
||||
await root.locator('#documentPending').click();
|
||||
await root.locator('#documentCompleted').click();
|
||||
await root.locator('#documentDeleted').click();
|
||||
|
||||
await root.getByRole('button', { name: 'General' }).click();
|
||||
|
||||
await root.locator('[data-testid="documentDistributionMethodSelectValue"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.distributionMethod }).click();
|
||||
@@ -190,19 +205,35 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await root.locator('#recipientSigned').click();
|
||||
await root.locator('#recipientSigningRequest').click();
|
||||
await root.locator('#recipientRemoved').click();
|
||||
await root.locator('#documentPending').click();
|
||||
await root.locator('#documentCompleted').click();
|
||||
await root.locator('#documentDeleted').click();
|
||||
await root.getByRole('button', { name: 'Notifications' }).click();
|
||||
|
||||
// Distribution is NONE: email-content fields stay rendered but disabled,
|
||||
// recipient-facing checkboxes are hidden entirely and replaced by an alert,
|
||||
// owner-facing checkboxes stay editable so we toggle them here.
|
||||
await expect(root.locator('input[name="meta.subject"]')).toBeDisabled();
|
||||
await expect(root.locator('textarea[name="meta.message"]')).toBeDisabled();
|
||||
await expect(root.locator('input[name="meta.emailReplyTo"]')).toBeDisabled();
|
||||
await expect(root.locator('#recipientSigned')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientRemoved')).toHaveCount(0);
|
||||
await expect(root.locator('#documentPending')).toHaveCount(0);
|
||||
await expect(root.locator('#documentCompleted')).toHaveCount(0);
|
||||
await expect(root.locator('#documentDeleted')).toHaveCount(0);
|
||||
await expect(root.getByText(/Email distribution needs to be enabled/)).toBeVisible();
|
||||
|
||||
// Email Sender select only renders when the org has the emailDomains feature
|
||||
// flag plus allowConfigureEmailSender, so the assertion is conditional.
|
||||
const emailSenderSelect = getComboboxByLabel(root, 'Email Sender');
|
||||
const hasEmailSenderSelect = (await emailSenderSelect.count()) > 0;
|
||||
|
||||
if (hasEmailSenderSelect) {
|
||||
await expect(emailSenderSelect).toBeDisabled();
|
||||
}
|
||||
|
||||
await expect(root.locator('#ownerDocumentCompleted')).toBeEnabled();
|
||||
await root.locator('#ownerDocumentCompleted').click();
|
||||
await root.locator('#ownerRecipientExpired').click();
|
||||
await root.locator('#ownerDocumentCreated').click();
|
||||
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
|
||||
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
|
||||
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
|
||||
|
||||
await root.getByRole('button', { name: 'Security' }).click();
|
||||
await selectMultiSelectOption(root, 'documentAccessSelectValue', TEST_SETTINGS_VALUES.accessAuth);
|
||||
@@ -264,13 +295,17 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
|
||||
TEST_SETTINGS_VALUES.reminderRepeatUnit,
|
||||
);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientRemoved')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentPending')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentDeleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await root.getByRole('button', { name: 'Notifications' }).click();
|
||||
// Distribution persisted as NONE: recipient-facing checkboxes are hidden, owner-facing
|
||||
// checkboxes remain visible and persist their stored values. Email-content fields are
|
||||
// still rendered (disabled) and persist their stored values.
|
||||
await expect(root.locator('#recipientSigned')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientRemoved')).toHaveCount(0);
|
||||
await expect(root.locator('#documentPending')).toHaveCount(0);
|
||||
await expect(root.locator('#documentCompleted')).toHaveCount(0);
|
||||
await expect(root.locator('#documentDeleted')).toHaveCount(0);
|
||||
await expect(root.getByText(/Email distribution needs to be enabled/)).toBeVisible();
|
||||
await expect(root.locator('#ownerDocumentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerRecipientExpired')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerDocumentCreated')).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const getEmailReports = async (organisationId: string) => {
|
||||
const stat = await prisma.organisationMonthlyStat.findUnique({
|
||||
where: {
|
||||
organisationId_period: {
|
||||
organisationId,
|
||||
period: currentMonthlyPeriod(),
|
||||
},
|
||||
},
|
||||
select: { emailReports: true },
|
||||
});
|
||||
|
||||
return stat?.emailReports ?? 0;
|
||||
};
|
||||
|
||||
test('[REPORT_SENDER]: only reports the sender after the button is clicked', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, ['recipient@documenso.com']);
|
||||
const token = document.recipients[0].token;
|
||||
|
||||
expect(await getEmailReports(organisation.id)).toBe(0);
|
||||
|
||||
await page.goto(`/report/${token}`);
|
||||
|
||||
// Visiting the page (GET) must not register a report.
|
||||
await expect(page.getByRole('heading', { name: 'Report this sender?' })).toBeVisible();
|
||||
expect(await getEmailReports(organisation.id)).toBe(0);
|
||||
|
||||
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||
|
||||
expect(await getEmailReports(organisation.id)).toBe(1);
|
||||
});
|
||||
|
||||
test('[REPORT_SENDER]: does not double count within the rate limit window', async ({ page }) => {
|
||||
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Rate limits are bypassed');
|
||||
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, ['recipient@documenso.com']);
|
||||
const token = document.recipients[0].token;
|
||||
|
||||
await page.goto(`/report/${token}`);
|
||||
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||
|
||||
await page.goto(`/report/${token}`);
|
||||
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||
|
||||
expect(await getEmailReports(organisation.id)).toBe(1);
|
||||
});
|
||||
|
||||
test('[REPORT_SENDER]: returns 404 for an invalid token', async ({ page }) => {
|
||||
const response = await page.goto('/report/not-a-real-token');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentDataType, FieldType } from '@prisma/client';
|
||||
|
||||
const BRANDING_URL = 'https://brand.example/signing?source=documenso';
|
||||
const PDF_PAGE_SELECTOR = 'img[data-page-number]';
|
||||
|
||||
const readBrandingLogo = async () => {
|
||||
const logo = await fs.readFile(path.join(__dirname, '../../assets/logo.png'));
|
||||
|
||||
return JSON.stringify({
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: logo.toString('base64'),
|
||||
});
|
||||
};
|
||||
|
||||
const enableOrganisationBranding = async ({
|
||||
organisationGlobalSettingsId,
|
||||
brandingUrl = BRANDING_URL,
|
||||
}: {
|
||||
organisationGlobalSettingsId: string;
|
||||
brandingUrl?: string;
|
||||
}) => {
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisationGlobalSettingsId },
|
||||
data: {
|
||||
brandingEnabled: true,
|
||||
brandingLogo: await readBrandingLogo(),
|
||||
brandingUrl,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* On signing surfaces the custom branding logo must render as a plain image.
|
||||
* It must not be wrapped in any link, and the Brand Website must never appear
|
||||
* as a link on these pages.
|
||||
*/
|
||||
const expectPlainBrandingLogo = async (page: Page, logoName: string) => {
|
||||
const logo = page.getByRole('img', { name: logoName });
|
||||
|
||||
await expect(logo).toBeVisible();
|
||||
|
||||
// The custom logo must not be wrapped in a link.
|
||||
await expect(page.getByRole('link', { name: logoName })).toHaveCount(0);
|
||||
|
||||
// The Brand Website must never be rendered as a link on signing pages.
|
||||
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
||||
};
|
||||
|
||||
test('[SIGNING_BRANDING]: V1 normal signing renders custom logo as a plain image', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
await enableOrganisationBranding({
|
||||
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||
});
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['v1-branding-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
|
||||
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||
});
|
||||
|
||||
test('[SIGNING_BRANDING]: V2 signing renders custom logo as a plain image', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
await enableOrganisationBranding({
|
||||
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||
});
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['v2-branding-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
const directTemplate = await seedDirectTemplate({
|
||||
title: 'V2 Branding Direct Template',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||
|
||||
await page.goto(formatDirectTemplatePath(directTemplate.directLink?.token || ''));
|
||||
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||
});
|
||||
|
||||
test('[SIGNING_BRANDING]: V2 signing keeps internal link for the Documenso fallback logo', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['v2-fallback-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
|
||||
const fallbackLogoLink = page.locator('a[href="/"]').first();
|
||||
|
||||
await expect(fallbackLogoLink).toBeVisible();
|
||||
});
|
||||
|
||||
test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Website links', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
await enableOrganisationBranding({
|
||||
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||
});
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['embed-branding-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
await page.goto(`/embed/sign/${recipients[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
|
||||
});
|
||||
@@ -1,23 +1,395 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_USER_ACCOUNT_TYPE } from '@documenso/lib/constants/organisations';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test('[USER] delete account', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
await apiSignin({ page, email: user.email, redirectPath: '/settings' });
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
/**
|
||||
* The deleted-account service account is where orphaned DOCUMENT envelopes land
|
||||
* when the team/org they belong to is torn down. It is created by a migration so
|
||||
* it always exists in the test database.
|
||||
*/
|
||||
const getDeletedServiceAccount = async () => {
|
||||
const deletedAccount = await prisma.user.findFirstOrThrow({
|
||||
where: { email: { startsWith: 'deleted-account@' } },
|
||||
select: {
|
||||
id: true,
|
||||
ownedOrganisations: { select: { teams: { select: { id: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: deletedAccount.id,
|
||||
teamId: deletedAccount.ownedOrganisations[0].teams[0].id,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Drives the account deletion through the settings UI, exactly as a user would.
|
||||
* Returns once the app has redirected to the sign-in page (deletion is performed
|
||||
* synchronously by the `profile.deleteAccount` mutation before the redirect).
|
||||
*/
|
||||
const deleteAccountViaUi = async (page: Page, email: string) => {
|
||||
await apiSignin({ page, email, redirectPath: '/settings' });
|
||||
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.getByLabel('Confirm Email').fill(user.email);
|
||||
await page.getByLabel('Confirm Email').fill(email);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Confirm Deletion' }).click();
|
||||
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/signin`);
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
||||
};
|
||||
|
||||
// Verify that the user no longer exists in the database
|
||||
const seedDocumentWithStatus = async (sender: User, teamId: number, key: string, status: DocumentStatus) => {
|
||||
const document = await seedBlankDocument(sender, teamId, { key });
|
||||
|
||||
if (status !== DocumentStatus.DRAFT) {
|
||||
await prisma.envelope.update({
|
||||
where: { id: document.id },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
const waitForOrganisationToBeGone = async (organisationId: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const org = await prisma.organisation.findUnique({
|
||||
where: { id: organisationId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return org === null;
|
||||
},
|
||||
{
|
||||
message: `Organisation ${organisationId} was not removed after account deletion`,
|
||||
timeout: 15_000,
|
||||
intervals: [250, 500, 1000],
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
|
||||
// ─── Happy path: the basic flow still works ──────────────────────────────────
|
||||
|
||||
test('[USER] delete account', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
// Verify that the user no longer exists in the database.
|
||||
await expect(getUserByEmail({ email: user.email })).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ─── Owned organisation: documents orphaned to the service account ───────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: owned org docs are orphaned to service account, drafts and templates removed', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
// Inflight/completed DOCUMENT envelopes that must survive as orphans.
|
||||
const completed = await seedDocumentWithStatus(user, team.id, 'owned-completed', DocumentStatus.COMPLETED);
|
||||
const pending = await seedDocumentWithStatus(user, team.id, 'owned-pending', DocumentStatus.PENDING);
|
||||
const rejected = await seedDocumentWithStatus(user, team.id, 'owned-rejected', DocumentStatus.REJECTED);
|
||||
|
||||
// A draft DOCUMENT — orphan only re-parents PENDING/REJECTED/COMPLETED, so it is hard-deleted.
|
||||
const draft = await seedDocumentWithStatus(user, team.id, 'owned-draft', DocumentStatus.DRAFT);
|
||||
|
||||
// A TEMPLATE — orphan only re-parents DOCUMENT envelopes, so it is hard-deleted.
|
||||
const template = await seedBlankDocument(user, team.id, { key: 'owned-template' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: template.id },
|
||||
data: { type: EnvelopeType.TEMPLATE },
|
||||
});
|
||||
|
||||
expect(await prisma.envelope.count({ where: { teamId: team.id } })).toBe(5);
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
const service = await getDeletedServiceAccount();
|
||||
|
||||
// Completed/pending/rejected: re-parented to the service account + soft-deleted.
|
||||
for (const original of [completed, pending, rejected]) {
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: original.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(after, `envelope ${original.id} should survive as an orphan`).not.toBeNull();
|
||||
expect(after?.teamId).toBe(service.teamId);
|
||||
expect(after?.userId).toBe(service.id);
|
||||
expect(after?.deletedAt).not.toBeNull();
|
||||
}
|
||||
|
||||
// Draft + template are hard-deleted.
|
||||
expect(await prisma.envelope.findUnique({ where: { id: draft.id } })).toBeNull();
|
||||
expect(await prisma.envelope.findUnique({ where: { id: template.id } })).toBeNull();
|
||||
|
||||
// The owned org, its team, and the user are gone. Nothing references the old team.
|
||||
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).toBeNull();
|
||||
expect(await prisma.team.findUnique({ where: { id: team.id } })).toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: user.id } })).toBeNull();
|
||||
expect(await prisma.envelope.count({ where: { teamId: team.id } })).toBe(0);
|
||||
});
|
||||
|
||||
// ─── Member of another org: documents transferred to the OWNER, not deleted ──
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: docs in orgs the user is a member of are transferred to the org owner', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Another org, owned by someone else, that the deleted user is merely a member of.
|
||||
const { user: ownerB, organisation: orgB, team: teamB } = await seedUser();
|
||||
|
||||
// The account being deleted. They own their own (personal) org too.
|
||||
const { user: userA, organisation: orgA, team: teamA } = await seedUser();
|
||||
|
||||
await seedOrganisationMembers({
|
||||
organisationId: orgB.id,
|
||||
members: [{ email: userA.email, name: userA.name ?? 'User A', organisationRole: 'MEMBER' }],
|
||||
});
|
||||
|
||||
// userA authors envelopes inside orgB's team (both completed and draft).
|
||||
const memberCompleted = await seedDocumentWithStatus(userA, teamB.id, 'member-completed', DocumentStatus.COMPLETED);
|
||||
const memberDraft = await seedDocumentWithStatus(userA, teamB.id, 'member-draft', DocumentStatus.DRAFT);
|
||||
|
||||
// userA also has a completed doc in their OWN org (should orphan to service account).
|
||||
const ownedCompleted = await seedDocumentWithStatus(userA, teamA.id, 'owned-completed', DocumentStatus.COMPLETED);
|
||||
|
||||
await deleteAccountViaUi(page, userA.email);
|
||||
|
||||
await waitForOrganisationToBeGone(orgA.id);
|
||||
|
||||
const service = await getDeletedServiceAccount();
|
||||
|
||||
// Member-org envelopes — regardless of status — are reassigned to orgB's owner,
|
||||
// stay in orgB's team, and are NOT soft-deleted.
|
||||
for (const original of [memberCompleted, memberDraft]) {
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: original.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(after, `member envelope ${original.id} should be transferred, not deleted`).not.toBeNull();
|
||||
expect(after?.teamId).toBe(teamB.id);
|
||||
expect(after?.userId).toBe(ownerB.id);
|
||||
expect(after?.deletedAt).toBeNull();
|
||||
}
|
||||
|
||||
// The other org and its owner survive — only the deleted user's own org is removed.
|
||||
expect(await prisma.organisation.findUnique({ where: { id: orgB.id } })).not.toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: ownerB.id } })).not.toBeNull();
|
||||
|
||||
// The deleted user's own completed doc was orphaned to the service account.
|
||||
const ownedAfter = await prisma.envelope.findUnique({
|
||||
where: { id: ownedCompleted.id },
|
||||
select: { teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(ownedAfter, 'owned-org envelope should survive as an orphan').not.toBeNull();
|
||||
expect(ownedAfter?.teamId).toBe(service.teamId);
|
||||
expect(ownedAfter?.userId).toBe(service.id);
|
||||
expect(ownedAfter?.deletedAt).not.toBeNull();
|
||||
|
||||
// userA is gone.
|
||||
expect(await prisma.user.findUnique({ where: { id: userA.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Owned org with members: org torn down, members survive, their docs orphaned ─
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: deleting the owner removes the org but keeps members and orphans their docs', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: owner, organisation, team } = await seedUser();
|
||||
|
||||
const [member] = await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [{ organisationRole: 'MEMBER' }],
|
||||
});
|
||||
|
||||
// A member (not the owner) authored a completed doc inside the owned org's team.
|
||||
// The orphan logic filters by teamId only, so this must be orphaned too.
|
||||
const memberCompleted = await seedDocumentWithStatus(member, team.id, 'member-completed', DocumentStatus.COMPLETED);
|
||||
|
||||
await deleteAccountViaUi(page, owner.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
const service = await getDeletedServiceAccount();
|
||||
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: memberCompleted.id },
|
||||
select: { teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(after, 'member-authored envelope should survive as an orphan').not.toBeNull();
|
||||
expect(after?.teamId).toBe(service.teamId);
|
||||
expect(after?.userId).toBe(service.id);
|
||||
expect(after?.deletedAt).not.toBeNull();
|
||||
|
||||
// The member user survives — only the org and its owner are removed.
|
||||
expect(await prisma.user.findUnique({ where: { id: member.id } })).not.toBeNull();
|
||||
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: owner.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Subscription cancellation is scheduled for owned orgs ───────────────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: a cancel-subscription job is enqueued for an owned org that has a subscription', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation } = await seedUser();
|
||||
|
||||
const planId = `sub_e2e_${nanoid()}`;
|
||||
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
planId,
|
||||
priceId: `price_e2e_${nanoid()}`,
|
||||
customerId: `cus_e2e_${nanoid()}`,
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// The deletion must schedule the Stripe subscription cancellation job with the
|
||||
// captured planId (the Subscription row itself cascades away with the org).
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.cancel-organisation-subscription',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (job.payload as { stripeSubscriptionId?: string }).stripeSubscriptionId ?? null;
|
||||
},
|
||||
{
|
||||
message: 'cancel-organisation-subscription job was not enqueued',
|
||||
timeout: 15_000,
|
||||
intervals: [250, 500, 1000],
|
||||
},
|
||||
)
|
||||
.toBe(planId);
|
||||
|
||||
// The local Subscription row cascades away with the organisation — which is
|
||||
// exactly why the planId has to be captured into the job payload beforehand.
|
||||
expect(await prisma.subscription.findUnique({ where: { planId } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Owned org account (SSO) rows are cleaned up, members survive ────────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: org-linked account rows are removed when an owned org is torn down', async ({ page }) => {
|
||||
const { user: owner, organisation } = await seedUser();
|
||||
|
||||
const [member] = await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [{ organisationRole: 'MEMBER' }],
|
||||
});
|
||||
|
||||
// Simulate a member who linked their login through the organisation's SSO.
|
||||
// These rows are keyed by `provider = organisation.id` and have no foreign key
|
||||
// to the organisation, so they must be deleted explicitly during teardown.
|
||||
const orgAccount = await prisma.account.create({
|
||||
data: {
|
||||
userId: member.id,
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: organisation.id,
|
||||
providerAccountId: `oidc-${nanoid()}`,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteAccountViaUi(page, owner.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// The org-linked account row is gone...
|
||||
expect(await prisma.account.findUnique({ where: { id: orgAccount.id } })).toBeNull();
|
||||
expect(
|
||||
await prisma.account.count({
|
||||
where: { type: ORGANISATION_USER_ACCOUNT_TYPE, provider: organisation.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// ...but the member user it belonged to survives (only the org + owner are removed).
|
||||
expect(await prisma.user.findUnique({ where: { id: member.id } })).not.toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: owner.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Sad path: no subscription means no cancel job is enqueued ────────────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: no cancel-subscription job is enqueued when the owned org has no subscription', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation } = await seedUser();
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.cancel-organisation-subscription',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
expect(job).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Sad path: a mismatched confirmation email leaves everything intact ───────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: a wrong confirmation email keeps the account, org and documents intact', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
const completed = await seedDocumentWithStatus(user, team.id, 'kept-completed', DocumentStatus.COMPLETED);
|
||||
|
||||
await apiSignin({ page, email: user.email, redirectPath: '/settings' });
|
||||
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.getByLabel('Confirm Email').fill('not-my-email@example.com');
|
||||
|
||||
// The confirm button stays disabled while the email does not match.
|
||||
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).toBeDisabled();
|
||||
|
||||
// Nothing was deleted or orphaned.
|
||||
expect(await prisma.user.findUnique({ where: { id: user.id } })).not.toBeNull();
|
||||
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).not.toBeNull();
|
||||
|
||||
const docAfter = await prisma.envelope.findUnique({
|
||||
where: { id: completed.id },
|
||||
select: { teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(docAfter?.teamId).toBe(team.id);
|
||||
expect(docAfter?.userId).toBe(user.id);
|
||||
expect(docAfter?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export const AuthenticationErrorCode = {
|
||||
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
|
||||
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
|
||||
SignupDisabled: 'SIGNUP_DISABLED',
|
||||
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
|
||||
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
|
||||
// IncorrectIdentityProvider: 'INCORRECT_IDENTITY_PROVIDER',
|
||||
// IncorrectPassword: 'INCORRECT_PASSWORD',
|
||||
|
||||
@@ -30,7 +30,6 @@ export const sessionCookieOptions = {
|
||||
sameSite: useSecureCookies ? 'none' : 'lax',
|
||||
secure: useSecureCookies,
|
||||
domain: getCookieDomain(),
|
||||
expires: new Date(Date.now() + AUTH_SESSION_LIFETIME),
|
||||
} as const;
|
||||
|
||||
export const extractSessionCookieFromHeaders = (headers: Headers): string | null => {
|
||||
@@ -56,7 +55,10 @@ export const getSessionCookie = async (c: Context): Promise<string | null> => {
|
||||
* @param sessionToken - The session token to set.
|
||||
*/
|
||||
export const setSessionCookie = async (c: Context, sessionToken: string) => {
|
||||
await setSignedCookie(c, sessionCookieName, sessionToken, getAuthSecret(), sessionCookieOptions).catch((err) => {
|
||||
await setSignedCookie(c, sessionCookieName, sessionToken, getAuthSecret(), {
|
||||
...sessionCookieOptions,
|
||||
expires: new Date(Date.now() + AUTH_SESSION_LIFETIME),
|
||||
}).catch((err) => {
|
||||
appLog('SetSessionCookie', `Error setting signed cookie: ${err}`);
|
||||
|
||||
throw err;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { AUTH_SESSION_LIFETIME } from '../../config';
|
||||
*/
|
||||
export type SessionUser = Pick<
|
||||
User,
|
||||
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature'
|
||||
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled'
|
||||
>;
|
||||
|
||||
export type SessionValidationResult =
|
||||
@@ -86,6 +86,7 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
|
||||
twoFactorEnabled: true,
|
||||
roles: true,
|
||||
signature: true,
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertUserNotDisabledById } from '@documenso/lib/server-only/user/assert-user-not-disabled';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import type { HonoAuthContext } from '../../types/context';
|
||||
@@ -10,8 +11,15 @@ type AuthorizeUser = {
|
||||
|
||||
/**
|
||||
* Handles creating a session.
|
||||
*
|
||||
* Refuses to issue a session for a disabled account. This is the single
|
||||
* chokepoint shared by every sign-in path (email/password, passkey, OAuth,
|
||||
* OIDC, organisation OIDC), so the guard belongs here rather than in each
|
||||
* caller.
|
||||
*/
|
||||
export const onAuthorize = async (user: AuthorizeUser, c: Context<HonoAuthContext>) => {
|
||||
await assertUserNotDisabledById({ userId: user.userId });
|
||||
|
||||
const metadata = c.get('requestMetadata');
|
||||
|
||||
const sessionToken = generateSessionToken();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import {
|
||||
isDisposableEmail,
|
||||
isEmailDomainAllowedForSignup,
|
||||
isSignupEnabledForProvider,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
|
||||
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
|
||||
@@ -132,6 +137,17 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
return c.redirect(errorUrl.toString(), 302);
|
||||
}
|
||||
|
||||
// Reject disposable / throwaway email providers for new SSO users.
|
||||
const additionalBlockedDomains = await getEmailBlocklistDomains();
|
||||
|
||||
if (isDisposableEmail(email, additionalBlockedDomains)) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
|
||||
|
||||
return c.redirect(errorUrl.toString(), 302);
|
||||
}
|
||||
|
||||
// Handle new user.
|
||||
const createdUser = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
|
||||
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -74,6 +75,17 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
||||
return c.redirect(errorUrl.toString(), 302);
|
||||
}
|
||||
|
||||
// Reject disposable / throwaway email providers for new SSO users.
|
||||
const additionalBlockedDomains = await getEmailBlocklistDomains();
|
||||
|
||||
if (isDisposableEmail(email, additionalBlockedDomains)) {
|
||||
const errorUrl = new URL(formatOrganisationLoginUrl(orgUrl));
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
|
||||
|
||||
return c.redirect(errorUrl.toString(), 302);
|
||||
}
|
||||
|
||||
userToLink = await prisma.user.create({
|
||||
data: {
|
||||
email: email,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import {
|
||||
isDisposableEmail,
|
||||
isEmailDomainAllowedForSignup,
|
||||
isSignupEnabledForProvider,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
@@ -18,6 +22,7 @@ import {
|
||||
signupRateLimit,
|
||||
verifyEmailRateLimit,
|
||||
} from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
|
||||
@@ -167,12 +172,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
});
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
throw new AppError('ACCOUNT_DISABLED', {
|
||||
message: 'Account disabled',
|
||||
});
|
||||
}
|
||||
|
||||
// The disabled check now lives inside `onAuthorize` so every sign-in path
|
||||
// (password, passkey, OAuth, OIDC) shares the same enforcement.
|
||||
await onAuthorize({ userId: user.id }, c);
|
||||
|
||||
return c.text('', 201);
|
||||
@@ -214,6 +215,14 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
});
|
||||
}
|
||||
|
||||
const additionalBlockedDomains = await getEmailBlocklistDomains();
|
||||
|
||||
if (isDisposableEmail(email, additionalBlockedDomains)) {
|
||||
throw new AppError(AuthenticationErrorCode.SignupDisposableEmail, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await createUser({ name, email, password, signature }).catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
|
||||
@@ -75,6 +75,13 @@ export const sendOrganisationAccountLinkConfirmationEmail = async ({
|
||||
},
|
||||
});
|
||||
|
||||
// We only take `emailLanguage` here and intentionally ignore the resolved
|
||||
// `emailTransport`/`senderEmail`. Unlike other INTERNAL emails, this is an
|
||||
// auth-critical SSO account creation/linking confirmation: it must always be
|
||||
// delivered from trusted Documenso infrastructure (see the `mailer.sendMail`
|
||||
// below). Routing it through the organisation's own (potentially
|
||||
// misconfigured) transport could block account linking and lock users out of
|
||||
// their own SSO setup.
|
||||
const { emailLanguage } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
@@ -101,6 +108,10 @@ export const sendOrganisationAccountLinkConfirmationEmail = async ({
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
// Deliberately uses the global Documenso mailer + internal sender (not the
|
||||
// organisation's configured email transport) so auth/SSO confirmation mail is
|
||||
// always sent from trusted, controlled infrastructure. See the note on the
|
||||
// getEmailContext call above.
|
||||
return mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
|
||||
@@ -28,3 +28,10 @@ export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
||||
* Used as an initial value for the frontend before values are loaded from the server.
|
||||
*/
|
||||
export const DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT = 5;
|
||||
|
||||
/**
|
||||
* Used as an initial value for the frontend before values are loaded from the server.
|
||||
*
|
||||
* 0 = Unlimited recipients.
|
||||
*/
|
||||
export const DEFAULT_RECIPIENT_COUNT = 20;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentSource, EnvelopeType, SubscriptionStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -69,6 +70,15 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
|
||||
};
|
||||
}
|
||||
|
||||
// Early return for organisations created ahead of a paid checkout that are still awaiting payment.
|
||||
if (isOrganisationPendingPayment(organisation)) {
|
||||
return {
|
||||
quota: INACTIVE_PLAN_LIMITS,
|
||||
remaining: INACTIVE_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Allow unlimited documents for users with an unlimited documents claim.
|
||||
// This also allows "free" claim users without subscriptions if they have this flag.
|
||||
if (organisation.organisationClaim.flags.unlimitedDocuments) {
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export type CreateCheckoutSessionOptions = {
|
||||
customerId: string;
|
||||
priceId: string;
|
||||
returnUrl: string;
|
||||
subscriptionMetadata?: Stripe.Metadata;
|
||||
};
|
||||
|
||||
export const createCheckoutSession = async ({
|
||||
customerId,
|
||||
priceId,
|
||||
returnUrl,
|
||||
subscriptionMetadata,
|
||||
}: CreateCheckoutSessionOptions) => {
|
||||
export const createCheckoutSession = async ({ customerId, priceId, returnUrl }: CreateCheckoutSessionOptions) => {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
@@ -27,9 +20,6 @@ export const createCheckoutSession = async ({
|
||||
success_url: `${returnUrl}?success=true`,
|
||||
cancel_url: `${returnUrl}?canceled=true`,
|
||||
billing_address_collection: 'required',
|
||||
subscription_data: {
|
||||
metadata: subscriptionMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
import { reconcileSeatBasedPlans } from './update-subscription-item-quantity';
|
||||
|
||||
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
||||
|
||||
export type SyncStripeCustomerSubscriptionOptions = {
|
||||
customerId: string;
|
||||
|
||||
/**
|
||||
* When true, the organisationClaim will not be synced.
|
||||
*
|
||||
* Used by the admin sync route to update only the Subscription
|
||||
* row while leaving claim entitlements untouched.
|
||||
*/
|
||||
bypassClaimUpdate?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Idempotent, convergent sync of a Stripe customer's subscription state into the local database.
|
||||
*
|
||||
* Fetches the current truth from Stripe and writes it locally, regardless of which
|
||||
* webhook event (or manual trigger) initiated the sync. Safe to run at any time,
|
||||
* any number of times.
|
||||
*
|
||||
* This function never creates organisations.
|
||||
*/
|
||||
export const syncStripeCustomerSubscription = async ({
|
||||
customerId,
|
||||
bypassClaimUpdate = false,
|
||||
}: SyncStripeCustomerSubscriptionOptions) => {
|
||||
// Note: `data.items.data.price.product` would exceed Stripe's 4-level expansion
|
||||
// limit on list endpoints, so the product is fetched separately when needed.
|
||||
const stripeSubscriptions = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'all',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const liveSubscriptions = stripeSubscriptions.data.filter((subscription) =>
|
||||
LIVE_SUBSCRIPTION_STATUSES.includes(subscription.status),
|
||||
);
|
||||
|
||||
if (liveSubscriptions.length > 1) {
|
||||
console.error(`Customer ${customerId} has ${liveSubscriptions.length} live subscriptions, expected at most 1`);
|
||||
|
||||
throw new Error(`Customer ${customerId} has multiple live subscriptions`);
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
console.error(`Organisation not found for customer ${customerId}, nothing to sync`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const liveSubscription = liveSubscriptions[0];
|
||||
|
||||
if (!liveSubscription) {
|
||||
await handleNoLiveSubscription({ organisation });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await handleLiveSubscription({
|
||||
organisation,
|
||||
subscription: liveSubscription,
|
||||
customerId,
|
||||
bypassClaimUpdate,
|
||||
});
|
||||
};
|
||||
|
||||
type OrganisationWithClaimAndSubscription = Prisma.OrganisationGetPayload<{
|
||||
include: { organisationClaim: true; subscription: true };
|
||||
}>;
|
||||
|
||||
type HandleNoLiveSubscriptionOptions = {
|
||||
organisation: OrganisationWithClaimAndSubscription;
|
||||
};
|
||||
|
||||
const handleNoLiveSubscription = async ({ organisation }: HandleNoLiveSubscriptionOptions) => {
|
||||
// Individuals get their subscription deleted so they can return to the free plan.
|
||||
if (organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
||||
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (organisation.subscription) {
|
||||
await tx.subscription.delete({
|
||||
where: {
|
||||
id: organisation.subscription.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
||||
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other cases, mark the subscription as inactive if a row exists.
|
||||
if (organisation.subscription) {
|
||||
await prisma.subscription.update({
|
||||
where: {
|
||||
id: organisation.subscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.INACTIVE,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type HandleLiveSubscriptionOptions = {
|
||||
organisation: OrganisationWithClaimAndSubscription;
|
||||
subscription: Stripe.Subscription;
|
||||
customerId: string;
|
||||
bypassClaimUpdate: boolean;
|
||||
};
|
||||
|
||||
const handleLiveSubscription = async ({
|
||||
organisation,
|
||||
subscription,
|
||||
customerId,
|
||||
bypassClaimUpdate,
|
||||
}: HandleLiveSubscriptionOptions) => {
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error(`No support for multiple subscription items on subscription ${subscription.id}`);
|
||||
|
||||
throw new Error(`No support for multiple subscription items on subscription ${subscription.id}`);
|
||||
}
|
||||
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
|
||||
const claim = await extractStripeClaim(subscriptionItem.price);
|
||||
|
||||
if (!claim) {
|
||||
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||
|
||||
throw new Error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||
}
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
const periodEnd =
|
||||
subscription.status === 'trialing' && subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: new Date(subscription.current_period_end * 1000);
|
||||
|
||||
const shouldUpdateClaim =
|
||||
!bypassClaimUpdate && organisation.organisationClaim.originalSubscriptionClaimId !== claim.id;
|
||||
|
||||
// Migrate the organisation type if it is no longer an individual/free plan.
|
||||
// Never demote an ORGANISATION back to PERSONAL.
|
||||
const shouldMigrateOrganisationType =
|
||||
organisation.type === OrganisationType.PERSONAL &&
|
||||
claim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
|
||||
claim.id !== INTERNAL_CLAIM_ID.FREE;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.upsert({
|
||||
where: {
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
create: {
|
||||
organisationId: organisation.id,
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscriptionItem.price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
update: {
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscriptionItem.price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldUpdateClaim) {
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: claim.id,
|
||||
...createOrganisationClaimUpsertData(claim),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldMigrateOrganisationType) {
|
||||
await tx.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
type: OrganisationType.ORGANISATION,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Detect a billing-period roll by comparing the persisted period end with
|
||||
// the freshly-fetched one — the convergent equivalent of the old
|
||||
// `previous_attributes.current_period_start` signal. On renewal, reconcile
|
||||
// the seat quantity and claim down to the actual member count. The reconcile
|
||||
// itself no-ops for non-seat/unlimited plans and non-ACTIVE subscriptions.
|
||||
const previousPeriodEnd = organisation.subscription?.periodEnd ?? null;
|
||||
|
||||
const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime();
|
||||
|
||||
if (hasPeriodAdvanced && !bypassClaimUpdate) {
|
||||
await reconcileSeatBasedPlans(organisation.id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
* The order of priority is:
|
||||
* 1. Price metadata
|
||||
* 2. Product metadata
|
||||
*
|
||||
* @returns The claimId or null if no claimId is found.
|
||||
*/
|
||||
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
|
||||
if (priceId.metadata.claimId) {
|
||||
return priceId.metadata.claimId;
|
||||
}
|
||||
|
||||
// Use the expanded product when available to avoid an extra API call.
|
||||
if (typeof priceId.product !== 'string' && 'metadata' in priceId.product) {
|
||||
return priceId.product.metadata.claimId || null;
|
||||
}
|
||||
|
||||
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
|
||||
|
||||
const product = await stripe.products.retrieve(productId);
|
||||
|
||||
return product.metadata.claimId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
*/
|
||||
export const extractStripeClaim = async (priceId: Stripe.Price) => {
|
||||
const claimId = await extractStripeClaimId(priceId);
|
||||
|
||||
if (!claimId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
|
||||
where: { id: claimId },
|
||||
});
|
||||
|
||||
if (!subscriptionClaim) {
|
||||
console.error(`Subscription claim ${claimId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscriptionClaim;
|
||||
};
|
||||
@@ -11,12 +11,14 @@ export type UpdateSubscriptionItemQuantityOptions = {
|
||||
subscriptionId: string;
|
||||
quantity: number;
|
||||
priceId: string;
|
||||
prorationBehaviour: 'always_invoice' | 'none';
|
||||
};
|
||||
|
||||
export const updateSubscriptionItemQuantity = async ({
|
||||
subscriptionId,
|
||||
quantity,
|
||||
priceId,
|
||||
prorationBehaviour,
|
||||
}: UpdateSubscriptionItemQuantityOptions) => {
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
@@ -26,7 +28,6 @@ export const updateSubscriptionItemQuantity = async ({
|
||||
throw new Error('Subscription does not contain required item');
|
||||
}
|
||||
|
||||
const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year');
|
||||
const oldQuantity = items[0].quantity;
|
||||
|
||||
if (oldQuantity === quantity) {
|
||||
@@ -38,13 +39,12 @@ export const updateSubscriptionItemQuantity = async ({
|
||||
id: item.id,
|
||||
quantity,
|
||||
})),
|
||||
proration_behavior: prorationBehaviour,
|
||||
// Need to "off_session" updates since adding 3DS will have payments
|
||||
// not pass through for these immediate invoices.
|
||||
off_session: true,
|
||||
};
|
||||
|
||||
// Only invoice immediately when changing the quantity of yearly item.
|
||||
if (hasYearlyItem) {
|
||||
subscriptionUpdatePayload.proration_behavior = 'always_invoice';
|
||||
}
|
||||
|
||||
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
||||
};
|
||||
|
||||
@@ -55,15 +55,19 @@ export const updateSubscriptionItemQuantity = async ({
|
||||
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
|
||||
* organisation claim represents unlimited seats.
|
||||
*
|
||||
* Organisations without a subscription (e.g. after being downgraded to the
|
||||
* free plan) can pass `null`, in which case the claim cap is enforced
|
||||
* directly without the seats-based exemption.
|
||||
*
|
||||
* Should only be called from grow paths (invite/add). Reducing operations
|
||||
* must never be gated by this check.
|
||||
*
|
||||
* @param subscription - The organisation's Stripe subscription.
|
||||
* @param subscription - The organisation's Stripe subscription, if any.
|
||||
* @param organisationClaim - The organisation claim.
|
||||
* @param quantity - The proposed total member + pending invite count.
|
||||
* @param quantity - The proposed total member count.
|
||||
*/
|
||||
export const assertMemberCountWithinCap = async (
|
||||
subscription: Subscription,
|
||||
subscription: Subscription | null,
|
||||
organisationClaim: OrganisationClaim,
|
||||
quantity: number,
|
||||
) => {
|
||||
@@ -75,10 +79,12 @@ export const assertMemberCountWithinCap = async (
|
||||
}
|
||||
|
||||
// Seats-based plans don't have a hard cap; Stripe meters the usage.
|
||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||
if (subscription) {
|
||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||
|
||||
if (isSeatsBased) {
|
||||
return;
|
||||
if (isSeatsBased) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (quantity > maximumMemberCount) {
|
||||
@@ -89,48 +95,127 @@ export const assertMemberCountWithinCap = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Syncs the organisation's member count with the Stripe subscription quantity.
|
||||
* Syncs the Stripe subscription quantity with the organisation's member count.
|
||||
*
|
||||
* No-ops for plans that are not seats-based, and for organisations with
|
||||
* unlimited seats (`organisationClaim.memberCount === 0`). Safe to call from
|
||||
* both grow and shrink paths.
|
||||
* This is a Stripe <-> Database sync operation.
|
||||
*
|
||||
* Note: `organisationClaim.memberCount` is the paid seat high-water mark for the
|
||||
* current billing period — the highest count we've already billed for.
|
||||
*
|
||||
* @param subscription - The subscription to sync the member count with.
|
||||
* @param organisationClaim - The organisation claim.
|
||||
* @param quantity - The new total member + pending invite count to sync.
|
||||
* @param quantity - The new total member count to sync.
|
||||
* @param mode - The member-count change that triggered the sync.
|
||||
*/
|
||||
export const syncMemberCountWithStripeSeatPlan = async (
|
||||
subscription: Subscription,
|
||||
organisationClaim: OrganisationClaim,
|
||||
quantity: number,
|
||||
mode: 'grow' | 'shrink',
|
||||
) => {
|
||||
// Infinite seats means no sync needed.
|
||||
// Unlimited seats — nothing to meter.
|
||||
if (organisationClaim.memberCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||
|
||||
// Only seat-based plans support seat syncing.
|
||||
if (!isSeatsBased) {
|
||||
return;
|
||||
}
|
||||
|
||||
appLog('BILLING', 'Updating seat based plan');
|
||||
// Whether to immediately invoice for new seats if the quantity is greater than
|
||||
// the high-water mark.
|
||||
const billsForNewSeats = mode === 'grow' && quantity > organisationClaim.memberCount;
|
||||
|
||||
appLog('BILLING', `Syncing seat based plan (${mode}, quantity ${quantity})`);
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: subscription.priceId,
|
||||
subscriptionId: subscription.planId,
|
||||
quantity,
|
||||
prorationBehaviour: billsForNewSeats ? 'always_invoice' : 'none',
|
||||
});
|
||||
|
||||
// Advance the high-water mark when billing for new seats reset it to the
|
||||
// actual count on reconcile. Re-adds and shrinks deliberately leave it so a
|
||||
// seat already paid for this period is never re-charged.
|
||||
if (billsForNewSeats) {
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
memberCount: quantity,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconciles the organisation claim seat counter, and the stripe quantity with the
|
||||
* actual member count.
|
||||
*
|
||||
* Uses the member count as the authoritative source of truth. Meaning:
|
||||
* - Update the organisation claim with the member count
|
||||
* - Update the Stripe subscription quantity to the member count
|
||||
*
|
||||
* This should only be called when the billing period rolls over.
|
||||
*/
|
||||
export const reconcileSeatBasedPlans = async (organisationId: string) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation || !organisation.subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { subscription, organisationClaim } = organisation;
|
||||
|
||||
// Unlimited seats — nothing to meter.
|
||||
if (organisationClaim.memberCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||
|
||||
// Only seat-based plans support seat syncing.
|
||||
if (!isSeatsBased) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memberCount = await prisma.organisationMember.count({
|
||||
where: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
// An organisation always retains its owner; never write the unlimited sentinel.
|
||||
if (memberCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: subscription.priceId,
|
||||
subscriptionId: subscription.planId,
|
||||
quantity: memberCount,
|
||||
prorationBehaviour: 'none',
|
||||
});
|
||||
|
||||
// This should be automatically updated after the Stripe webhook is fired
|
||||
// but we just manually adjust it here as well to avoid any race conditions.
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
memberCount: quantity,
|
||||
memberCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,17 +2,28 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { onSubscriptionCreated } from './on-subscription-created';
|
||||
import { onSubscriptionDeleted } from './on-subscription-deleted';
|
||||
import { onSubscriptionUpdated } from './on-subscription-updated';
|
||||
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Events that trigger a sync of the customer's subscription state.
|
||||
*
|
||||
* The event payload is never trusted beyond extracting the customer ID,
|
||||
* the sync function fetches the current truth from Stripe.
|
||||
*/
|
||||
const SYNCED_EVENT_TYPES: string[] = [
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated',
|
||||
'customer.subscription.deleted',
|
||||
'checkout.session.completed',
|
||||
'invoice.payment_succeeded',
|
||||
'invoice.payment_failed',
|
||||
];
|
||||
|
||||
export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
@@ -60,69 +71,45 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
|
||||
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
|
||||
/**
|
||||
* Notes:
|
||||
* - Dropped invoice.payment_succeeded
|
||||
* - Dropped invoice.payment_failed
|
||||
* - Dropped checkout-session.completed
|
||||
*/
|
||||
return await match(event.type)
|
||||
.with('customer.subscription.created', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
if (!SYNCED_EVENT_TYPES.includes(event.type)) {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionCreated({ subscription });
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const eventObject = event.data.object as { customer?: string | Stripe.Customer | null };
|
||||
|
||||
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
|
||||
status: 200,
|
||||
});
|
||||
})
|
||||
.with('customer.subscription.updated', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const customerId = typeof eventObject.customer === 'string' ? eventObject.customer : eventObject.customer?.id;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const previousAttributes = event.data.previous_attributes as Partial<Stripe.Subscription> | null;
|
||||
if (!customerId) {
|
||||
console.error(`No customer found on ${event.type} event ${event.id}, nothing to sync`);
|
||||
|
||||
await onSubscriptionUpdated({ subscription, previousAttributes });
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
|
||||
status: 200,
|
||||
});
|
||||
})
|
||||
.with('customer.subscription.deleted', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await syncStripeCustomerSubscription({ customerId });
|
||||
|
||||
await onSubscriptionDeleted({ subscription });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
})
|
||||
.otherwise(() => {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof Response) {
|
||||
const message = await err.json();
|
||||
console.error(message);
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import {
|
||||
createOrganisation,
|
||||
createOrganisationClaimUpsertData,
|
||||
} from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type { InternalClaim, StripeOrganisationCreateMetadata } from '@documenso/lib/types/subscription';
|
||||
import { INTERNAL_CLAIM_ID, ZStripeOrganisationCreateMetadataSchema } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { extractStripeClaim } from './on-subscription-updated';
|
||||
|
||||
export type OnSubscriptionCreatedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Todo: We might want to pull this into a job so we can do steps. Since if organisation creation passes but
|
||||
* fails after this would be automatically rerun by Stripe, which means duplicate organisations can be
|
||||
* potentially created.
|
||||
*/
|
||||
export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCreatedOptions) => {
|
||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
// Todo: logging
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error('No support for multiple items');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No support for multiple items',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
const claim = await extractStripeClaim(subscriptionItem.price);
|
||||
|
||||
// Todo: logging
|
||||
if (!claim) {
|
||||
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Subscription claim on ${subscriptionItem.price.id} not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const organisationCreateData = subscription.metadata?.organisationCreateData;
|
||||
|
||||
// A new subscription can be for an existing organisation or a new one.
|
||||
const organisationId = organisationCreateData
|
||||
? await handleOrganisationCreate({
|
||||
customerId,
|
||||
claim,
|
||||
unknownCreateData: organisationCreateData,
|
||||
})
|
||||
: await handleOrganisationUpdate({
|
||||
customerId,
|
||||
claim,
|
||||
});
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
const periodEnd =
|
||||
subscription.status === 'trialing' && subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: new Date(subscription.current_period_end * 1000);
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: {
|
||||
organisationId,
|
||||
},
|
||||
create: {
|
||||
organisationId,
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
update: {
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type HandleOrganisationCreateOptions = {
|
||||
customerId: string;
|
||||
claim: InternalClaim;
|
||||
unknownCreateData: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the creation of an organisation.
|
||||
*/
|
||||
const handleOrganisationCreate = async ({ customerId, claim, unknownCreateData }: HandleOrganisationCreateOptions) => {
|
||||
let organisationCreateFlowData: StripeOrganisationCreateMetadata | null = null;
|
||||
|
||||
const parseResult = ZStripeOrganisationCreateMetadataSchema.safeParse(JSON.parse(unknownCreateData));
|
||||
|
||||
if (!parseResult.success) {
|
||||
console.error('Invalid organisation create flow data');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid organisation create flow data',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
organisationCreateFlowData = parseResult.data;
|
||||
|
||||
const createdOrganisation = await createOrganisation({
|
||||
name: organisationCreateFlowData.organisationName,
|
||||
userId: organisationCreateFlowData.userId,
|
||||
type: OrganisationType.ORGANISATION,
|
||||
customerId,
|
||||
claim,
|
||||
});
|
||||
|
||||
return createdOrganisation.id;
|
||||
};
|
||||
|
||||
type HandleOrganisationUpdateOptions = {
|
||||
customerId: string;
|
||||
claim: InternalClaim;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the updating an exist organisation claims.
|
||||
*/
|
||||
const handleOrganisationUpdate = async ({ customerId, claim }: HandleOrganisationUpdateOptions) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Todo: logging
|
||||
if (organisation.subscription && organisation.subscription.status !== SubscriptionStatus.INACTIVE) {
|
||||
console.error('Organisation already has an active subscription');
|
||||
|
||||
// This should never happen
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation already has an active subscription`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let newOrganisationType: OrganisationType = OrganisationType.ORGANISATION;
|
||||
|
||||
// Keep the organisation as personal if the claim is for an individual.
|
||||
if (organisation.type === OrganisationType.PERSONAL && claim.id === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
||||
newOrganisationType = OrganisationType.PERSONAL;
|
||||
}
|
||||
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
type: newOrganisationType,
|
||||
organisationClaim: {
|
||||
update: {
|
||||
originalSubscriptionClaimId: claim.id,
|
||||
...createOrganisationClaimUpsertData(claim),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return organisation.id;
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
|
||||
import { extractStripeClaimId } from './on-subscription-updated';
|
||||
|
||||
export type OnSubscriptionDeletedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
|
||||
const existingSubscription = await prisma.subscription.findUnique({
|
||||
where: {
|
||||
planId: subscription.id,
|
||||
},
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// If the subscription doesn't exist, we don't need to do anything.
|
||||
if (!existingSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionClaimId = await extractClaimIdFromStripeSubscription(subscription);
|
||||
|
||||
// Individuals get their subscription deleted so they can return to the
|
||||
// free plan.
|
||||
if (subscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.delete({
|
||||
where: {
|
||||
id: existingSubscription.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: existingSubscription.organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
||||
...createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other cases, mark the subscription as inactive since
|
||||
// they should still have a "Personal" account.
|
||||
await prisma.subscription.update({
|
||||
where: {
|
||||
id: existingSubscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.INACTIVE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the claim ID from the Stripe subscription.
|
||||
*
|
||||
* Returns `null` if no claim ID found.
|
||||
*/
|
||||
const extractClaimIdFromStripeSubscription = async (subscription: Stripe.Subscription) => {
|
||||
const deletedItem = subscription.items.data[0];
|
||||
|
||||
if (!deletedItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await extractStripeClaimId(deletedItem.price);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,182 +0,0 @@
|
||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type OnSubscriptionUpdatedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
previousAttributes: Partial<Stripe.Subscription> | null;
|
||||
};
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const onSubscriptionUpdated = async ({ subscription, previousAttributes }: OnSubscriptionUpdatedOptions) => {
|
||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
// Todo: logging
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error('No support for multiple items');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No support for multiple items',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
organisation.subscription &&
|
||||
organisation.subscription.status !== SubscriptionStatus.INACTIVE &&
|
||||
organisation.subscription.planId !== subscription.id
|
||||
) {
|
||||
console.error('[WARNING]: Organisation might have two subscriptions');
|
||||
}
|
||||
|
||||
const previousItem = previousAttributes?.items?.data[0];
|
||||
const updatedItem = subscription.items.data[0];
|
||||
|
||||
const previousSubscriptionClaimId = previousItem ? await extractStripeClaimId(previousItem.price) : null;
|
||||
const updatedSubscriptionClaim = await extractStripeClaim(updatedItem.price);
|
||||
|
||||
if (!updatedSubscriptionClaim) {
|
||||
console.error(`Subscription claim on ${updatedItem.price.id} not found`);
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Subscription claim on ${updatedItem.price.id} not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const newClaimFound = previousSubscriptionClaimId !== updatedSubscriptionClaim.id;
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
const periodEnd =
|
||||
subscription.status === 'trialing' && subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: new Date(subscription.current_period_end * 1000);
|
||||
|
||||
// Migrate the organisation type if it is no longer an individual plan.
|
||||
if (
|
||||
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
|
||||
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.FREE &&
|
||||
organisation.type === OrganisationType.PERSONAL
|
||||
) {
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
type: OrganisationType.ORGANISATION,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.update({
|
||||
where: {
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
data: {
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
|
||||
// Override current organisation claim if new one is found.
|
||||
if (newClaimFound) {
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: updatedSubscriptionClaim.id,
|
||||
...createOrganisationClaimUpsertData(updatedSubscriptionClaim),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
* The order of priority is:
|
||||
* 1. Price metadata
|
||||
* 2. Product metadata
|
||||
*
|
||||
* @returns The claimId or null if no claimId is found.
|
||||
*/
|
||||
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
|
||||
if (priceId.metadata.claimId) {
|
||||
return priceId.metadata.claimId;
|
||||
}
|
||||
|
||||
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
|
||||
|
||||
const product = await stripe.products.retrieve(productId);
|
||||
|
||||
return product.metadata.claimId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
*/
|
||||
export const extractStripeClaim = async (priceId: Stripe.Price) => {
|
||||
const claimId = await extractStripeClaimId(priceId);
|
||||
|
||||
if (!claimId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
|
||||
where: { id: claimId },
|
||||
});
|
||||
|
||||
if (!subscriptionClaim) {
|
||||
console.error(`Subscription claim ${claimId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscriptionClaim;
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Img, Link } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { getSafeBrandingUrl } from '../utils/branding-url';
|
||||
|
||||
export type TemplateBrandingLogoProps = {
|
||||
assetBaseUrl: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the email logo.
|
||||
*
|
||||
* - When custom branding is enabled with a logo, the branding logo is shown.
|
||||
* If a safe (http/https) Brand Website is configured, the logo links to it.
|
||||
* - Otherwise the Documenso logo is shown.
|
||||
*/
|
||||
export const TemplateBrandingLogo = ({ assetBaseUrl, className = 'mb-4 h-6' }: TemplateBrandingLogoProps) => {
|
||||
const branding = useBranding();
|
||||
|
||||
const hasCustomBrandingLogo = branding.brandingEnabled && Boolean(branding.brandingLogo);
|
||||
|
||||
if (!hasCustomBrandingLogo) {
|
||||
const documensoLogoUrl = new URL('/static/logo.png', assetBaseUrl).toString();
|
||||
|
||||
return <Img src={documensoLogoUrl} alt="Documenso Logo" className={className} />;
|
||||
}
|
||||
|
||||
const brandingLogo = <Img src={branding.brandingLogo} alt="Branding Logo" className={className} />;
|
||||
|
||||
const safeBrandingUrl = getSafeBrandingUrl(branding.brandingUrl);
|
||||
|
||||
if (!safeBrandingUrl) {
|
||||
return brandingLogo;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={safeBrandingUrl} target="_blank">
|
||||
{brandingLogo}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateBrandingLogo;
|
||||
@@ -2,16 +2,32 @@ import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Link, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { getSafeBrandingUrl } from '../utils/branding-url';
|
||||
|
||||
export type TemplateFooterProps = {
|
||||
isDocument?: boolean;
|
||||
reportUrl?: string;
|
||||
};
|
||||
|
||||
export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterProps) => {
|
||||
const branding = useBranding();
|
||||
|
||||
const safeBrandingUrl = branding.brandingEnabled ? getSafeBrandingUrl(branding.brandingUrl) : null;
|
||||
|
||||
return (
|
||||
<Section>
|
||||
{reportUrl && (
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
<Trans>
|
||||
Did not expect this email?{' '}
|
||||
<Link className="text-[#7AC455]" href={reportUrl}>
|
||||
Click here to report the sender
|
||||
</Link>
|
||||
. Never sign a document you don't recognize or weren't expecting.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isDocument && !branding.brandingHidePoweredBy && (
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
<Trans>
|
||||
@@ -37,6 +53,14 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{branding.brandingEnabled && safeBrandingUrl && (
|
||||
<Text className="my-8 text-slate-400 text-sm">
|
||||
<Link href={safeBrandingUrl} target="_blank">
|
||||
{safeBrandingUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!branding.brandingEnabled && (
|
||||
<Text className="my-8 text-slate-400 text-sm">
|
||||
Documenso, Inc.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user